From 1ffaf39c04b9049306462ed1997dad01d25fae3c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 11:37:53 +0700 Subject: [PATCH 01/18] docs(release): add external pilot report template Signed-off-by: Jeremi Joslin --- release/conformance/integrations/README.md | 6 ++ .../integrations/pilot-report.template.md | 79 +++++++++++++++++++ release/scripts/test_integration_e2_runner.py | 34 ++++++++ 3 files changed, 119 insertions(+) create mode 100644 release/conformance/integrations/pilot-report.template.md diff --git a/release/conformance/integrations/README.md b/release/conformance/integrations/README.md index 71584a0b..344a9733 100644 --- a/release/conformance/integrations/README.md +++ b/release/conformance/integrations/README.md @@ -163,6 +163,12 @@ non-closing evidence. The validator accepts `status: passed` only when every applicable case and teardown is recorded as passed; maintainer review still establishes whether the recorded hashes correspond to the restricted evidence. +After an independent operator completes the frozen journey, use +[`pilot-report.template.md`](pilot-report.template.md) to publish the bounded +outcome, findings, and public triage links without copying restricted evidence +into the repository. A plan or dry run is not evidence, and one pilot is not +proof of broad production readiness. + ## Review the source packet - [`profiles/opencrvs-dci-v1.9.profile.json`](profiles/opencrvs-dci-v1.9.profile.json) diff --git a/release/conformance/integrations/pilot-report.template.md b/release/conformance/integrations/pilot-report.template.md new file mode 100644 index 00000000..3baf9eb4 --- /dev/null +++ b/release/conformance/integrations/pilot-report.template.md @@ -0,0 +1,79 @@ +# External integration pilot report + +Use this template only after an independent operator has completed the frozen +pilot journey. Publish a concise account of the outcome and link the validated, +sanitized result. Do not include credentials, network origins, operator or +source identifiers, record identifiers, raw audits, private evidence, or links +to restricted evidence. + +Plans, dry runs, fixture runs, and source-built branch runs are not pilot +evidence. One completed pilot is not proof of broad production readiness. + +## Closing evidence + +- Sanitized run result: [link to the schema-valid public result] +- Frozen Registry Stack candidate: [link to the published release] +- Immutable Solmara release and release-pin evidence: [link] +- Independent operator: [confirmed or not confirmed; do not identify the + operator] +- Owner-approved non-production source: [confirmed or not confirmed; do not + identify the owner or source] +- Integration profile and reviewed operation: [safe public summary; the exact + values remain in the sanitized result] +- Supported topology and journey: [safe public summary] +- Generated Registry YAML edited by hand: [no, or explain why the pilot does + not close] +- Overall outcome: [passed, failed, or incomplete] + +Issue closure still requires a frozen published candidate, an immutable Solmara +release pinned to that candidate, an independent operator, and an +owner-approved source. A plan or maintainer-run substitute cannot satisfy any +of these requirements. + +## What the pilot did and did not prove + +The pilot showed: [summarize only the bounded journey completed from published +artifacts, including offline checks and the selected Relay and, when +applicable, Notary flow]. + +The pilot did not show: [summarize excluded versions, operations, topologies, +scale, availability, recovery, or other support claims]. It is not upstream +product certification, general country-system conformance, a security audit, +or evidence that Registry Stack is broadly production-ready. + +## Findings and triage + +Use safe summaries and public issue or pull-request links. Record `not +exercised` rather than inferring success. + +| Area | Exercised | Sanitized outcome | Public triage links | +|---|---|---|---| +| Operator handoff and independence | [yes/no] | [summary] | [links or none] | +| Install or deployment | [yes/no] | [summary] | [links or none] | +| Configuration and environment binding | [yes/no] | [summary] | [links or none] | +| Diagnostics and ordinary source failures | [yes/no] | [summary] | [links or none] | +| Upgrade or rollback | [yes/no] | [summary] | [links or none] | +| Restart, teardown, and other operations | [yes/no] | [summary] | [links or none] | +| Security boundaries and redaction | [yes/no] | [summary] | [links or none] | +| Documentation and operator journey | [yes/no] | [summary] | [links or none] | + +### Blocking findings + +List each blocker with its public triage issue, fix, and independent +re-verification link. Unresolved blockers keep the pilot open. + +- [none, or safe finding summary and links] + +### Accepted limitations and narrowed support + +List each owner-approved limitation or narrowed support claim with its public +decision, operator-guidance update, support-wording update, and review trigger. +Do not use this section to waive a blocker silently. + +- [none, or safe limitation summary and links] + +## Conclusion + +[State whether the bounded pilot closes the external-pilot gate, remains +blocked, or requires a narrower support claim. Reiterate any unexercised area +that affects the conclusion.] diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index 77061386..c6ea0445 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -268,6 +268,40 @@ def write_result(self, result: dict[str, object]) -> Path: def test_checked_in_packet_is_closed_and_valid(self) -> None: self.module.validate_packet() + def test_pilot_report_template_preserves_the_public_contract(self) -> None: + readme = (self.module.CONFIG_DIR / "README.md").read_text(encoding="utf-8") + template = ( + self.module.CONFIG_DIR / "pilot-report.template.md" + ).read_text(encoding="utf-8") + normalized_template = " ".join(template.split()) + self.assertIn("(pilot-report.template.md)", readme) + self.assertIn( + "Do not include credentials, network origins, operator or source " + "identifiers, record identifiers, raw audits, private evidence, or " + "links to restricted evidence.", + normalized_template, + ) + for text in ( + "Sanitized run result:", + "Plans, dry runs", + "One completed pilot is not proof of broad production readiness.", + "Frozen Registry Stack candidate:", + "Immutable Solmara release", + "Independent operator:", + "Owner-approved non-production source:", + "### Blocking findings", + "### Accepted limitations and narrowed support", + "Operator handoff and independence", + "Install or deployment", + "Configuration and environment binding", + "Diagnostics and ordinary source failures", + "Upgrade or rollback", + "Restart, teardown, and other operations", + "Security boundaries and redaction", + "Documentation and operator journey", + ): + self.assertIn(text, template) + def test_nested_result_objects_must_remain_closed(self) -> None: schema = self.module.load_json(self.module.SCHEMA_PATH) schema["$defs"]["case"]["additionalProperties"] = True From 5d9b8bacb3385ef3deba47b44d763bdac4cd71ef Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 11:43:57 +0700 Subject: [PATCH 02/18] docs(registryctl): add OpenSPP owner holdout runbook Signed-off-by: Jeremi Joslin --- .../project-authoring/openspp-exact/README.md | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md new file mode 100644 index 00000000..60c90e2a --- /dev/null +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -0,0 +1,212 @@ +# OpenSPP exact-lookup owner holdout + +This workspace is offline-fixture-only pending the OpenSPP product-owner proof +in [GH#357](https://github.com/registrystack/registry-stack/issues/357). +It is not a supported public starter, evidence of live OpenSPP compatibility, +or an E2 interoperability claim. + +The committed HTTP path, query, fields, version label, identifiers, and +responses describe a synthetic wire shape. +They do not assert that an OpenSPP release exposes this API. + +## Run the offline workspace + +Run these commands from the repository root: + +```sh +cd crates/registryctl/tests/fixtures/project-authoring/openspp-exact +registryctl test --project-dir . --integration individual --fixture social-registry-match --trace +registryctl test --project-dir . --integration individual --fixture social-registry-match --watch +registryctl test --project-dir . +registryctl check --project-dir . --environment local --explain +registryctl build --project-dir . --environment local +``` + +The watch command runs the selected fixture once, waits for an authored file to +change, and reruns it. +Press `Ctrl+C` after observing the rerun. +The other commands exit after reporting their result. + +`test` uses synthetic fixtures and an implementation-owned offline binding. +It does not contact the origin in `environments/local.yaml`. +`check` and `build` validate that environment, but the `.invalid` origins and +placeholder secret references are deliberately non-deployable. + +## Prepare the owner-run workspace + +Use a private copy of this workspace from one tagged Registry Stack release +that contains both +[PR #355](https://github.com/registrystack/registry-stack/pull/355) and +[PR #364](https://github.com/registrystack/registry-stack/pull/364). +Record the tag and use the matching `registryctl`, Relay image, Notary image, +and published image digests. +Atomically pin the candidate adopter deployment to those artifacts. +Do not use a source build, a branch image, the retired monorepo `lab/`, shared +Relay or Notary state, or a hand-edited generated YAML file. +Each Relay authority needs its own dedicated Notary and Notary-owned PostgreSQL +state. + +Select and record one exact OpenSPP version and one read-only operation before +editing the workspace. +Replace every fictional assumption in the private copy: + +| File | Fields to replace and review | +| --- | --- | +| `integrations/individual/integration.yaml` | Replace `id` and the `source.versions.unverified` fixture label with the reviewed integration identity and exact selected OpenSPP version. Classify that version under `source.versions.tested` only after the owner evidence is accepted. | +| `integrations/individual/integration.yaml` | Replace the input name, type, length, pattern, HTTP method, relative path, query, no-match statuses, authentication type, response format, and response-size bound with the selected read-only operation's contract. | +| `integrations/individual/integration.yaml` | Replace output names, types, lengths, and `x-registry-source` pointers. Reassess the `ambiguity` and `subject_mismatch` not-applicable rationales. Add fixtures when either outcome is applicable. | +| `integrations/individual/fixtures/*.yaml` | Replace the synthetic selector, request expectation, source response, normalized outputs, outcome, and claims. Keep all retained fixture data synthetic. | +| `registry-stack.yaml` | Replace `registry.id`, the service id, `purpose`, `legal_basis`, `consent`, `access.scopes`, the consultation input mapping, claim ids and declarations, disclosure modes, and credential profile with reviewed owner decisions. | +| `environments/.yaml` | Replace `integrations.individual.source.origin`, `integrations.individual.source.credential.token.secret`, and `integrations.individual.source.credential.generation` with the private OpenSPP source binding. | +| `environments/.yaml` | Replace every `issuance` field and the `callers.programme-service` map key, API-key fingerprint secret reference, and scopes with candidate-owned values. | +| `environments/.yaml` | Replace every `relay`, `notary_relay`, and `deployment` field. Add the candidate-required state bindings. Keep all deployment values outside public evidence. | + +Use bounded one-request HTTP authoring when it expresses the selected operation. +Use reviewed Rhai only when the operation requires project-owned traversal or +normalization. +Do not add OpenSPP-specific Rust dispatch, restore a Notary source connector, +add an integration sidecar, or edit generated runtime configuration. + +Rerun the focused trace, complete offline fixture suite, check, and build after +every contract change. +For the private owner environment, replace `local` in the check and build +commands with the exact environment filename without `.yaml`. +Activate the generated Relay and Notary Config Bundle inputs through the +documented path for the selected tagged candidate, without modifying generated +files. + +## Prepare the governed live evaluation + +Create the request and expected-result files under `.registry-stack/` in the +private workspace. +That directory is ignored by this workspace. +Use only an owner-approved non-production record. +The following request shape matches the committed synthetic project, so replace +the identifier scheme, value, purpose, and claim ids when the authored project +changes: + +```json +{ + "target": { + "type": "Person", + "identifiers": [ + { + "scheme": "openspp_individual_id", + "value": "IND-AB12CD34" + } + ] + }, + "claims": [ + "social-registry-record-exists", + "social-registry-active", + "programme-code", + "household-reference" + ], + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "social-programme-verification" +} +``` + +The expected-result file must contain only a `claims` object. +Its keys must exactly match the request's claim ids. +Each claim value must be a non-empty object containing only the exact `value`, +`satisfied`, or `disclosure` fields that the owner intends to verify. +This example reflects only the committed synthetic fixture and must be replaced +with reviewed expectations for the owner-approved record: + +```json +{ + "claims": { + "social-registry-record-exists": { + "satisfied": true, + "disclosure": "predicate" + }, + "social-registry-active": { + "satisfied": true, + "disclosure": "predicate" + }, + "programme-code": { + "value": "SUPPORT", + "disclosure": "value" + }, + "household-reference": { + "disclosure": "redacted" + } + } +} +``` + +The governed live test reads exactly four process variables: + +| Variable | Owner-supplied value | +| --- | --- | +| `REGISTRY_STACK_LIVE_NOTARY_ORIGIN` | The non-production candidate Notary origin. Use HTTPS with no path, query, user information, or fragment. HTTP is accepted only for a loopback origin. | +| `REGISTRY_STACK_LIVE_NOTARY_API_KEY` | The deployed Notary caller API key. This is not the OpenSPP source credential. Load it from the owner's secret mechanism, never from a command argument or tracked file. | +| `REGISTRY_STACK_LIVE_REQUEST_FILE` | The absolute path to the strict JSON evaluation request. Use a bounded regular file, not a symbolic link. | +| `REGISTRY_STACK_LIVE_EXPECTED_FILE` | The absolute path to the strict JSON expected-result file. Use a bounded regular file, not a symbolic link. | + +Load the API key into the process environment without echoing it, export the +other three variables, and run: + +```sh +registryctl test --project-dir . --environment --live +``` + +The command refuses a production environment. +It first reruns the offline fixtures, checks the candidate Notary's Relay +readiness, and then sends one request to the governed Notary evaluation path. +It requires the returned claim fields to match the expected file and requires +source-backed Relay provenance. +It never sends the request directly to OpenSPP. +Unset all four variables after the run. + +Repeat the live command with separate private request and expected-result files +for each owner-approved match or no-match case. +Use the candidate's governed denial and failure checks for authorization denial +before source access and bounded source failures. +Prove ambiguity and subject mismatch with live cases when the selected operation +makes them applicable, or retain an owner-reviewed not-applicable rationale +that matches the real response contract. + +## Evidence and redaction checklist + +Before asking to close GH#357, record: + +- [ ] Exact OpenSPP version and read-only operation, including which + country-specific mapping files changed. +- [ ] Exact Registry Stack tag, `registryctl` version, adopter commit, Relay and + Notary image digests, and per-authority Notary and PostgreSQL topology. +- [ ] The commands and pass or fail outcomes for focused trace, watch, complete + offline fixtures, check, build, activation, and governed live evaluation. +- [ ] Match and no-match outcomes, plus applicable ambiguity or subject-mismatch + behavior or reviewed reasons that they are not applicable. +- [ ] Authorization denial before source access, bounded failure, disclosure, + redaction, and source-backed provenance outcomes. +- [ ] Confirmation that generated Relay and Notary files were activated + unchanged. +- [ ] Confirmation that no OpenSPP-specific Registry Stack Rust, Notary source + connector, integration sidecar, or direct registry test path was needed. +- [ ] Confirmation that changing the country mapping required only reviewed + project-authored files and fixtures. +- [ ] Any gap fixed in the generic authoring or runtime model, or recorded as an + explicit limitation before the 1.0 decision. + +Before retaining or publishing evidence: + +- [ ] Remove Notary and OpenSPP credentials, secret values, private origins, + private network details, raw selectors, subject identifiers, source rows, + source response bodies, and deployment-specific file paths. +- [ ] Do not retain shell history, environment dumps, packet captures, verbose + HTTP transcripts, or logs that can contain those values. +- [ ] Keep public evidence to tested versions, the operation description, + commands, outcomes, limitations, redaction checks, and non-sensitive + artifact digests. +- [ ] Have the OpenSPP owner review the redacted evidence before publication. + +A successful owner run can support only the wording +`live-authoring-validated OpenSPP starter` for the exact tested version and +operation. +It does not establish E2 interoperability unless the complete common +integration matrix also passes. +Until that evidence exists, this workspace remains an offline fixture and no +public OpenSPP support claim follows from it. From 50dc91a6ab7a8f13c0d8f4feba3df230a2cfd814 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 11:46:45 +0700 Subject: [PATCH 03/18] docs: clarify Solmara reader gate Signed-off-by: Jeremi Joslin --- .../content/docs/tutorials/first-run-with-solmara-lab.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx index ad0ec17e..b33e3dfc 100644 --- a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx +++ b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx @@ -38,10 +38,10 @@ Do not use the generated local keys in production. For a standalone local project generated from your own data, use the `registryctl` tutorials: [run a protected registry API](../publish-spreadsheet-secured-registry-api/) or [evaluate a claim with Registry Notary](../verify-claim-registry-api/). Clone Solmara Lab when you -want the full multi-service country demo. The hosted path remains visible for inspection, but its -credential continuation is held by [GH#330](https://github.com/registrystack/registry-stack/issues/330) -and the incomplete [GH#198](https://github.com/registrystack/registry-stack/issues/198) -fresh-reader run. +want the full multi-service country demo. Solmara Lab does not yet have an immutable release. The +final 1.0 release-candidate reader run remains gated in +[GH#198](https://github.com/registrystack/registry-stack/issues/198) on a tagged Registry Stack +candidate and an atomically pinned Solmara Lab release. ## Prerequisites From 36459f207160276f2cbfed2b1012ed32810d5f70 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 21:51:13 +0700 Subject: [PATCH 04/18] fix: correct adopter exercise evidence semantics Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 24 +++++++--- .../src/project_authoring/tests.rs | 47 +++++++++++++++++++ .../project-authoring/openspp-exact/README.md | 4 ++ .../integrations/pilot-report.template.md | 8 ++-- release/scripts/test_integration_e2_runner.py | 2 +- 5 files changed, 72 insertions(+), 13 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 149557e5..e819d01a 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -606,10 +606,7 @@ fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result Result ureq::Request { + ureq::post(endpoint.as_str()) + .set("content-type", "application/json") + .set("accept", registry_notary_core::FORMAT_CLAIM_RESULT_JSON) + .set("x-api-key", api_key) +} + +fn governed_live_fixture_report(returned_claims: Vec) -> FixtureReport { + FixtureReport { integration: "governed-notary-relay".to_string(), fixture: "live-evaluation".to_string(), inputs: Vec::new(), calls: vec!["notary-evaluation".to_string()], outputs: Vec::new(), claims: returned_claims, - outcome: Some("match".to_string()), + // Claim expectations prove disclosed values, not the source-level + // match or no-match outcome that produced them. + outcome: None, expected_error: None, source_access: None, passed: true, failure: None, - }) + } } fn validate_live_relay_readiness(origin: &url::Url) -> Result<()> { diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 750ec239..a5982b1f 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -683,6 +683,53 @@ outputs: ); } + #[test] + fn governed_live_request_negotiates_the_claim_result_media_type() { + let endpoint = + url::Url::parse("http://127.0.0.1:8080/v1/evaluations").expect("endpoint parses"); + let request = governed_live_evaluation_request(&endpoint, "test-api-key"); + + assert_eq!(request.header("content-type"), Some("application/json")); + assert_eq!( + request.header("accept"), + Some(registry_notary_core::FORMAT_CLAIM_RESULT_JSON) + ); + } + + #[test] + fn governed_live_report_does_not_infer_a_source_outcome() { + let claims = vec!["record-exists".to_string()]; + let expected = json!({ + "claims": { + "record-exists": { + "satisfied": false, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [{ + "claim_id": "record-exists", + "satisfied": false, + "disclosure": "predicate", + "provenance": { "used": { "relay_consultation_count": 1 } }, + }], + }); + let returned_claims = + validate_live_response(&response, &claims, &expected).expect("no-match result passes"); + let report = governed_live_fixture_report(returned_claims); + + assert_eq!(report.claims, claims); + assert_eq!(report.outcome, None); + assert!( + serde_json::to_value(report) + .expect("report serializes") + .get("outcome") + .is_none(), + "neutral live evidence must not serialize a source outcome" + ); + } + #[test] fn cel_consultation_roots_ignore_string_literals() { assert_eq!( diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 60c90e2a..1de395fd 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -107,6 +107,10 @@ changes: } ``` +`registryctl` sends +`Accept: application/vnd.registry-notary.claim-result+json`, matching the +request's `format`, and validates that claim-result envelope. + The expected-result file must contain only a `claims` object. Its keys must exactly match the request's claim ids. Each claim value must be a non-empty object containing only the exact `value`, diff --git a/release/conformance/integrations/pilot-report.template.md b/release/conformance/integrations/pilot-report.template.md index 3baf9eb4..aa4a0f06 100644 --- a/release/conformance/integrations/pilot-report.template.md +++ b/release/conformance/integrations/pilot-report.template.md @@ -13,7 +13,6 @@ evidence. One completed pilot is not proof of broad production readiness. - Sanitized run result: [link to the schema-valid public result] - Frozen Registry Stack candidate: [link to the published release] -- Immutable Solmara release and release-pin evidence: [link] - Independent operator: [confirmed or not confirmed; do not identify the operator] - Owner-approved non-production source: [confirmed or not confirmed; do not @@ -25,10 +24,9 @@ evidence. One completed pilot is not proof of broad production readiness. not close] - Overall outcome: [passed, failed, or incomplete] -Issue closure still requires a frozen published candidate, an immutable Solmara -release pinned to that candidate, an independent operator, and an -owner-approved source. A plan or maintainer-run substitute cannot satisfy any -of these requirements. +Issue closure still requires a frozen published candidate, an independent +operator, and an owner-approved source. A plan or maintainer-run substitute +cannot satisfy any of these requirements. ## What the pilot did and did not prove diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index c6ea0445..422fbda2 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -281,12 +281,12 @@ def test_pilot_report_template_preserves_the_public_contract(self) -> None: "links to restricted evidence.", normalized_template, ) + self.assertNotIn("Solmara", template) for text in ( "Sanitized run result:", "Plans, dry runs", "One completed pilot is not proof of broad production readiness.", "Frozen Registry Stack candidate:", - "Immutable Solmara release", "Independent operator:", "Owner-approved non-production source:", "### Blocking findings", From c79e29e588a5ba146d290bceb209a64e21cdf0d8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 22:47:49 +0700 Subject: [PATCH 05/18] fix governed integration evidence contracts Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 32 +++++++- .../src/project_authoring/tests.rs | 74 ++++++++++++++++++- .../project-authoring/openspp-exact/README.md | 11 ++- .../tutorials/first-run-with-solmara-lab.mdx | 9 ++- .../integrations/pilot-report.template.md | 3 +- release/scripts/test_integration_e2_runner.py | 5 ++ 6 files changed, 122 insertions(+), 12 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index e819d01a..21aceeff 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -721,6 +721,27 @@ fn validate_live_response( let result = result .as_object() .ok_or_else(|| anyhow!("governed Notary result must be an object"))?; + if result.keys().any(|key| { + !matches!( + key.as_str(), + "evaluation_id" + | "claim_id" + | "claim_version" + | "subject_type" + | "requester_ref" + | "target_ref" + | "value" + | "satisfied" + | "disclosure" + | "redacted_fields" + | "format" + | "issued_at" + | "expires_at" + | "provenance" + ) + }) { + bail!("governed Notary result has an unsupported field"); + } let claim_id = result .get("claim_id") .and_then(Value::as_str) @@ -733,12 +754,15 @@ fn validate_live_response( .ok_or_else(|| anyhow!("live expected claim result must be an object"))?; if expected_result .keys() - .any(|key| !matches!(key.as_str(), "value" | "satisfied" | "disclosure")) - || expected_result.is_empty() + .map(String::as_str) + .collect::>() + != BTreeSet::from(["disclosure", "satisfied", "value"]) { - bail!("live expected claim result has an unsupported field"); + bail!( + "live expected claim result must contain exactly value, satisfied, and disclosure" + ); } - for field in expected_result.keys() { + for field in ["value", "satisfied", "disclosure"] { if result.get(field) != expected_result.get(field) { bail!("governed Notary disclosed claim result did not match the expected fixture"); } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index a5982b1f..e9eff38a 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -659,11 +659,21 @@ outputs: #[test] fn governed_live_result_requires_exact_disclosure_and_source_provenance() { let claims = vec!["eligible".to_string()]; - let expected = json!({ "claims": { "eligible": { "satisfied": true } } }); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); let response = json!({ "results": [{ "claim_id": "eligible", + "value": true, "satisfied": true, + "disclosure": "predicate", "provenance": { "used": { "relay_consultation_count": 1 } }, }], }); @@ -683,6 +693,66 @@ outputs: ); } + #[test] + fn governed_live_result_rejects_partial_disclosure_expectations() { + let claims = vec!["eligible".to_string()]; + let expected = json!({ + "claims": { + "eligible": { + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [{ + "claim_id": "eligible", + "value": "unexpected source value", + "satisfied": true, + "disclosure": "predicate", + "provenance": { "used": { "relay_consultation_count": 1 } }, + }], + }); + + assert!( + validate_live_response(&response, &claims, &expected) + .expect_err("partial expected disclosure must fail closed") + .to_string() + .contains("must contain exactly value, satisfied, and disclosure") + ); + } + + #[test] + fn governed_live_result_rejects_unknown_result_fields() { + let claims = vec!["eligible".to_string()]; + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [{ + "claim_id": "eligible", + "value": true, + "satisfied": true, + "disclosure": "predicate", + "raw_value": "unexpected source value", + "provenance": { "used": { "relay_consultation_count": 1 } }, + }], + }); + + assert!( + validate_live_response(&response, &claims, &expected) + .expect_err("unknown result fields must fail closed") + .to_string() + .contains("result has an unsupported field") + ); + } + #[test] fn governed_live_request_negotiates_the_claim_result_media_type() { let endpoint = @@ -702,6 +772,7 @@ outputs: let expected = json!({ "claims": { "record-exists": { + "value": false, "satisfied": false, "disclosure": "predicate", }, @@ -710,6 +781,7 @@ outputs: let response = json!({ "results": [{ "claim_id": "record-exists", + "value": false, "satisfied": false, "disclosure": "predicate", "provenance": { "used": { "relay_consultation_count": 1 } }, diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 1de395fd..2dc5eece 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -113,8 +113,10 @@ request's `format`, and validates that claim-result envelope. The expected-result file must contain only a `claims` object. Its keys must exactly match the request's claim ids. -Each claim value must be a non-empty object containing only the exact `value`, -`satisfied`, or `disclosure` fields that the owner intends to verify. +Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, +using `null` when the Notary result has no value or satisfaction decision. +The live runner compares all three fields and rejects unrecognized result +fields so an over-disclosed response cannot pass as evidence. This example reflects only the committed synthetic fixture and must be replaced with reviewed expectations for the owner-approved record: @@ -122,18 +124,23 @@ with reviewed expectations for the owner-approved record: { "claims": { "social-registry-record-exists": { + "value": true, "satisfied": true, "disclosure": "predicate" }, "social-registry-active": { + "value": true, "satisfied": true, "disclosure": "predicate" }, "programme-code": { "value": "SUPPORT", + "satisfied": null, "disclosure": "value" }, "household-reference": { + "value": null, + "satisfied": null, "disclosure": "redacted" } } diff --git a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx index b33e3dfc..501ff9f3 100644 --- a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx +++ b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx @@ -38,10 +38,11 @@ Do not use the generated local keys in production. For a standalone local project generated from your own data, use the `registryctl` tutorials: [run a protected registry API](../publish-spreadsheet-secured-registry-api/) or [evaluate a claim with Registry Notary](../verify-claim-registry-api/). Clone Solmara Lab when you -want the full multi-service country demo. Solmara Lab does not yet have an immutable release. The -final 1.0 release-candidate reader run remains gated in -[GH#198](https://github.com/registrystack/registry-stack/issues/198) on a tagged Registry Stack -candidate and an atomically pinned Solmara Lab release. +want the full multi-service country demo. The planned final 1.0 release-candidate reader run in +[GH#198](https://github.com/registrystack/registry-stack/issues/198) requires a tagged Registry +Stack candidate and a Solmara Lab release pinned atomically to that candidate. +{/* TODO[evidence]: Replace this issue-tracked plan with a stable public run artifact and + immutable Solmara Lab release link before presenting the gate as completed evidence. */} ## Prerequisites diff --git a/release/conformance/integrations/pilot-report.template.md b/release/conformance/integrations/pilot-report.template.md index aa4a0f06..bc891da3 100644 --- a/release/conformance/integrations/pilot-report.template.md +++ b/release/conformance/integrations/pilot-report.template.md @@ -11,7 +11,8 @@ evidence. One completed pilot is not proof of broad production readiness. ## Closing evidence -- Sanitized run result: [link to the schema-valid public result] +- Sanitized run result: [link to the public result accepted by + `python3 release/scripts/integration-e2-runner.py validate`] - Frozen Registry Stack candidate: [link to the published release] - Independent operator: [confirmed or not confirmed; do not identify the operator] diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index 422fbda2..c454e0a9 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -282,6 +282,11 @@ def test_pilot_report_template_preserves_the_public_contract(self) -> None: normalized_template, ) self.assertNotIn("Solmara", template) + self.assertIn( + "python3 release/scripts/integration-e2-runner.py validate", + normalized_template, + ) + self.assertNotIn("schema-valid public result", normalized_template) for text in ( "Sanitized run result:", "Plans, dry runs", From dc862e5f7653ad51483c38d850d05863062c8dd6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:05:07 +0700 Subject: [PATCH 06/18] fix(registryctl): validate live results recursively Signed-off-by: Jeremi Joslin --- crates/registry-notary-core/src/model.rs | 6 + .../src/project_authoring/commands.rs | 44 ++--- .../src/project_authoring/tests.rs | 179 ++++++++++++++---- .../project-authoring/openspp-exact/README.md | 7 +- 4 files changed, 170 insertions(+), 66 deletions(-) diff --git a/crates/registry-notary-core/src/model.rs b/crates/registry-notary-core/src/model.rs index 2ea27daa..2805e207 100644 --- a/crates/registry-notary-core/src/model.rs +++ b/crates/registry-notary-core/src/model.rs @@ -1378,6 +1378,7 @@ pub struct EvidenceFormat { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ClaimResultView { pub evaluation_id: String, pub claim_id: String, @@ -1398,6 +1399,7 @@ pub struct ClaimResultView { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TargetRefView { #[serde(rename = "type", default, skip_serializing_if = "String::is_empty")] pub entity_type: String, @@ -1409,6 +1411,7 @@ pub struct TargetRefView { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct EvidenceEntityRef { #[serde(rename = "type")] pub entity_type: String, @@ -1435,6 +1438,7 @@ pub const PROVENANCE_GENERATED_BY_CLAIM_EVALUATION: &str = "claim_evaluation"; /// Requester-side identity (client, actor, subject) is deliberately absent; /// those live in restricted audit, never on the public wire. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ClaimProvenance { pub schema_version: String, pub generated_by: ProvenanceGeneratedBy, @@ -1481,6 +1485,7 @@ impl ClaimProvenance { /// `policy_id` here names the *evaluation* policy under which the result was /// produced. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ProvenanceGeneratedBy { #[serde(rename = "type")] pub entry_type: String, @@ -1512,6 +1517,7 @@ pub struct ProvenanceGeneratedBy { /// The consumed side of a claim provenance record: how many Relay consultations /// contributed to the claim. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ProvenanceUsed { pub relay_consultation_count: usize, } diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 21aceeff..6ddcba20 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -718,34 +718,19 @@ fn validate_live_response( } let mut returned = BTreeSet::new(); for result in results { - let result = result + let result_object = result .as_object() .ok_or_else(|| anyhow!("governed Notary result must be an object"))?; - if result.keys().any(|key| { - !matches!( - key.as_str(), - "evaluation_id" - | "claim_id" - | "claim_version" - | "subject_type" - | "requester_ref" - | "target_ref" - | "value" - | "satisfied" - | "disclosure" - | "redacted_fields" - | "format" - | "issued_at" - | "expires_at" - | "provenance" - ) - }) { - bail!("governed Notary result has an unsupported field"); + let result_view: registry_notary_core::ClaimResultView = + serde_json::from_value(result.clone()).map_err(|_| { + anyhow!( + "governed Notary result does not match the closed public claim-result schema" + ) + })?; + if !result_view.provenance.derived_from.is_empty() { + bail!("governed Notary result provenance derived_from must remain empty"); } - let claim_id = result - .get("claim_id") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("governed Notary result lacks a claim_id"))?; + let claim_id = result_view.claim_id.as_str(); if !requested.contains(claim_id) || !returned.insert(claim_id.to_string()) { bail!("governed Notary response contains an unknown or duplicate claim result"); } @@ -763,16 +748,11 @@ fn validate_live_response( ); } for field in ["value", "satisfied", "disclosure"] { - if result.get(field) != expected_result.get(field) { + if result_object.get(field) != expected_result.get(field) { bail!("governed Notary disclosed claim result did not match the expected fixture"); } } - if result - .get("provenance") - .and_then(|value| value.pointer("/used/relay_consultation_count")) - .and_then(Value::as_u64) - .is_none_or(|count| count == 0) - { + if result_view.provenance.used.relay_consultation_count == 0 { bail!("governed Notary result lacks source-backed provenance"); } } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index e9eff38a..adc5dc68 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -656,8 +656,51 @@ outputs: } } + fn governed_live_claim_result( + claim_id: &str, + value: Value, + satisfied: Option, + disclosure: &str, + ) -> Value { + serde_json::to_value(registry_notary_core::ClaimResultView { + evaluation_id: "eval-live-1".to_string(), + claim_id: claim_id.to_string(), + claim_version: "1.0.0".to_string(), + subject_type: "Person".to_string(), + requester_ref: Some(registry_notary_core::EvidenceEntityRef { + entity_type: "Organisation".to_string(), + handle: "rnref:v1:requester".to_string(), + identifier_schemes: Vec::new(), + profile: None, + }), + target_ref: registry_notary_core::TargetRefView { + entity_type: "Person".to_string(), + handle: "rnref:v1:target".to_string(), + identifier_schemes: vec!["openspp_individual_id".to_string()], + profile: Some("openspp".to_string()), + }, + value: Some(value), + satisfied, + disclosure: disclosure.to_string(), + redacted_fields: Vec::new(), + format: registry_notary_core::FORMAT_CLAIM_RESULT_JSON.to_string(), + issued_at: "2026-07-23T00:00:00Z".to_string(), + expires_at: None, + provenance: registry_notary_core::ClaimProvenance::new( + "registry-notary".to_string(), + "eval-live-1".to_string(), + claim_id.to_string(), + "1.0.0".to_string(), + registry_notary_core::ProvenanceUsed { + relay_consultation_count: 1, + }, + ), + }) + .expect("actual claim result serializes") + } + #[test] - fn governed_live_result_requires_exact_disclosure_and_source_provenance() { + fn governed_live_result_accepts_actual_shape_and_requires_source_provenance() { let claims = vec!["eligible".to_string()]; let expected = json!({ "claims": { @@ -669,13 +712,12 @@ outputs: }, }); let response = json!({ - "results": [{ - "claim_id": "eligible", - "value": true, - "satisfied": true, - "disclosure": "predicate", - "provenance": { "used": { "relay_consultation_count": 1 } }, - }], + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], }); assert_eq!( validate_live_response(&response, &claims, &expected).expect("exact result passes"), @@ -705,13 +747,12 @@ outputs: }, }); let response = json!({ - "results": [{ - "claim_id": "eligible", - "value": "unexpected source value", - "satisfied": true, - "disclosure": "predicate", - "provenance": { "used": { "relay_consultation_count": 1 } }, - }], + "results": [governed_live_claim_result( + "eligible", + json!("unexpected source value"), + Some(true), + "predicate", + )], }); assert!( @@ -734,22 +775,97 @@ outputs: }, }, }); - let response = json!({ - "results": [{ - "claim_id": "eligible", - "value": true, - "satisfied": true, - "disclosure": "predicate", - "raw_value": "unexpected source value", - "provenance": { "used": { "relay_consultation_count": 1 } }, - }], + let mut response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], }); + response["results"][0]["raw_value"] = json!("unexpected source value"); assert!( validate_live_response(&response, &claims, &expected) .expect_err("unknown result fields must fail closed") .to_string() - .contains("result has an unsupported field") + .contains("closed public claim-result schema") + ); + } + + #[test] + fn governed_live_result_rejects_nested_unknown_fields() { + let claims = vec!["eligible".to_string()]; + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + for (pointer, field) in [ + ("/results/0/target_ref", "raw_identifier"), + ("/results/0/requester_ref", "raw_principal"), + ("/results/0/provenance", "raw_source"), + ("/results/0/provenance/generated_by", "raw_policy"), + ("/results/0/provenance/used", "raw_source_count"), + ] { + let mut over_disclosed = response.clone(); + over_disclosed + .pointer_mut(pointer) + .and_then(Value::as_object_mut) + .expect("actual nested result object exists") + .insert(field.to_string(), json!("private")); + + assert!( + validate_live_response(&over_disclosed, &claims, &expected) + .expect_err("nested unknown fields must fail closed") + .to_string() + .contains("closed public claim-result schema"), + "nested field {pointer}/{field} was accepted" + ); + } + } + + #[test] + fn governed_live_result_requires_empty_derived_from() { + let claims = vec!["eligible".to_string()]; + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + response["results"][0]["provenance"]["derived_from"] = + json!([{ "raw_source_row": "private" }]); + + assert!( + validate_live_response(&response, &claims, &expected) + .expect_err("non-empty derived_from must fail closed") + .to_string() + .contains("derived_from must remain empty") ); } @@ -779,13 +895,12 @@ outputs: }, }); let response = json!({ - "results": [{ - "claim_id": "record-exists", - "value": false, - "satisfied": false, - "disclosure": "predicate", - "provenance": { "used": { "relay_consultation_count": 1 } }, - }], + "results": [governed_live_claim_result( + "record-exists", + json!(false), + Some(false), + "predicate", + )], }); let returned_claims = validate_live_response(&response, &claims, &expected).expect("no-match result passes"); diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 2dc5eece..0f571228 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -115,8 +115,11 @@ The expected-result file must contain only a `claims` object. Its keys must exactly match the request's claim ids. Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, using `null` when the Notary result has no value or satisfaction decision. -The live runner compares all three fields and rejects unrecognized result -fields so an over-disclosed response cannot pass as evidence. +The live runner parses each complete result through the recursively closed +public `ClaimResultView`, compares all three disclosure fields, rejects +unrecognized nested fields, and requires the reserved provenance +`derived_from` array to remain empty. An over-disclosed response cannot pass +as evidence. This example reflects only the committed synthetic fixture and must be replaced with reviewed expectations for the owner-approved record: From c263b838cd87f6af6491dab65e5c2384d50dde5d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:16:08 +0700 Subject: [PATCH 07/18] fix(registryctl): align live evidence guarantees Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 5 +++ .../src/project_authoring/tests.rs | 36 +++++++++++++++++++ .../project-authoring/openspp-exact/README.md | 11 +++--- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 6ddcba20..ac43b392 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -730,6 +730,11 @@ fn validate_live_response( if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } + if result_view.provenance.generated_by.pack_id.is_some() + || result_view.provenance.generated_by.pack_version.is_some() + { + bail!("governed Notary result exceeds the closed public claim-result schema"); + } let claim_id = result_view.claim_id.as_str(); if !requested.contains(claim_id) || !returned.insert(claim_id.to_string()) { bail!("governed Notary response contains an unknown or duplicate claim result"); diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index adc5dc68..adf69d1e 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -869,6 +869,42 @@ outputs: ); } + #[test] + fn governed_live_result_rejects_provenance_fields_outside_public_schema() { + let claims = vec!["eligible".to_string()]; + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + for field in ["pack_id", "pack_version"] { + let mut unsupported = response.clone(); + unsupported["results"][0]["provenance"]["generated_by"][field] = + json!("unsupported"); + + assert!( + validate_live_response(&unsupported, &claims, &expected) + .expect_err("fields outside the public schema must fail closed") + .to_string() + .contains("exceeds the closed public claim-result schema"), + "provenance field {field} was accepted" + ); + } + } + #[test] fn governed_live_request_negotiates_the_claim_result_media_type() { let endpoint = diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 0f571228..1757613e 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -115,11 +115,12 @@ The expected-result file must contain only a `claims` object. Its keys must exactly match the request's claim ids. Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, using `null` when the Notary result has no value or satisfaction decision. -The live runner parses each complete result through the recursively closed -public `ClaimResultView`, compares all three disclosure fields, rejects -unrecognized nested fields, and requires the reserved provenance -`derived_from` array to remain empty. An over-disclosed response cannot pass -as evidence. +The live runner rejects keys outside its accepted result, reference, and +provenance structures; rejects non-null provenance `pack_id` and +`pack_version`; requires exact `value`, `satisfied`, and `disclosure` matches; +requires an empty `derived_from` array; and requires a non-zero Relay +consultation count. It does not judge whether an otherwise allowed handle or +identifier value is semantically appropriate. This example reflects only the committed synthetic fixture and must be replaced with reviewed expectations for the owner-approved record: From 5e1a9f9bb8610ff4976a42a873b95df6141c59a0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:18:51 +0700 Subject: [PATCH 08/18] fix(registryctl): reject omitted provenance keys Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 8 ++++--- .../src/project_authoring/tests.rs | 23 ++++++++++--------- .../project-authoring/openspp-exact/README.md | 10 ++++---- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index ac43b392..ee33eec5 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -730,9 +730,11 @@ fn validate_live_response( if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } - if result_view.provenance.generated_by.pack_id.is_some() - || result_view.provenance.generated_by.pack_version.is_some() - { + let generated_by = result + .pointer("/provenance/generated_by") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("governed Notary result has invalid provenance"))?; + if generated_by.contains_key("pack_id") || generated_by.contains_key("pack_version") { bail!("governed Notary result exceeds the closed public claim-result schema"); } let claim_id = result_view.claim_id.as_str(); diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index adf69d1e..0260e640 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -891,17 +891,18 @@ outputs: }); for field in ["pack_id", "pack_version"] { - let mut unsupported = response.clone(); - unsupported["results"][0]["provenance"]["generated_by"][field] = - json!("unsupported"); - - assert!( - validate_live_response(&unsupported, &claims, &expected) - .expect_err("fields outside the public schema must fail closed") - .to_string() - .contains("exceeds the closed public claim-result schema"), - "provenance field {field} was accepted" - ); + for value in [json!("unsupported"), Value::Null] { + let mut unsupported = response.clone(); + unsupported["results"][0]["provenance"]["generated_by"][field] = value; + + assert!( + validate_live_response(&unsupported, &claims, &expected) + .expect_err("fields outside the public schema must fail closed") + .to_string() + .contains("exceeds the closed public claim-result schema"), + "provenance field {field} was accepted" + ); + } } } diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 1757613e..24e31146 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -116,11 +116,11 @@ Its keys must exactly match the request's claim ids. Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, using `null` when the Notary result has no value or satisfaction decision. The live runner rejects keys outside its accepted result, reference, and -provenance structures; rejects non-null provenance `pack_id` and -`pack_version`; requires exact `value`, `satisfied`, and `disclosure` matches; -requires an empty `derived_from` array; and requires a non-zero Relay -consultation count. It does not judge whether an otherwise allowed handle or -identifier value is semantically appropriate. +provenance structures; does not accept provenance `pack_id` or `pack_version` +keys; requires exact `value`, `satisfied`, and `disclosure` matches; requires +an empty `derived_from` array; and requires a non-zero Relay consultation +count. It does not judge whether an otherwise allowed handle or identifier +value is semantically appropriate. This example reflects only the committed synthetic fixture and must be replaced with reviewed expectations for the owner-approved record: From 0422c74998bf509751ecab29532ddf59922d8e86 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 23 Jul 2026 23:59:40 +0700 Subject: [PATCH 09/18] fix(registryctl): require expires-at response field Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 5 ++ .../src/project_authoring/tests.rs | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index ee33eec5..ad6dbdcf 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -721,6 +721,11 @@ fn validate_live_response( let result_object = result .as_object() .ok_or_else(|| anyhow!("governed Notary result must be an object"))?; + if !result_object.contains_key("expires_at") { + bail!( + "governed Notary result does not match the closed public claim-result schema: expires_at is required" + ); + } let result_view: registry_notary_core::ClaimResultView = serde_json::from_value(result.clone()).map_err(|_| { anyhow!( diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 0260e640..17ae9eb2 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -735,6 +735,53 @@ outputs: ); } + #[test] + fn governed_live_result_requires_explicit_expires_at() { + let claims = vec!["eligible".to_string()]; + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + assert_eq!( + response.pointer("/results/0/expires_at"), + Some(&Value::Null) + ); + assert_eq!( + validate_live_response(&response, &claims, &expected) + .expect("explicit null expires_at passes"), + claims + ); + + let mut missing_expires_at = response; + assert_eq!( + missing_expires_at["results"][0] + .as_object_mut() + .expect("actual result is an object") + .remove("expires_at"), + Some(Value::Null) + ); + assert!( + validate_live_response(&missing_expires_at, &claims, &expected) + .expect_err("missing expires_at must fail closed") + .to_string() + .contains("expires_at is required") + ); + } + #[test] fn governed_live_result_rejects_partial_disclosure_expectations() { let claims = vec!["eligible".to_string()]; From ff4664717c3d6973ac0a96077cc2d8d96d039162 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:13:25 +0700 Subject: [PATCH 10/18] fix(registryctl): validate provenance constants Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 9 +++ .../src/project_authoring/tests.rs | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index ad6dbdcf..67d82bd9 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -732,6 +732,15 @@ fn validate_live_response( "governed Notary result does not match the closed public claim-result schema" ) })?; + if result_view.provenance.schema_version + != registry_notary_core::CLAIM_PROVENANCE_SCHEMA_VERSION + || result_view.provenance.generated_by.entry_type + != registry_notary_core::PROVENANCE_GENERATED_BY_CLAIM_EVALUATION + { + bail!( + "governed Notary result provenance constants do not match the closed public claim-result schema" + ); + } if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 17ae9eb2..a060b461 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -782,6 +782,70 @@ outputs: ); } + #[test] + fn governed_live_result_requires_canonical_provenance_constants() { + let claims = vec!["eligible".to_string()]; + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }); + + assert_eq!( + response.pointer("/results/0/provenance/schema_version"), + Some(&json!( + registry_notary_core::CLAIM_PROVENANCE_SCHEMA_VERSION + )) + ); + assert_eq!( + response.pointer("/results/0/provenance/generated_by/type"), + Some(&json!( + registry_notary_core::PROVENANCE_GENERATED_BY_CLAIM_EVALUATION + )) + ); + assert_eq!( + validate_live_response(&response, &claims, &expected) + .expect("canonical provenance constants pass"), + claims + ); + + for (pointer, value) in [ + ( + "/results/0/provenance/schema_version", + json!("registry-notary-claim-provenance/v1"), + ), + ( + "/results/0/provenance/generated_by/type", + json!("stale_evaluation"), + ), + ] { + let mut invalid = response.clone(); + *invalid + .pointer_mut(pointer) + .expect("actual provenance constant exists") = value; + + assert!( + validate_live_response(&invalid, &claims, &expected) + .expect_err("non-canonical provenance constant must fail closed") + .to_string() + .contains("provenance constants"), + "provenance constant {pointer} was accepted" + ); + } + } + #[test] fn governed_live_result_rejects_partial_disclosure_expectations() { let claims = vec!["eligible".to_string()]; From 121be1b100dc6da055d8b50ae490bd842840ee1c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 00:18:19 +0700 Subject: [PATCH 11/18] chore: narrow adopter exercise packet scope Signed-off-by: Jeremi Joslin --- .../tutorials/first-run-with-solmara-lab.mdx | 9 +-- release/conformance/integrations/README.md | 6 -- .../integrations/pilot-report.template.md | 78 ------------------- release/scripts/test_integration_e2_runner.py | 39 ---------- 4 files changed, 4 insertions(+), 128 deletions(-) delete mode 100644 release/conformance/integrations/pilot-report.template.md diff --git a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx index 501ff9f3..ad0ec17e 100644 --- a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx +++ b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx @@ -38,11 +38,10 @@ Do not use the generated local keys in production. For a standalone local project generated from your own data, use the `registryctl` tutorials: [run a protected registry API](../publish-spreadsheet-secured-registry-api/) or [evaluate a claim with Registry Notary](../verify-claim-registry-api/). Clone Solmara Lab when you -want the full multi-service country demo. The planned final 1.0 release-candidate reader run in -[GH#198](https://github.com/registrystack/registry-stack/issues/198) requires a tagged Registry -Stack candidate and a Solmara Lab release pinned atomically to that candidate. -{/* TODO[evidence]: Replace this issue-tracked plan with a stable public run artifact and - immutable Solmara Lab release link before presenting the gate as completed evidence. */} +want the full multi-service country demo. The hosted path remains visible for inspection, but its +credential continuation is held by [GH#330](https://github.com/registrystack/registry-stack/issues/330) +and the incomplete [GH#198](https://github.com/registrystack/registry-stack/issues/198) +fresh-reader run. ## Prerequisites diff --git a/release/conformance/integrations/README.md b/release/conformance/integrations/README.md index 344a9733..71584a0b 100644 --- a/release/conformance/integrations/README.md +++ b/release/conformance/integrations/README.md @@ -163,12 +163,6 @@ non-closing evidence. The validator accepts `status: passed` only when every applicable case and teardown is recorded as passed; maintainer review still establishes whether the recorded hashes correspond to the restricted evidence. -After an independent operator completes the frozen journey, use -[`pilot-report.template.md`](pilot-report.template.md) to publish the bounded -outcome, findings, and public triage links without copying restricted evidence -into the repository. A plan or dry run is not evidence, and one pilot is not -proof of broad production readiness. - ## Review the source packet - [`profiles/opencrvs-dci-v1.9.profile.json`](profiles/opencrvs-dci-v1.9.profile.json) diff --git a/release/conformance/integrations/pilot-report.template.md b/release/conformance/integrations/pilot-report.template.md deleted file mode 100644 index bc891da3..00000000 --- a/release/conformance/integrations/pilot-report.template.md +++ /dev/null @@ -1,78 +0,0 @@ -# External integration pilot report - -Use this template only after an independent operator has completed the frozen -pilot journey. Publish a concise account of the outcome and link the validated, -sanitized result. Do not include credentials, network origins, operator or -source identifiers, record identifiers, raw audits, private evidence, or links -to restricted evidence. - -Plans, dry runs, fixture runs, and source-built branch runs are not pilot -evidence. One completed pilot is not proof of broad production readiness. - -## Closing evidence - -- Sanitized run result: [link to the public result accepted by - `python3 release/scripts/integration-e2-runner.py validate`] -- Frozen Registry Stack candidate: [link to the published release] -- Independent operator: [confirmed or not confirmed; do not identify the - operator] -- Owner-approved non-production source: [confirmed or not confirmed; do not - identify the owner or source] -- Integration profile and reviewed operation: [safe public summary; the exact - values remain in the sanitized result] -- Supported topology and journey: [safe public summary] -- Generated Registry YAML edited by hand: [no, or explain why the pilot does - not close] -- Overall outcome: [passed, failed, or incomplete] - -Issue closure still requires a frozen published candidate, an independent -operator, and an owner-approved source. A plan or maintainer-run substitute -cannot satisfy any of these requirements. - -## What the pilot did and did not prove - -The pilot showed: [summarize only the bounded journey completed from published -artifacts, including offline checks and the selected Relay and, when -applicable, Notary flow]. - -The pilot did not show: [summarize excluded versions, operations, topologies, -scale, availability, recovery, or other support claims]. It is not upstream -product certification, general country-system conformance, a security audit, -or evidence that Registry Stack is broadly production-ready. - -## Findings and triage - -Use safe summaries and public issue or pull-request links. Record `not -exercised` rather than inferring success. - -| Area | Exercised | Sanitized outcome | Public triage links | -|---|---|---|---| -| Operator handoff and independence | [yes/no] | [summary] | [links or none] | -| Install or deployment | [yes/no] | [summary] | [links or none] | -| Configuration and environment binding | [yes/no] | [summary] | [links or none] | -| Diagnostics and ordinary source failures | [yes/no] | [summary] | [links or none] | -| Upgrade or rollback | [yes/no] | [summary] | [links or none] | -| Restart, teardown, and other operations | [yes/no] | [summary] | [links or none] | -| Security boundaries and redaction | [yes/no] | [summary] | [links or none] | -| Documentation and operator journey | [yes/no] | [summary] | [links or none] | - -### Blocking findings - -List each blocker with its public triage issue, fix, and independent -re-verification link. Unresolved blockers keep the pilot open. - -- [none, or safe finding summary and links] - -### Accepted limitations and narrowed support - -List each owner-approved limitation or narrowed support claim with its public -decision, operator-guidance update, support-wording update, and review trigger. -Do not use this section to waive a blocker silently. - -- [none, or safe limitation summary and links] - -## Conclusion - -[State whether the bounded pilot closes the external-pilot gate, remains -blocked, or requires a narrower support claim. Reiterate any unexercised area -that affects the conclusion.] diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index c454e0a9..77061386 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -268,45 +268,6 @@ def write_result(self, result: dict[str, object]) -> Path: def test_checked_in_packet_is_closed_and_valid(self) -> None: self.module.validate_packet() - def test_pilot_report_template_preserves_the_public_contract(self) -> None: - readme = (self.module.CONFIG_DIR / "README.md").read_text(encoding="utf-8") - template = ( - self.module.CONFIG_DIR / "pilot-report.template.md" - ).read_text(encoding="utf-8") - normalized_template = " ".join(template.split()) - self.assertIn("(pilot-report.template.md)", readme) - self.assertIn( - "Do not include credentials, network origins, operator or source " - "identifiers, record identifiers, raw audits, private evidence, or " - "links to restricted evidence.", - normalized_template, - ) - self.assertNotIn("Solmara", template) - self.assertIn( - "python3 release/scripts/integration-e2-runner.py validate", - normalized_template, - ) - self.assertNotIn("schema-valid public result", normalized_template) - for text in ( - "Sanitized run result:", - "Plans, dry runs", - "One completed pilot is not proof of broad production readiness.", - "Frozen Registry Stack candidate:", - "Independent operator:", - "Owner-approved non-production source:", - "### Blocking findings", - "### Accepted limitations and narrowed support", - "Operator handoff and independence", - "Install or deployment", - "Configuration and environment binding", - "Diagnostics and ordinary source failures", - "Upgrade or rollback", - "Restart, teardown, and other operations", - "Security boundaries and redaction", - "Documentation and operator journey", - ): - self.assertIn(text, template) - def test_nested_result_objects_must_remain_closed(self) -> None: schema = self.module.load_json(self.module.SCHEMA_PATH) schema["$defs"]["case"]["additionalProperties"] = True From 50d4197f824a3781354a9dc4327a5fbc54389a39 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 01:46:08 +0700 Subject: [PATCH 12/18] fix(registryctl): close live evidence validation Signed-off-by: Jeremi Joslin --- crates/registryctl/src/project_authoring.rs | 1 + .../src/project_authoring/commands.rs | 111 ++++++-- .../src/project_authoring/tests.rs | 237 ++++++++++++++++++ .../project-authoring/openspp-exact/README.md | 18 +- 4 files changed, 338 insertions(+), 29 deletions(-) diff --git a/crates/registryctl/src/project_authoring.rs b/crates/registryctl/src/project_authoring.rs index d603b20f..641f1337 100644 --- a/crates/registryctl/src/project_authoring.rs +++ b/crates/registryctl/src/project_authoring.rs @@ -21,6 +21,7 @@ use registry_relay::source_plan::{ use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; static PROJECT_STARTERS: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets/project-starters"); diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 67d82bd9..68643ae9 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -568,17 +568,12 @@ fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result Result, +) -> bool { + matches!(environment, "prod" | "production") + || environment.starts_with("prod-") + || environment.starts_with("production-") + || environment.ends_with("-prod") + || environment.ends_with("-production") + || matches!( + deployment_profile, + Some(DeploymentProfile::Production | DeploymentProfile::EvidenceGrade) + ) +} + fn governed_live_evaluation_request(endpoint: &url::Url, api_key: &str) -> ureq::Request { ureq::post(endpoint.as_str()) .set("content-type", "application/json") @@ -721,11 +731,7 @@ fn validate_live_response( let result_object = result .as_object() .ok_or_else(|| anyhow!("governed Notary result must be an object"))?; - if !result_object.contains_key("expires_at") { - bail!( - "governed Notary result does not match the closed public claim-result schema: expires_at is required" - ); - } + validate_live_result_raw_schema(result, result_object)?; let result_view: registry_notary_core::ClaimResultView = serde_json::from_value(result.clone()).map_err(|_| { anyhow!( @@ -741,16 +747,28 @@ fn validate_live_response( "governed Notary result provenance constants do not match the closed public claim-result schema" ); } + let generated_by = &result_view.provenance.generated_by; + if generated_by.evaluation_id != result_view.evaluation_id + || generated_by.claim_id != result_view.claim_id + || generated_by.claim_version != result_view.claim_version + { + bail!( + "governed Notary result provenance does not identify the returned claim result" + ); + } + if result_view.format != registry_notary_core::FORMAT_CLAIM_RESULT_JSON { + bail!("governed Notary result has an invalid claim-result format"); + } + if OffsetDateTime::parse(&result_view.issued_at, &Rfc3339).is_err() + || result_view.expires_at.as_deref().is_some_and(|expires_at| { + OffsetDateTime::parse(expires_at, &Rfc3339).is_err() + }) + { + bail!("governed Notary result timestamps do not match the public date-time schema"); + } if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } - let generated_by = result - .pointer("/provenance/generated_by") - .and_then(Value::as_object) - .ok_or_else(|| anyhow!("governed Notary result has invalid provenance"))?; - if generated_by.contains_key("pack_id") || generated_by.contains_key("pack_version") { - bail!("governed Notary result exceeds the closed public claim-result schema"); - } let claim_id = result_view.claim_id.as_str(); if !requested.contains(claim_id) || !returned.insert(claim_id.to_string()) { bail!("governed Notary response contains an unknown or duplicate claim result"); @@ -780,6 +798,53 @@ fn validate_live_response( Ok(returned.into_iter().collect()) } +// These properties are optional in the public OpenAPI schema, but their types +// exclude null when present. `expires_at` is intentionally not listed because +// the public schema requires that key and permits an explicit null. +const LIVE_RESULT_OPTIONAL_NON_NULL_PATHS: &[&str] = &[ + "/requester_ref", + "/requester_ref/identifier_schemes", + "/requester_ref/profile", + "/target_ref/type", + "/target_ref/identifier_schemes", + "/target_ref/profile", + "/provenance/generated_by/policy_id", + "/provenance/generated_by/policy_version", + "/provenance/generated_by/policy_hash", +]; + +// The transport model carries these fields, but the closed public +// ClaimResultView schema does not expose them. +const LIVE_RESULT_SCHEMA_EXCLUDED_PATHS: &[&str] = &[ + "/redacted_fields", + "/provenance/generated_by/pack_id", + "/provenance/generated_by/pack_version", +]; + +fn validate_live_result_raw_schema( + result: &Value, + result_object: &Map, +) -> Result<()> { + if !result_object.contains_key("expires_at") { + bail!( + "governed Notary result does not match the closed public claim-result schema: expires_at is required" + ); + } + if LIVE_RESULT_OPTIONAL_NON_NULL_PATHS + .iter() + .any(|pointer| result.pointer(pointer).is_some_and(Value::is_null)) + { + bail!("governed Notary result optional public field cannot be null"); + } + if LIVE_RESULT_SCHEMA_EXCLUDED_PATHS + .iter() + .any(|pointer| result.pointer(pointer).is_some()) + { + bail!("governed Notary result exceeds the closed public claim-result schema"); + } + Ok(()) +} + fn validate_live_notary_origin(value: &str) -> Result { if value.len() > 2048 || value.trim() != value { bail!("live Notary origin has an invalid bounded shape"); diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index a060b461..7ad286ca 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -699,6 +699,94 @@ outputs: .expect("actual claim result serializes") } + fn governed_live_eligible_fixture() -> (Vec, Value, Value) { + ( + vec!["eligible".to_string()], + json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }), + json!({ + "results": [governed_live_claim_result( + "eligible", + json!(true), + Some(true), + "predicate", + )], + }), + ) + } + + fn set_governed_live_result_pointer(response: &mut Value, pointer: &str, value: Value) { + let result = response + .pointer_mut("/results/0") + .expect("actual claim result exists"); + let (parent_pointer, field) = pointer + .rsplit_once('/') + .expect("result field pointer has a parent"); + let parent = if parent_pointer.is_empty() { + result.as_object_mut() + } else { + result + .pointer_mut(parent_pointer) + .and_then(Value::as_object_mut) + } + .expect("actual result field parent exists"); + parent.insert(field.to_string(), value); + } + + #[test] + fn governed_live_production_guard_covers_name_and_profile_symmetry() { + for environment in [ + "prod", + "production", + "prod-us", + "production-us", + "us-prod", + "us-production", + ] { + assert!( + governed_live_environment_is_production( + environment, + Some(DeploymentProfile::Local) + ), + "production-shaped environment name {environment} was accepted" + ); + } + for environment in [ + "owner-pilot", + "preproduction", + "productionish", + "product-copy-us", + ] { + assert!( + !governed_live_environment_is_production( + environment, + Some(DeploymentProfile::Local) + ), + "non-production-shaped environment name {environment} was rejected" + ); + } + for (profile, rejected) in [ + (DeploymentProfile::Local, false), + (DeploymentProfile::HostedLab, false), + (DeploymentProfile::Production, true), + (DeploymentProfile::EvidenceGrade, true), + ] { + assert_eq!( + governed_live_environment_is_production("owner-pilot", Some(profile)), + rejected, + "unexpected live classification for deployment profile {}", + profile.as_str() + ); + } + } + #[test] fn governed_live_result_accepts_actual_shape_and_requires_source_provenance() { let claims = vec!["eligible".to_string()]; @@ -846,6 +934,155 @@ outputs: } } + #[test] + fn governed_live_result_binds_provenance_to_returned_result() { + let (claims, expected, response) = governed_live_eligible_fixture(); + assert_eq!( + validate_live_response(&response, &claims, &expected) + .expect("canonical provenance binding passes"), + claims + ); + + for (pointer, value) in [ + ( + "/provenance/generated_by/evaluation_id", + json!("eval-other"), + ), + ("/provenance/generated_by/claim_id", json!("other-claim")), + ( + "/provenance/generated_by/claim_version", + json!("0.9.0"), + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, value); + + assert!( + validate_live_response(&invalid, &claims, &expected) + .expect_err("misbound provenance must fail closed") + .to_string() + .contains("does not identify the returned claim result"), + "provenance binding {pointer} was accepted" + ); + } + } + + #[test] + fn governed_live_result_requires_public_date_time_timestamps() { + let (claims, expected, mut response) = governed_live_eligible_fixture(); + set_governed_live_result_pointer( + &mut response, + "/expires_at", + json!("2026-07-24T07:30:00+07:00"), + ); + assert_eq!( + validate_live_response(&response, &claims, &expected) + .expect("canonical RFC3339 timestamps pass"), + claims + ); + + for pointer in ["/issued_at", "/expires_at"] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, json!("not-rfc3339")); + + assert!( + validate_live_response(&invalid, &claims, &expected) + .expect_err("invalid public timestamp must fail closed") + .to_string() + .contains("public date-time schema"), + "invalid timestamp {pointer} was accepted" + ); + } + } + + #[test] + fn governed_live_result_rejects_schema_excluded_redacted_fields() { + let (claims, expected, mut response) = governed_live_eligible_fixture(); + assert!( + response + .pointer("/results/0/redacted_fields") + .is_none(), + "canonical public result omits redacted_fields" + ); + set_governed_live_result_pointer( + &mut response, + "/redacted_fields", + json!(["private_field"]), + ); + + assert!( + validate_live_response(&response, &claims, &expected) + .expect_err("schema-excluded redacted_fields must fail closed") + .to_string() + .contains("exceeds the closed public claim-result schema") + ); + } + + #[test] + fn governed_live_result_rejects_null_optional_public_fields_recursively() { + let (claims, expected, mut response) = governed_live_eligible_fixture(); + for (pointer, value) in [ + ("/requester_ref/identifier_schemes", json!([])), + ("/requester_ref/profile", json!("requester")), + ("/provenance/generated_by/policy_id", json!("eligibility")), + ("/provenance/generated_by/policy_version", json!("1.0.0")), + ( + "/provenance/generated_by/policy_hash", + json!(format!("sha256:{}", "0".repeat(64))), + ), + ] { + set_governed_live_result_pointer(&mut response, pointer, value); + } + for pointer in LIVE_RESULT_OPTIONAL_NON_NULL_PATHS { + assert!( + response + .pointer(&format!("/results/0{pointer}")) + .is_some_and(|value| !value.is_null()), + "canonical optional public field {pointer} is absent or null" + ); + } + assert_eq!( + validate_live_response(&response, &claims, &expected) + .expect("non-null optional public fields pass"), + claims + ); + + for pointer in LIVE_RESULT_OPTIONAL_NON_NULL_PATHS { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, Value::Null); + + assert!( + validate_live_response(&invalid, &claims, &expected) + .expect_err("null optional public field must fail closed") + .to_string() + .contains("optional public field cannot be null"), + "null optional public field {pointer} was accepted" + ); + } + } + + #[test] + fn governed_live_result_requires_claim_result_format() { + let (claims, expected, mut response) = governed_live_eligible_fixture(); + assert_eq!( + response.pointer("/results/0/format"), + Some(&json!(registry_notary_core::FORMAT_CLAIM_RESULT_JSON)) + ); + assert_eq!( + validate_live_response(&response, &claims, &expected) + .expect("canonical claim-result format passes"), + claims + ); + + set_governed_live_result_pointer(&mut response, "/format", json!("application/json")); + assert!( + validate_live_response(&response, &claims, &expected) + .expect_err("wrong result format must fail closed") + .to_string() + .contains("invalid claim-result format") + ); + } + #[test] fn governed_live_result_rejects_partial_disclosure_expectations() { let claims = vec!["eligible".to_string()]; diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 24e31146..f7b6b83f 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -116,11 +116,13 @@ Its keys must exactly match the request's claim ids. Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, using `null` when the Notary result has no value or satisfaction decision. The live runner rejects keys outside its accepted result, reference, and -provenance structures; does not accept provenance `pack_id` or `pack_version` -keys; requires exact `value`, `satisfied`, and `disclosure` matches; requires -an empty `derived_from` array; and requires a non-zero Relay consultation -count. It does not judge whether an otherwise allowed handle or identifier -value is semantically appropriate. +provenance structures, including `redacted_fields`, `pack_id`, and +`pack_version`; rejects nulls for optional public fields; requires the canonical +claim-result format and RFC3339 timestamps; binds each provenance record to its +returned evaluation, claim, and version; requires exact `value`, `satisfied`, +and `disclosure` matches; requires an empty `derived_from` array; and requires a +non-zero Relay consultation count. It does not judge whether an otherwise +allowed handle or identifier value is semantically appropriate. This example reflects only the committed synthetic fixture and must be replaced with reviewed expectations for the owner-approved record: @@ -167,7 +169,11 @@ other three variables, and run: registryctl test --project-dir . --environment --live ``` -The command refuses a production environment. +The command refuses environment names `prod`, `production`, `prod-*`, +`production-*`, `*-prod`, and `*-production`, plus environments whose +`deployment.profile` is `production` or `evidence_grade`. +This guard classifies the selected environment name and profile; it does not +infer deployment classification from the operator-supplied Notary origin. It first reruns the offline fixtures, checks the candidate Notary's Relay readiness, and then sends one request to the governed Notary evaluation path. It requires the returned claim fields to match the expected file and requires From 623e1890c9c5779d5da5ca85a789e5bd7502475e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 03:56:18 +0700 Subject: [PATCH 13/18] fix(registryctl): validate governed result identity Signed-off-by: Jeremi Joslin --- crates/registry-notary-server/src/openapi.rs | 14 + .../src/project_authoring/commands.rs | 146 ++++++- .../src/project_authoring/tests.rs | 375 +++++++++++++++--- .../project-authoring/openspp-exact/README.md | 119 ++++-- .../openapi/registry-notary.openapi.json | 7 + 5 files changed, 564 insertions(+), 97 deletions(-) diff --git a/crates/registry-notary-server/src/openapi.rs b/crates/registry-notary-server/src/openapi.rs index 994d5931..229730e6 100644 --- a/crates/registry-notary-server/src/openapi.rs +++ b/crates/registry-notary-server/src/openapi.rs @@ -2155,6 +2155,11 @@ fn claim_result_view_schema() -> Value { }, "satisfied": { "type": ["boolean", "null"] }, "disclosure": { "type": "string" }, + "redacted_fields": { + "type": "array", + "items": { "type": "string" }, + "description": "Claim or top-level field names redacted from the public result. Omitted when no redaction was applied." + }, "format": { "type": "string" }, "issued_at": { "type": "string", "format": "date-time" }, "expires_at": { "type": ["string", "null"], "format": "date-time" }, @@ -4049,6 +4054,15 @@ mod tests { doc["components"]["schemas"]["BatchClaimResultView"]["properties"]["value"]["type"], json!(["object", "array", "string", "number", "integer", "boolean", "null"]) ); + assert_eq!( + doc["components"]["schemas"]["ClaimResultView"]["properties"]["redacted_fields"] + ["items"]["type"], + json!("string") + ); + assert!(!doc["components"]["schemas"]["ClaimResultView"]["required"] + .as_array() + .expect("required claim-result fields are an array") + .contains(&json!("redacted_fields"))); let evaluate_request = &doc["components"]["schemas"]["EvaluateRequest"]["properties"]; assert!(evaluate_request.get("subject").is_none()); diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 68643ae9..d2eea1c1 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -588,7 +588,7 @@ fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result Result Result<()> { fn validate_live_response( response: &Value, - requested_claims: &[String], + request: &ValidatedLiveRequest, expected: &Value, ) -> Result> { let object = response @@ -710,10 +710,11 @@ fn validate_live_response( let results = object["results"] .as_array() .ok_or_else(|| anyhow!("governed Notary response results must be an array"))?; - if results.len() != requested_claims.len() { + if results.len() != request.claims.len() { bail!("governed Notary response did not return every requested claim exactly once"); } - let requested = requested_claims + let requested = request + .claims .iter() .map(String::as_str) .collect::>(); @@ -727,6 +728,7 @@ fn validate_live_response( bail!("live expected-result claims do not exactly match the governed request"); } let mut returned = BTreeSet::new(); + let mut evaluation_id = None; for result in results { let result_object = result .as_object() @@ -756,6 +758,13 @@ fn validate_live_response( "governed Notary result provenance does not identify the returned claim result" ); } + if evaluation_id + .as_ref() + .is_some_and(|evaluation_id| evaluation_id != &result_view.evaluation_id) + { + bail!("governed Notary response combines results from different evaluations"); + } + evaluation_id.get_or_insert_with(|| result_view.evaluation_id.clone()); if result_view.format != registry_notary_core::FORMAT_CLAIM_RESULT_JSON { bail!("governed Notary result has an invalid claim-result format"); } @@ -769,10 +778,16 @@ fn validate_live_response( if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } + validate_live_result_redaction(&result_view)?; let claim_id = result_view.claim_id.as_str(); if !requested.contains(claim_id) || !returned.insert(claim_id.to_string()) { bail!("governed Notary response contains an unknown or duplicate claim result"); } + if request.claim_versions.get(claim_id).map(String::as_str) + != Some(result_view.claim_version.as_str()) + { + bail!("governed Notary result claim version does not match the authored project"); + } let expected_result = expected[claim_id] .as_object() .ok_or_else(|| anyhow!("live expected claim result must be an object"))?; @@ -802,6 +817,7 @@ fn validate_live_response( // exclude null when present. `expires_at` is intentionally not listed because // the public schema requires that key and permits an explicit null. const LIVE_RESULT_OPTIONAL_NON_NULL_PATHS: &[&str] = &[ + "/redacted_fields", "/requester_ref", "/requester_ref/identifier_schemes", "/requester_ref/profile", @@ -813,10 +829,7 @@ const LIVE_RESULT_OPTIONAL_NON_NULL_PATHS: &[&str] = &[ "/provenance/generated_by/policy_hash", ]; -// The transport model carries these fields, but the closed public -// ClaimResultView schema does not expose them. const LIVE_RESULT_SCHEMA_EXCLUDED_PATHS: &[&str] = &[ - "/redacted_fields", "/provenance/generated_by/pack_id", "/provenance/generated_by/pack_version", ]; @@ -845,6 +858,53 @@ fn validate_live_result_raw_schema( Ok(()) } +fn validate_live_result_redaction(result: ®istry_notary_core::ClaimResultView) -> Result<()> { + let disclosure = registry_notary_core::DisclosureProfile::parse(&result.disclosure) + .ok_or_else(|| anyhow!("governed Notary result has an invalid disclosure profile"))?; + match disclosure { + registry_notary_core::DisclosureProfile::Redacted => { + if result.value.is_some() + || result.satisfied.is_some() + || result.redacted_fields.is_empty() + { + bail!("governed Notary result violates full-redaction semantics"); + } + } + registry_notary_core::DisclosureProfile::Predicate => { + if !result.redacted_fields.is_empty() { + bail!("governed Notary result exposes a predicate over redacted fields"); + } + } + registry_notary_core::DisclosureProfile::Value => { + if result.redacted_fields.is_empty() { + return Ok(()); + } + let Some(value) = result.value.as_ref().and_then(Value::as_object) else { + bail!("governed Notary result has invalid field-redaction semantics"); + }; + let unique = result + .redacted_fields + .iter() + .map(String::as_str) + .collect::>(); + if result.satisfied.is_some() + || unique.len() != result.redacted_fields.len() + || unique.iter().any(|field| { + field.is_empty() + || *field == "value" + || field.contains('.') + || field.contains('[') + || field.contains(']') + || value.contains_key(*field) + }) + { + bail!("governed Notary result has invalid field-redaction semantics"); + } + } + } + Ok(()) +} + fn validate_live_notary_origin(value: &str) -> Result { if value.len() > 2048 || value.trim() != value { bail!("live Notary origin has an invalid bounded shape"); @@ -951,7 +1011,16 @@ mod external_request_reader_tests { } } -fn validate_live_request(loaded: &LoadedRegistryProject, request: &Value) -> Result> { +#[derive(Debug, PartialEq, Eq)] +struct ValidatedLiveRequest { + claims: Vec, + claim_versions: BTreeMap, +} + +fn validate_live_request( + loaded: &LoadedRegistryProject, + request: &Value, +) -> Result { let object = request .as_object() .ok_or_else(|| anyhow!("live request must be a JSON object"))?; @@ -979,26 +1048,63 @@ fn validate_live_request(loaded: &LoadedRegistryProject, request: &Value) -> Res bail!("live request claim count is outside the project bound"); } let mut ids = Vec::with_capacity(claims.len()); - let mut unique = BTreeSet::new(); + let mut claim_versions = BTreeMap::new(); + let mut selected_claims = Vec::with_capacity(claims.len()); for claim in claims { - let id = match claim { - Value::String(id) => id.as_str(), - Value::Object(object) => object - .get("id") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("live request claim reference is invalid"))?, + let (id, requested_version) = match claim { + Value::String(id) => (id.as_str(), None), + Value::Object(object) => ( + object + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("live request claim reference is invalid"))?, + object.get("version"), + ), _ => bail!("live request claim reference is invalid"), }; - if !services + let service = services .iter() - .any(|service| service.claims.contains_key(id)) - || !unique.insert(id) + .find(|service| service.claims.contains_key(id)) + .ok_or_else(|| anyhow!("live request contains an unknown project claim"))?; + let authored_version = service.version.to_string(); + if requested_version.is_some_and(|version| { + version.as_str() != Some(authored_version.as_str()) + }) { + bail!("live request claim version does not match the authored project"); + } + if claim_versions + .insert(id.to_string(), authored_version) + .is_some() { bail!("live request contains an unknown or duplicate project claim"); } ids.push(id.to_string()); + selected_claims.push( + service + .claims + .get(id) + .expect("selected project claim remains present"), + ); } - Ok(ids) + let disclosure = match object.get("disclosure") { + Some(Value::String(disclosure)) => disclosure.as_str(), + Some(_) => bail!("live request disclosure profile is invalid"), + None => expanded_disclosure(&selected_claims[0].disclosure).0, + }; + if registry_notary_core::DisclosureProfile::parse(disclosure).is_none() { + bail!("live request disclosure profile is invalid"); + } + if selected_claims.iter().any(|claim| { + !expanded_disclosure(&claim.disclosure) + .1 + .contains(&disclosure) + }) { + bail!("live request disclosure is not allowed for every selected project claim"); + } + Ok(ValidatedLiveRequest { + claims: ids, + claim_versions, + }) } fn contains_sensitive_request_key(value: &Value) -> bool { diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 7ad286ca..55e1bf1f 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -699,9 +699,19 @@ outputs: .expect("actual claim result serializes") } - fn governed_live_eligible_fixture() -> (Vec, Value, Value) { + fn governed_live_validated_request(claims: &[&str]) -> ValidatedLiveRequest { + ValidatedLiveRequest { + claims: claims.iter().map(|claim| (*claim).to_string()).collect(), + claim_versions: claims + .iter() + .map(|claim| ((*claim).to_string(), "1.0.0".to_string())) + .collect(), + } + } + + fn governed_live_eligible_fixture() -> (ValidatedLiveRequest, Value, Value) { ( - vec!["eligible".to_string()], + governed_live_validated_request(&["eligible"]), json!({ "claims": { "eligible": { @@ -789,7 +799,7 @@ outputs: #[test] fn governed_live_result_accepts_actual_shape_and_requires_source_provenance() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -808,15 +818,15 @@ outputs: )], }); assert_eq!( - validate_live_response(&response, &claims, &expected).expect("exact result passes"), - claims + validate_live_response(&response, &request, &expected).expect("exact result passes"), + request.claims ); let mut missing_provenance = response; missing_provenance["results"][0]["provenance"]["used"]["relay_consultation_count"] = json!(0); assert!( - validate_live_response(&missing_provenance, &claims, &expected) + validate_live_response(&missing_provenance, &request, &expected) .expect_err("source-free result must fail") .to_string() .contains("source-backed provenance") @@ -825,7 +835,7 @@ outputs: #[test] fn governed_live_result_requires_explicit_expires_at() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -849,9 +859,9 @@ outputs: Some(&Value::Null) ); assert_eq!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("explicit null expires_at passes"), - claims + request.claims ); let mut missing_expires_at = response; @@ -863,7 +873,7 @@ outputs: Some(Value::Null) ); assert!( - validate_live_response(&missing_expires_at, &claims, &expected) + validate_live_response(&missing_expires_at, &request, &expected) .expect_err("missing expires_at must fail closed") .to_string() .contains("expires_at is required") @@ -872,7 +882,7 @@ outputs: #[test] fn governed_live_result_requires_canonical_provenance_constants() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -904,9 +914,9 @@ outputs: )) ); assert_eq!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("canonical provenance constants pass"), - claims + request.claims ); for (pointer, value) in [ @@ -925,7 +935,7 @@ outputs: .expect("actual provenance constant exists") = value; assert!( - validate_live_response(&invalid, &claims, &expected) + validate_live_response(&invalid, &request, &expected) .expect_err("non-canonical provenance constant must fail closed") .to_string() .contains("provenance constants"), @@ -936,11 +946,11 @@ outputs: #[test] fn governed_live_result_binds_provenance_to_returned_result() { - let (claims, expected, response) = governed_live_eligible_fixture(); + let (request, expected, response) = governed_live_eligible_fixture(); assert_eq!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("canonical provenance binding passes"), - claims + request.claims ); for (pointer, value) in [ @@ -958,7 +968,7 @@ outputs: set_governed_live_result_pointer(&mut invalid, pointer, value); assert!( - validate_live_response(&invalid, &claims, &expected) + validate_live_response(&invalid, &request, &expected) .expect_err("misbound provenance must fail closed") .to_string() .contains("does not identify the returned claim result"), @@ -969,16 +979,16 @@ outputs: #[test] fn governed_live_result_requires_public_date_time_timestamps() { - let (claims, expected, mut response) = governed_live_eligible_fixture(); + let (request, expected, mut response) = governed_live_eligible_fixture(); set_governed_live_result_pointer( &mut response, "/expires_at", json!("2026-07-24T07:30:00+07:00"), ); assert_eq!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("canonical RFC3339 timestamps pass"), - claims + request.claims ); for pointer in ["/issued_at", "/expires_at"] { @@ -986,7 +996,7 @@ outputs: set_governed_live_result_pointer(&mut invalid, pointer, json!("not-rfc3339")); assert!( - validate_live_response(&invalid, &claims, &expected) + validate_live_response(&invalid, &request, &expected) .expect_err("invalid public timestamp must fail closed") .to_string() .contains("public date-time schema"), @@ -996,31 +1006,209 @@ outputs: } #[test] - fn governed_live_result_rejects_schema_excluded_redacted_fields() { - let (claims, expected, mut response) = governed_live_eligible_fixture(); + fn governed_live_result_accepts_runtime_redaction_evidence() { + let request = governed_live_validated_request(&["household-reference"]); + let expected = json!({ + "claims": { + "household-reference": { + "value": null, + "satisfied": null, + "disclosure": "redacted", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "household-reference", + Value::Null, + None, + "redacted", + )], + }); assert!( response .pointer("/results/0/redacted_fields") .is_none(), - "canonical public result omits redacted_fields" + "empty redaction metadata is omitted" + ); + set_governed_live_result_pointer( + &mut response, + "/redacted_fields", + json!(["household-reference"]), ); + + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("runtime full-redaction evidence passes"), + request.claims + ); + + let mut missing_evidence = response; + missing_evidence["results"][0] + .as_object_mut() + .expect("actual result is an object") + .remove("redacted_fields"); + assert!( + validate_live_response(&missing_evidence, &request, &expected) + .expect_err("full redaction without redaction evidence must fail closed") + .to_string() + .contains("full-redaction semantics") + ); + } + + #[test] + fn governed_live_result_enforces_field_redaction_semantics() { + let request = governed_live_validated_request(&["profile"]); + let expected = json!({ + "claims": { + "profile": { + "value": { "status": "eligible" }, + "satisfied": null, + "disclosure": "value", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "profile", + json!({ "status": "eligible" }), + None, + "value", + )], + }); set_governed_live_result_pointer( &mut response, "/redacted_fields", json!(["private_field"]), ); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("field-redacted object evidence passes"), + request.claims + ); + let mut leaked = response.clone(); + leaked["results"][0]["value"]["private_field"] = json!("private source value"); + let error = validate_live_response(&leaked, &request, &expected) + .expect_err("listed redacted field must be absent from the returned value") + .to_string(); + assert!(error.contains("field-redaction semantics")); + assert!(!error.contains("private source value")); + + let (predicate_request, predicate_expected, mut predicate_response) = + governed_live_eligible_fixture(); + set_governed_live_result_pointer( + &mut predicate_response, + "/redacted_fields", + json!(["private_field"]), + ); assert!( - validate_live_response(&response, &claims, &expected) - .expect_err("schema-excluded redacted_fields must fail closed") + validate_live_response( + &predicate_response, + &predicate_request, + &predicate_expected, + ) + .expect_err("predicate over redacted fields must fail closed") + .to_string() + .contains("predicate over redacted fields") + ); + } + + #[test] + fn governed_live_response_requires_one_evaluation_id() { + let request = governed_live_validated_request(&["eligible", "active"]); + let expected = json!({ + "claims": { + "eligible": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + "active": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let response = json!({ + "results": [ + governed_live_claim_result("eligible", json!(true), Some(true), "predicate"), + governed_live_claim_result("active", json!(true), Some(true), "predicate"), + ], + }); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("one evaluation id across all results passes"), + request.claim_versions.keys().cloned().collect::>() + ); + + let mut mixed = response; + mixed["results"][1]["evaluation_id"] = json!("eval-live-2"); + mixed["results"][1]["provenance"]["generated_by"]["evaluation_id"] = + json!("eval-live-2"); + assert!( + validate_live_response(&mixed, &request, &expected) + .expect_err("mixed evaluation ids must fail closed") + .to_string() + .contains("different evaluations") + ); + } + + #[test] + fn governed_live_result_claim_version_matches_authored_request() { + let loaded = load_registry_project(&project_golden("openspp-exact"), None) + .expect("OpenSPP golden project loads"); + let request = validate_live_request( + &loaded, + &json!({ + "purpose": "social-programme-verification", + "claims": ["social-registry-record-exists"], + "disclosure": "predicate", + }), + ) + .expect("authored request validates"); + let expected = json!({ + "claims": { + "social-registry-record-exists": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "social-registry-record-exists", + json!(true), + Some(true), + "predicate", + )], + }); + response["results"][0]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["claim_version"] = json!("1"); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("authored claim version passes"), + request.claims + ); + + let mut wrong_version = response; + wrong_version["results"][0]["claim_version"] = json!("2.0.0"); + wrong_version["results"][0]["provenance"]["generated_by"]["claim_version"] = + json!("2.0.0"); + assert!( + validate_live_response(&wrong_version, &request, &expected) + .expect_err("result from a different claim version must fail closed") .to_string() - .contains("exceeds the closed public claim-result schema") + .contains("does not match the authored project") ); } #[test] fn governed_live_result_rejects_null_optional_public_fields_recursively() { - let (claims, expected, mut response) = governed_live_eligible_fixture(); + let (request, expected, mut response) = governed_live_eligible_fixture(); + set_governed_live_result_pointer(&mut response, "/redacted_fields", json!([])); for (pointer, value) in [ ("/requester_ref/identifier_schemes", json!([])), ("/requester_ref/profile", json!("requester")), @@ -1042,9 +1230,9 @@ outputs: ); } assert_eq!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("non-null optional public fields pass"), - claims + request.claims ); for pointer in LIVE_RESULT_OPTIONAL_NON_NULL_PATHS { @@ -1052,7 +1240,7 @@ outputs: set_governed_live_result_pointer(&mut invalid, pointer, Value::Null); assert!( - validate_live_response(&invalid, &claims, &expected) + validate_live_response(&invalid, &request, &expected) .expect_err("null optional public field must fail closed") .to_string() .contains("optional public field cannot be null"), @@ -1063,20 +1251,20 @@ outputs: #[test] fn governed_live_result_requires_claim_result_format() { - let (claims, expected, mut response) = governed_live_eligible_fixture(); + let (request, expected, mut response) = governed_live_eligible_fixture(); assert_eq!( response.pointer("/results/0/format"), Some(&json!(registry_notary_core::FORMAT_CLAIM_RESULT_JSON)) ); assert_eq!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect("canonical claim-result format passes"), - claims + request.claims ); set_governed_live_result_pointer(&mut response, "/format", json!("application/json")); assert!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect_err("wrong result format must fail closed") .to_string() .contains("invalid claim-result format") @@ -1085,7 +1273,7 @@ outputs: #[test] fn governed_live_result_rejects_partial_disclosure_expectations() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -1104,7 +1292,7 @@ outputs: }); assert!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect_err("partial expected disclosure must fail closed") .to_string() .contains("must contain exactly value, satisfied, and disclosure") @@ -1113,7 +1301,7 @@ outputs: #[test] fn governed_live_result_rejects_unknown_result_fields() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -1134,7 +1322,7 @@ outputs: response["results"][0]["raw_value"] = json!("unexpected source value"); assert!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect_err("unknown result fields must fail closed") .to_string() .contains("closed public claim-result schema") @@ -1143,7 +1331,7 @@ outputs: #[test] fn governed_live_result_rejects_nested_unknown_fields() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -1177,7 +1365,7 @@ outputs: .insert(field.to_string(), json!("private")); assert!( - validate_live_response(&over_disclosed, &claims, &expected) + validate_live_response(&over_disclosed, &request, &expected) .expect_err("nested unknown fields must fail closed") .to_string() .contains("closed public claim-result schema"), @@ -1188,7 +1376,7 @@ outputs: #[test] fn governed_live_result_requires_empty_derived_from() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -1210,7 +1398,7 @@ outputs: json!([{ "raw_source_row": "private" }]); assert!( - validate_live_response(&response, &claims, &expected) + validate_live_response(&response, &request, &expected) .expect_err("non-empty derived_from must fail closed") .to_string() .contains("derived_from must remain empty") @@ -1219,7 +1407,7 @@ outputs: #[test] fn governed_live_result_rejects_provenance_fields_outside_public_schema() { - let claims = vec!["eligible".to_string()]; + let request = governed_live_validated_request(&["eligible"]); let expected = json!({ "claims": { "eligible": { @@ -1244,7 +1432,7 @@ outputs: unsupported["results"][0]["provenance"]["generated_by"][field] = value; assert!( - validate_live_response(&unsupported, &claims, &expected) + validate_live_response(&unsupported, &request, &expected) .expect_err("fields outside the public schema must fail closed") .to_string() .contains("exceeds the closed public claim-result schema"), @@ -1269,7 +1457,7 @@ outputs: #[test] fn governed_live_report_does_not_infer_a_source_outcome() { - let claims = vec!["record-exists".to_string()]; + let request = governed_live_validated_request(&["record-exists"]); let expected = json!({ "claims": { "record-exists": { @@ -1288,10 +1476,10 @@ outputs: )], }); let returned_claims = - validate_live_response(&response, &claims, &expected).expect("no-match result passes"); + validate_live_response(&response, &request, &expected).expect("no-match result passes"); let report = governed_live_fixture_report(returned_claims); - assert_eq!(report.claims, claims); + assert_eq!(report.claims, request.claims); assert_eq!(report.outcome, None); assert!( serde_json::to_value(report) @@ -1349,6 +1537,80 @@ outputs: )); } + #[test] + fn live_request_requires_one_compatible_disclosure_profile() { + let loaded = load_registry_project(&project_golden("openspp-exact"), None) + .expect("OpenSPP golden project loads"); + + for (disclosure, claims) in [ + ( + "predicate", + vec!["social-registry-record-exists", "social-registry-active"], + ), + ("value", vec!["programme-code"]), + ("redacted", vec!["household-reference"]), + ] { + let request = json!({ + "purpose": "social-programme-verification", + "claims": claims, + "disclosure": disclosure, + }); + let validated = validate_live_request(&loaded, &request) + .expect("compatible disclosure request passes"); + assert!(validated + .claim_versions + .values() + .all(|version| version == "1")); + } + + let mixed = json!({ + "purpose": "social-programme-verification", + "claims": [ + "social-registry-record-exists", + "programme-code", + "household-reference", + ], + }); + assert!( + validate_live_request(&loaded, &mixed) + .expect_err("mixed defaults must fail before source access") + .to_string() + .contains("not allowed for every selected project claim") + ); + } + + #[test] + fn live_request_claim_version_must_match_the_authored_service() { + let loaded = load_registry_project(&project_golden("openspp-exact"), None) + .expect("OpenSPP golden project loads"); + let request = |version| { + json!({ + "purpose": "social-programme-verification", + "claims": [{ + "id": "social-registry-record-exists", + "version": version, + }], + "disclosure": "predicate", + }) + }; + + let validated = + validate_live_request(&loaded, &request("1")).expect("authored version passes"); + assert_eq!( + validated.claim_versions, + BTreeMap::from([( + "social-registry-record-exists".to_string(), + "1".to_string(), + )]) + ); + assert!( + validate_live_request(&loaded, &request("2")) + .expect_err("non-authored request version must fail closed") + .to_string() + .contains("does not match the authored project") + ); + } + #[test] fn live_request_resolves_claims_across_services_with_the_same_purpose() { let project = project_golden("custom-system"); @@ -1397,15 +1659,26 @@ outputs: .expect("split service project compiles"); validate_generated_notary(&compiled).expect("split service Notary config activates"); - let claims = validate_live_request( + let request = validate_live_request( &loaded, &json!({ "purpose": "household-support-screening", "claims": ["household-category", "household-eligible"], + "disclosure": "redacted", }), ) .expect("claims from both same-purpose services are valid"); - assert_eq!(claims, ["household-category", "household-eligible"]); + assert_eq!( + request.claims, + ["household-category", "household-eligible"] + ); + assert_eq!( + request.claim_versions, + BTreeMap::from([ + ("household-category".to_string(), "1".to_string()), + ("household-eligible".to_string(), "1".to_string()), + ]) + ); } #[test] diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index f7b6b83f..18c39b8c 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -81,9 +81,15 @@ Create the request and expected-result files under `.registry-stack/` in the private workspace. That directory is ignored by this workspace. Use only an owner-approved non-production record. -The following request shape matches the committed synthetic project, so replace -the identifier scheme, value, purpose, and claim ids when the authored project -changes: +Notary applies one disclosure profile to the whole evaluation, and this +project's generated disclosure policies deny incompatible downgrades. +Run separate request and expected-result pairs for the predicate, value, and +redacted claims. +The following pairs match the committed synthetic project, so replace the +identifier scheme, value, purpose, claim ids, and expected values when the +authored project changes. + +Predicate request: ```json { @@ -98,33 +104,15 @@ changes: }, "claims": [ "social-registry-record-exists", - "social-registry-active", - "programme-code", - "household-reference" + "social-registry-active" ], + "disclosure": "predicate", "format": "application/vnd.registry-notary.claim-result+json", "purpose": "social-programme-verification" } ``` -`registryctl` sends -`Accept: application/vnd.registry-notary.claim-result+json`, matching the -request's `format`, and validates that claim-result envelope. - -The expected-result file must contain only a `claims` object. -Its keys must exactly match the request's claim ids. -Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, -using `null` when the Notary result has no value or satisfaction decision. -The live runner rejects keys outside its accepted result, reference, and -provenance structures, including `redacted_fields`, `pack_id`, and -`pack_version`; rejects nulls for optional public fields; requires the canonical -claim-result format and RFC3339 timestamps; binds each provenance record to its -returned evaluation, claim, and version; requires exact `value`, `satisfied`, -and `disclosure` matches; requires an empty `derived_from` array; and requires a -non-zero Relay consultation count. It does not judge whether an otherwise -allowed handle or identifier value is semantically appropriate. -This example reflects only the committed synthetic fixture and must be replaced -with reviewed expectations for the owner-approved record: +Predicate expected result: ```json { @@ -138,12 +126,70 @@ with reviewed expectations for the owner-approved record: "value": true, "satisfied": true, "disclosure": "predicate" - }, + } + } +} +``` + +Value request: + +```json +{ + "target": { + "type": "Person", + "identifiers": [ + { + "scheme": "openspp_individual_id", + "value": "IND-AB12CD34" + } + ] + }, + "claims": ["programme-code"], + "disclosure": "value", + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "social-programme-verification" +} +``` + +Value expected result: + +```json +{ + "claims": { "programme-code": { "value": "SUPPORT", "satisfied": null, "disclosure": "value" - }, + } + } +} +``` + +Redacted request: + +```json +{ + "target": { + "type": "Person", + "identifiers": [ + { + "scheme": "openspp_individual_id", + "value": "IND-AB12CD34" + } + ] + }, + "claims": ["household-reference"], + "disclosure": "redacted", + "format": "application/vnd.registry-notary.claim-result+json", + "purpose": "social-programme-verification" +} +``` + +Redacted expected result: + +```json +{ + "claims": { "household-reference": { "value": null, "satisfied": null, @@ -153,6 +199,27 @@ with reviewed expectations for the owner-approved record: } ``` +`registryctl` sends +`Accept: application/vnd.registry-notary.claim-result+json`, matching the +request's `format`, and validates that claim-result envelope. + +Each expected-result file must contain only a `claims` object. +Its keys must exactly match the corresponding request's claim ids. +Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, +using `null` when the Notary result has no value or satisfaction decision. +The live runner rejects keys outside its accepted result, reference, and +provenance structures, including `pack_id` and `pack_version`; validates +public `redacted_fields` without exposing a listed field value; rejects nulls +for optional public fields; requires one evaluation id, the canonical +claim-result format, and RFC3339 timestamps; binds each provenance record and +claim version to the returned result and authored project; requires exact +`value`, `satisfied`, and `disclosure` matches; requires an empty +`derived_from` array; and requires a non-zero Relay consultation count. It does +not judge whether an otherwise allowed handle or identifier value is +semantically appropriate. +These examples reflect only the committed synthetic fixture and must be +replaced with reviewed expectations for the owner-approved record. + The governed live test reads exactly four process variables: | Variable | Owner-supplied value | diff --git a/products/notary/openapi/registry-notary.openapi.json b/products/notary/openapi/registry-notary.openapi.json index 2eb25cef..b154e827 100644 --- a/products/notary/openapi/registry-notary.openapi.json +++ b/products/notary/openapi/registry-notary.openapi.json @@ -358,6 +358,13 @@ "provenance": { "$ref": "#/components/schemas/ClaimProvenance" }, + "redacted_fields": { + "description": "Claim or top-level field names redacted from the public result. Omitted when no redaction was applied.", + "items": { + "type": "string" + }, + "type": "array" + }, "requester_ref": { "$ref": "#/components/schemas/EvidenceEntityRef" }, From 0ce574c244501a4a4ddeb1d2d3368d4c9aa5773d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 05:18:46 +0700 Subject: [PATCH 14/18] fix(registryctl): bind live Notary service Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 11 ++++ .../src/project_authoring/tests.rs | 62 +++++++++++++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index d2eea1c1..4150c806 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -758,6 +758,9 @@ fn validate_live_response( "governed Notary result provenance does not identify the returned claim result" ); } + if generated_by.service_id != request.notary_service_id { + bail!("governed Notary result provenance does not identify the selected Notary service"); + } if evaluation_id .as_ref() .is_some_and(|evaluation_id| evaluation_id != &result_view.evaluation_id) @@ -1015,6 +1018,7 @@ mod external_request_reader_tests { struct ValidatedLiveRequest { claims: Vec, claim_versions: BTreeMap, + notary_service_id: String, } fn validate_live_request( @@ -1027,6 +1031,12 @@ fn validate_live_request( if contains_sensitive_request_key(request) { bail!("live request contains a forbidden credential-like field"); } + let notary_service_id = loaded + .environment + .as_ref() + .and_then(|environment| environment.deployment.notary.as_ref()) + .map(|notary| notary.service.clone()) + .ok_or_else(|| anyhow!("live request environment does not declare a Notary service"))?; let purpose = object .get("purpose") .and_then(Value::as_str) @@ -1104,6 +1114,7 @@ fn validate_live_request( Ok(ValidatedLiveRequest { claims: ids, claim_versions, + notary_service_id, }) } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 55e1bf1f..b62dfa9c 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -706,6 +706,7 @@ outputs: .iter() .map(|claim| ((*claim).to_string(), "1.0.0".to_string())) .collect(), + notary_service_id: "registry-notary".to_string(), } } @@ -1157,7 +1158,7 @@ outputs: #[test] fn governed_live_result_claim_version_matches_authored_request() { - let loaded = load_registry_project(&project_golden("openspp-exact"), None) + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) .expect("OpenSPP golden project loads"); let request = validate_live_request( &loaded, @@ -1187,6 +1188,8 @@ outputs: }); response["results"][0]["claim_version"] = json!("1"); response["results"][0]["provenance"]["generated_by"]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["service_id"] = + json!("social-registry-notary"); assert_eq!( validate_live_response(&response, &request, &expected) .expect("authored claim version passes"), @@ -1205,6 +1208,56 @@ outputs: ); } + #[test] + fn governed_live_result_service_matches_selected_environment() { + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) + .expect("OpenSPP golden project and environment load"); + let request = validate_live_request( + &loaded, + &json!({ + "purpose": "social-programme-verification", + "claims": ["social-registry-record-exists"], + "disclosure": "predicate", + }), + ) + .expect("authored request validates"); + assert_eq!(request.notary_service_id, "social-registry-notary"); + let expected = json!({ + "claims": { + "social-registry-record-exists": { + "value": true, + "satisfied": true, + "disclosure": "predicate", + }, + }, + }); + let mut response = json!({ + "results": [governed_live_claim_result( + "social-registry-record-exists", + json!(true), + Some(true), + "predicate", + )], + }); + response["results"][0]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["claim_version"] = json!("1"); + response["results"][0]["provenance"]["generated_by"]["service_id"] = + json!("social-registry-notary"); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("selected Notary service passes"), + request.claims + ); + + response["results"][0]["provenance"]["generated_by"]["service_id"] = + json!("stale-notary"); + let error = validate_live_response(&response, &request, &expected) + .expect_err("wrong Notary service must fail closed") + .to_string(); + assert!(error.contains("does not identify the selected Notary service")); + assert!(!error.contains("stale-notary")); + } + #[test] fn governed_live_result_rejects_null_optional_public_fields_recursively() { let (request, expected, mut response) = governed_live_eligible_fixture(); @@ -1539,7 +1592,7 @@ outputs: #[test] fn live_request_requires_one_compatible_disclosure_profile() { - let loaded = load_registry_project(&project_golden("openspp-exact"), None) + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) .expect("OpenSPP golden project loads"); for (disclosure, claims) in [ @@ -1581,7 +1634,7 @@ outputs: #[test] fn live_request_claim_version_must_match_the_authored_service() { - let loaded = load_registry_project(&project_golden("openspp-exact"), None) + let loaded = load_registry_project(&project_golden("openspp-exact"), Some("local")) .expect("OpenSPP golden project loads"); let request = |version| { json!({ @@ -1614,7 +1667,8 @@ outputs: #[test] fn live_request_resolves_claims_across_services_with_the_same_purpose() { let project = project_golden("custom-system"); - let mut loaded = load_registry_project(&project, None).expect("golden project loads"); + let mut loaded = + load_registry_project(&project, Some("local")).expect("golden project loads"); let original_id = "household-eligibility"; let mut second: ServiceDeclaration = serde_json::from_value( serde_json::to_value(&loaded.project.services[original_id]) From ce9e33b6d0634f07203924eaf79ca11a574e53f9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 05:42:25 +0700 Subject: [PATCH 15/18] fix(registryctl): harden governed live evidence Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 40 ++++++++-- .../src/project_authoring/tests.rs | 79 ++++++++++++++++++- .../project-authoring/openspp-exact/README.md | 22 +++--- 3 files changed, 121 insertions(+), 20 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 4150c806..ea16f471 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -623,11 +623,9 @@ fn governed_live_environment_is_production( environment: &str, deployment_profile: Option, ) -> bool { - matches!(environment, "prod" | "production") - || environment.starts_with("prod-") - || environment.starts_with("production-") - || environment.ends_with("-prod") - || environment.ends_with("-production") + environment + .split(['-', '_', '.']) + .any(|segment| matches!(segment, "prod" | "production")) || matches!( deployment_profile, Some(DeploymentProfile::Production | DeploymentProfile::EvidenceGrade) @@ -781,7 +779,7 @@ fn validate_live_response( if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } - validate_live_result_redaction(&result_view)?; + validate_live_result_reference_handles(&result_view)?; let claim_id = result_view.claim_id.as_str(); if !requested.contains(claim_id) || !returned.insert(claim_id.to_string()) { bail!("governed Notary response contains an unknown or duplicate claim result"); @@ -791,6 +789,7 @@ fn validate_live_response( { bail!("governed Notary result claim version does not match the authored project"); } + validate_live_result_redaction(&result_view)?; let expected_result = expected[claim_id] .as_object() .ok_or_else(|| anyhow!("live expected claim result must be an object"))?; @@ -861,6 +860,32 @@ fn validate_live_result_raw_schema( Ok(()) } +fn validate_live_result_reference_handles( + result: ®istry_notary_core::ClaimResultView, +) -> Result<()> { + if !is_notary_pseudonymous_handle(&result.target_ref.handle) + || result + .requester_ref + .as_ref() + .is_some_and(|requester| !is_notary_pseudonymous_handle(&requester.handle)) + { + bail!("governed Notary result contains an invalid pseudonymous reference handle"); + } + Ok(()) +} + +fn is_notary_pseudonymous_handle(value: &str) -> bool { + let digest = value + .strip_prefix("rnref:v1:hmac-sha256:") + .or_else(|| value.strip_prefix("rnref:v1:sha256:")); + digest.is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }) +} + fn validate_live_result_redaction(result: ®istry_notary_core::ClaimResultView) -> Result<()> { let disclosure = registry_notary_core::DisclosureProfile::parse(&result.disclosure) .ok_or_else(|| anyhow!("governed Notary result has an invalid disclosure profile"))?; @@ -868,7 +893,8 @@ fn validate_live_result_redaction(result: ®istry_notary_core::ClaimResultView registry_notary_core::DisclosureProfile::Redacted => { if result.value.is_some() || result.satisfied.is_some() - || result.redacted_fields.is_empty() + || result.redacted_fields.len() != 1 + || result.redacted_fields[0] != result.claim_id { bail!("governed Notary result violates full-redaction semantics"); } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index b62dfa9c..667cde3b 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -669,13 +669,13 @@ outputs: subject_type: "Person".to_string(), requester_ref: Some(registry_notary_core::EvidenceEntityRef { entity_type: "Organisation".to_string(), - handle: "rnref:v1:requester".to_string(), + handle: format!("rnref:v1:hmac-sha256:{}", "1".repeat(64)), identifier_schemes: Vec::new(), profile: None, }), target_ref: registry_notary_core::TargetRefView { entity_type: "Person".to_string(), - handle: "rnref:v1:target".to_string(), + handle: format!("rnref:v1:hmac-sha256:{}", "2".repeat(64)), identifier_schemes: vec!["openspp_individual_id".to_string()], profile: Some("openspp".to_string()), }, @@ -760,6 +760,11 @@ outputs: "production-us", "us-prod", "us-production", + "prod_us", + "production.eu", + "eu_prod", + "eu.production", + "owner-prod-copy", ] { assert!( governed_live_environment_is_production( @@ -774,6 +779,8 @@ outputs: "preproduction", "productionish", "product-copy-us", + "owner-productionish-copy", + "eu_product", ] { assert!( !governed_live_environment_is_production( @@ -1044,7 +1051,7 @@ outputs: request.claims ); - let mut missing_evidence = response; + let mut missing_evidence = response.clone(); missing_evidence["results"][0] .as_object_mut() .expect("actual result is an object") @@ -1055,6 +1062,26 @@ outputs: .to_string() .contains("full-redaction semantics") ); + + for invalid_markers in [ + json!([]), + json!([""]), + json!(["household-reference", "household-reference"]), + json!(["other-claim"]), + json!(["IND-AB12CD34"]), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer( + &mut invalid, + "/redacted_fields", + invalid_markers.clone(), + ); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("invalid full-redaction marker set must fail closed") + .to_string(); + assert!(error.contains("full-redaction semantics")); + assert!(!error.contains("IND-AB12CD34")); + } } #[test] @@ -1258,6 +1285,52 @@ outputs: assert!(!error.contains("stale-notary")); } + #[test] + fn governed_live_result_requires_notary_pseudonymous_reference_handles() { + let (request, expected, response) = governed_live_eligible_fixture(); + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("keyed Notary pseudonymous references pass"), + request.claims + ); + + let mut local = response.clone(); + for pointer in ["/target_ref/handle", "/requester_ref/handle"] { + set_governed_live_result_pointer( + &mut local, + pointer, + json!(format!("rnref:v1:sha256:{}", "a".repeat(64))), + ); + } + assert_eq!( + validate_live_response(&local, &request, &expected) + .expect("local-development Notary pseudonymous references pass"), + request.claims + ); + + for pointer in ["/target_ref/handle", "/requester_ref/handle"] { + for invalid_handle in [ + "person-123".to_string(), + "rnref:v1:hmac-sha256:abcd".to_string(), + format!("rnref:v1:hmac-sha256:{}", "A".repeat(64)), + format!("rnref:v1:sha512:{}", "a".repeat(64)), + "rnref:v1:IND-AB12CD34".to_string(), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer( + &mut invalid, + pointer, + json!(invalid_handle.clone()), + ); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("invalid Notary reference handle must fail closed") + .to_string(); + assert!(error.contains("invalid pseudonymous reference handle")); + assert!(!error.contains("IND-AB12CD34")); + } + } + } + #[test] fn governed_live_result_rejects_null_optional_public_fields_recursively() { let (request, expected, mut response) = governed_live_eligible_fixture(); diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 18c39b8c..c68b3fc4 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -209,14 +209,16 @@ Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, using `null` when the Notary result has no value or satisfaction decision. The live runner rejects keys outside its accepted result, reference, and provenance structures, including `pack_id` and `pack_version`; validates -public `redacted_fields` without exposing a listed field value; rejects nulls -for optional public fields; requires one evaluation id, the canonical -claim-result format, and RFC3339 timestamps; binds each provenance record and -claim version to the returned result and authored project; requires exact -`value`, `satisfied`, and `disclosure` matches; requires an empty -`derived_from` array; and requires a non-zero Relay consultation count. It does -not judge whether an otherwise allowed handle or identifier value is -semantically appropriate. +public `redacted_fields`, including the current claim-id marker for full +redaction, without exposing a listed field value; requires `target_ref` and +optional `requester_ref` handles to use the Notary `rnref:v1` pseudonymous +SHA-256 shape; rejects nulls for optional public fields; requires one evaluation +id, the canonical claim-result format, and RFC3339 timestamps; binds each +provenance record and claim version to the returned result and authored +project; requires exact `value`, `satisfied`, and `disclosure` matches; requires +an empty `derived_from` array; and requires a non-zero Relay consultation count. +The runner validates the public pseudonym shape but cannot recompute its keyed +digest from the private owner record. These examples reflect only the committed synthetic fixture and must be replaced with reviewed expectations for the owner-approved record. @@ -236,8 +238,8 @@ other three variables, and run: registryctl test --project-dir . --environment --live ``` -The command refuses environment names `prod`, `production`, `prod-*`, -`production-*`, `*-prod`, and `*-production`, plus environments whose +The command refuses environment names containing an exact `prod` or +`production` segment separated by `.`, `_`, or `-`, plus environments whose `deployment.profile` is `production` or `evidence_grade`. This guard classifies the selected environment name and profile; it does not infer deployment classification from the operator-supplied Notary origin. From cc304b58b7c618ba1b07716fb7ff18c89656d618 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 05:56:55 +0700 Subject: [PATCH 16/18] fix(registryctl): bind configured redaction markers Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 59 ++++++++++++++++--- .../src/project_authoring/tests.rs | 54 ++++++++++++++++- .../project-authoring/openspp-exact/README.md | 23 +++++--- 3 files changed, 118 insertions(+), 18 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index ea16f471..4770ef6d 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -789,20 +789,26 @@ fn validate_live_response( { bail!("governed Notary result claim version does not match the authored project"); } - validate_live_result_redaction(&result_view)?; let expected_result = expected[claim_id] .as_object() .ok_or_else(|| anyhow!("live expected claim result must be an object"))?; - if expected_result + let expected_keys = expected_result .keys() .map(String::as_str) - .collect::>() - != BTreeSet::from(["disclosure", "satisfied", "value"]) + .collect::>(); + if expected_keys != BTreeSet::from(["disclosure", "satisfied", "value"]) + && expected_keys + != BTreeSet::from(["disclosure", "redacted_fields", "satisfied", "value"]) { bail!( - "live expected claim result must contain exactly value, satisfied, and disclosure" + "live expected claim result must contain value, satisfied, disclosure, and optional redacted_fields" ); } + validate_live_result_redaction( + &result_view, + expected_result.get("redacted_fields"), + expected_result.get("disclosure"), + )?; for field in ["value", "satisfied", "disclosure"] { if result_object.get(field) != expected_result.get(field) { bail!("governed Notary disclosed claim result did not match the expected fixture"); @@ -886,25 +892,36 @@ fn is_notary_pseudonymous_handle(value: &str) -> bool { }) } -fn validate_live_result_redaction(result: ®istry_notary_core::ClaimResultView) -> Result<()> { +fn validate_live_result_redaction( + result: ®istry_notary_core::ClaimResultView, + expected_redacted_fields: Option<&Value>, + expected_disclosure: Option<&Value>, +) -> Result<()> { let disclosure = registry_notary_core::DisclosureProfile::parse(&result.disclosure) .ok_or_else(|| anyhow!("governed Notary result has an invalid disclosure profile"))?; match disclosure { registry_notary_core::DisclosureProfile::Redacted => { + let expected_markers = match expected_redacted_fields { + Some(fields) => validate_live_expected_redaction_fields(fields)?, + None => vec![result.claim_id.clone()], + }; if result.value.is_some() || result.satisfied.is_some() - || result.redacted_fields.len() != 1 - || result.redacted_fields[0] != result.claim_id + || result.redacted_fields != expected_markers + || expected_disclosure.and_then(Value::as_str) != Some("redacted") { bail!("governed Notary result violates full-redaction semantics"); } } registry_notary_core::DisclosureProfile::Predicate => { - if !result.redacted_fields.is_empty() { + if !result.redacted_fields.is_empty() || expected_redacted_fields.is_some() { bail!("governed Notary result exposes a predicate over redacted fields"); } } registry_notary_core::DisclosureProfile::Value => { + if expected_redacted_fields.is_some() { + bail!("live expected redacted_fields apply only to a fully redacted claim result"); + } if result.redacted_fields.is_empty() { return Ok(()); } @@ -934,6 +951,30 @@ fn validate_live_result_redaction(result: ®istry_notary_core::ClaimResultView Ok(()) } +fn validate_live_expected_redaction_fields(fields: &Value) -> Result> { + let fields = fields + .as_array() + .filter(|fields| !fields.is_empty() && fields.len() <= MAX_OUTPUTS) + .ok_or_else(|| anyhow!("live expected redacted_fields have an invalid bounded shape"))?; + let mut unique = BTreeSet::new(); + for field in fields { + let field = field + .as_str() + .filter(|field| is_live_top_level_redaction_field(field)) + .ok_or_else(|| anyhow!("live expected redacted_fields have an invalid bounded shape"))?; + if !unique.insert(field.to_string()) { + bail!("live expected redacted_fields have an invalid bounded shape"); + } + } + Ok(unique.into_iter().collect()) +} + +fn is_live_top_level_redaction_field(field: &str) -> bool { + field != "value" + && !field.contains('.') + && validate_stable_id(field, "live expected redacted field").is_ok() +} + fn validate_live_notary_origin(value: &str) -> Result { if value.len() > 2048 || value.trim() != value { bail!("live Notary origin has an invalid bounded shape"); diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 667cde3b..31a7d08e 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -1063,6 +1063,40 @@ outputs: .contains("full-redaction semantics") ); + let mut configured_expected = expected.clone(); + configured_expected["claims"]["household-reference"]["redacted_fields"] = + json!(["status", "private_field"]); + let mut configured_response = response.clone(); + set_governed_live_result_pointer( + &mut configured_response, + "/redacted_fields", + json!(["private_field", "status"]), + ); + assert_eq!( + validate_live_response(&configured_response, &request, &configured_expected) + .expect("owner-approved configured redaction fields pass"), + request.claims + ); + + for invalid_markers in [ + json!([]), + json!(["private_field", "private_field"]), + json!(["private_field", "other_claim"]), + json!(["IND-AB12CD34"]), + ] { + let mut invalid = configured_response.clone(); + set_governed_live_result_pointer( + &mut invalid, + "/redacted_fields", + invalid_markers.clone(), + ); + let error = validate_live_response(&invalid, &request, &configured_expected) + .expect_err("runtime redaction fields must match the owner-approved set") + .to_string(); + assert!(error.contains("full-redaction semantics")); + assert!(!error.contains("IND-AB12CD34")); + } + for invalid_markers in [ json!([]), json!([""]), @@ -1082,6 +1116,24 @@ outputs: assert!(error.contains("full-redaction semantics")); assert!(!error.contains("IND-AB12CD34")); } + + for invalid_expected_markers in [ + json!([]), + json!(["private_field", "private_field"]), + json!(["profile.value"]), + json!(["IND-AB12CD34"]), + json!(["ssn=123"]), + ] { + let mut invalid_expected = expected.clone(); + invalid_expected["claims"]["household-reference"]["redacted_fields"] = + invalid_expected_markers.clone(); + let error = validate_live_response(&configured_response, &request, &invalid_expected) + .expect_err("invalid owner redaction fields must fail closed") + .to_string(); + assert!(error.contains("invalid bounded shape")); + assert!(!error.contains("IND-AB12CD34")); + assert!(!error.contains("ssn=123")); + } } #[test] @@ -1421,7 +1473,7 @@ outputs: validate_live_response(&response, &request, &expected) .expect_err("partial expected disclosure must fail closed") .to_string() - .contains("must contain exactly value, satisfied, and disclosure") + .contains("must contain value, satisfied, disclosure") ); } diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index c68b3fc4..9854665b 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -207,16 +207,23 @@ Each expected-result file must contain only a `claims` object. Its keys must exactly match the corresponding request's claim ids. Each claim value must contain exactly `value`, `satisfied`, and `disclosure`, using `null` when the Notary result has no value or satisfaction decision. +For a fully redacted claim whose deployed policy configures top-level redaction +fields, add `redacted_fields` containing that exact owner-approved field set. +The set must contain one to 64 unique bounded top-level field names; order in +the expected file is not significant. Omit `redacted_fields` when the policy +does not configure a field set, in which case the runner requires Notary's +default single claim-id marker. The live runner rejects keys outside its accepted result, reference, and provenance structures, including `pack_id` and `pack_version`; validates -public `redacted_fields`, including the current claim-id marker for full -redaction, without exposing a listed field value; requires `target_ref` and -optional `requester_ref` handles to use the Notary `rnref:v1` pseudonymous -SHA-256 shape; rejects nulls for optional public fields; requires one evaluation -id, the canonical claim-result format, and RFC3339 timestamps; binds each -provenance record and claim version to the returned result and authored -project; requires exact `value`, `satisfied`, and `disclosure` matches; requires -an empty `derived_from` array; and requires a non-zero Relay consultation count. +public `redacted_fields` against either that owner-approved canonical field set +or the default claim-id marker, without exposing a listed field value; requires +`target_ref` and optional `requester_ref` handles to use the Notary `rnref:v1` +pseudonymous SHA-256 shape; rejects nulls for optional public fields; requires +one evaluation id, the canonical claim-result format, and RFC3339 timestamps; +binds each provenance record and claim version to the returned result and +authored project; requires exact `value`, `satisfied`, and `disclosure` matches; +requires an empty `derived_from` array; and requires a non-zero Relay +consultation count. The runner validates the public pseudonym shape but cannot recompute its keyed digest from the private owner record. These examples reflect only the committed synthetic fixture and must be From 970b168f44850134aee7f6c22c158ac1dc934be0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 06:13:58 +0700 Subject: [PATCH 17/18] fix(registryctl): validate field redaction markers Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 8 +--- .../src/project_authoring/tests.rs | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 4770ef6d..8bd5f329 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -934,14 +934,10 @@ fn validate_live_result_redaction( .map(String::as_str) .collect::>(); if result.satisfied.is_some() + || result.redacted_fields.len() > MAX_OUTPUTS || unique.len() != result.redacted_fields.len() || unique.iter().any(|field| { - field.is_empty() - || *field == "value" - || field.contains('.') - || field.contains('[') - || field.contains(']') - || value.contains_key(*field) + !is_live_top_level_redaction_field(field) || value.contains_key(*field) }) { bail!("governed Notary result has invalid field-redaction semantics"); diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 31a7d08e..a1d9743d 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -1175,6 +1175,44 @@ outputs: assert!(error.contains("field-redaction semantics")); assert!(!error.contains("private source value")); + for (invalid_markers, sensitive_marker) in [ + (json!(["ssn=123"]), "ssn=123"), + (json!(["IND-AB12CD34"]), "IND-AB12CD34"), + (json!(["profile.ssn"]), "profile.ssn"), + (json!(["profile/ssn"]), "profile/ssn"), + ( + json!(["private_field", "private_field"]), + "private_field", + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer( + &mut invalid, + "/redacted_fields", + invalid_markers, + ); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("invalid runtime field-redaction marker must fail closed") + .to_string(); + assert!(error.contains("field-redaction semantics")); + assert!(!error.contains(sensitive_marker)); + } + + let excessive_markers = (0..=MAX_OUTPUTS) + .map(|index| format!("private_field_{index}")) + .collect::>(); + let mut excessive = response.clone(); + set_governed_live_result_pointer( + &mut excessive, + "/redacted_fields", + json!(excessive_markers), + ); + let error = validate_live_response(&excessive, &request, &expected) + .expect_err("runtime field-redaction markers must stay bounded") + .to_string(); + assert!(error.contains("field-redaction semantics")); + assert!(!error.contains("private_field_64")); + let (predicate_request, predicate_expected, mut predicate_response) = governed_live_eligible_fixture(); set_governed_live_result_pointer( From 9b2ef8e5700eab6d188fa3f8749076a2701689a6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 24 Jul 2026 06:46:37 +0700 Subject: [PATCH 18/18] fix(registryctl): bind live evidence to current run Signed-off-by: Jeremi Joslin --- .../src/project_authoring/commands.rs | 96 ++++++++- .../src/project_authoring/tests.rs | 197 ++++++++++++++++-- .../project-authoring/openspp-exact/README.md | 17 +- 3 files changed, 280 insertions(+), 30 deletions(-) diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 8bd5f329..096c294a 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -563,6 +563,17 @@ fn offline_private_path( .to_owned()) } +// A remote candidate can have a small NTP offset from the operator host. Thirty +// seconds accommodates that offset without accepting evidence outside this +// single governed request. +const GOVERNED_LIVE_REMOTE_CLOCK_SKEW: time::Duration = time::Duration::seconds(30); + +#[derive(Clone, Copy)] +struct GovernedLiveValidationWindow { + request_started_at: OffsetDateTime, + response_received_at: OffsetDateTime, +} + fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result { let environment = loaded .environment_name @@ -601,7 +612,9 @@ fn execute_governed_live_test(loaded: &LoadedRegistryProject) -> Result Result MAX_LIVE_RESPONSE_BYTES { bail!("governed Notary response exceeded the configured bound"); } let response = parse_json_strict(&response_bytes) .context("governed Notary response was not strict JSON")?; - let returned_claims = validate_live_response(&response, &validated_request, &expected)?; + let returned_claims = validate_live_response( + &response, + &validated_request, + &expected, + GovernedLiveValidationWindow { + request_started_at, + response_received_at, + }, + )?; Ok(governed_live_fixture_report(returned_claims)) } @@ -698,6 +720,7 @@ fn validate_live_response( response: &Value, request: &ValidatedLiveRequest, expected: &Value, + validation_window: GovernedLiveValidationWindow, ) -> Result> { let object = response .as_object() @@ -727,6 +750,8 @@ fn validate_live_response( } let mut returned = BTreeSet::new(); let mut evaluation_id = None; + let mut target_ref = None; + let mut requester_ref = None; for result in results { let result_object = result .as_object() @@ -759,6 +784,12 @@ fn validate_live_response( if generated_by.service_id != request.notary_service_id { bail!("governed Notary result provenance does not identify the selected Notary service"); } + if generated_by.policy_id.is_some() + || generated_by.policy_version.is_some() + || generated_by.policy_hash.is_some() + { + bail!("governed Notary API-key result carries unexpected named policy provenance"); + } if evaluation_id .as_ref() .is_some_and(|evaluation_id| evaluation_id != &result_view.evaluation_id) @@ -769,17 +800,29 @@ fn validate_live_response( if result_view.format != registry_notary_core::FORMAT_CLAIM_RESULT_JSON { bail!("governed Notary result has an invalid claim-result format"); } - if OffsetDateTime::parse(&result_view.issued_at, &Rfc3339).is_err() - || result_view.expires_at.as_deref().is_some_and(|expires_at| { - OffsetDateTime::parse(expires_at, &Rfc3339).is_err() - }) - { - bail!("governed Notary result timestamps do not match the public date-time schema"); - } + validate_live_result_timestamps(&result_view, validation_window)?; if !result_view.provenance.derived_from.is_empty() { bail!("governed Notary result provenance derived_from must remain empty"); } validate_live_result_reference_handles(&result_view)?; + let result_target_ref = result_object + .get("target_ref") + .ok_or_else(|| anyhow!("governed Notary result lacks its target reference"))?; + if target_ref + .as_ref() + .is_some_and(|target_ref| target_ref != result_target_ref) + { + bail!("governed Notary response combines inconsistent evaluation references"); + } + target_ref.get_or_insert_with(|| result_target_ref.clone()); + let result_requester_ref = result_object.get("requester_ref").cloned(); + if requester_ref + .as_ref() + .is_some_and(|requester_ref| requester_ref != &result_requester_ref) + { + bail!("governed Notary response combines inconsistent evaluation references"); + } + requester_ref.get_or_insert(result_requester_ref); let claim_id = result_view.claim_id.as_str(); if !requested.contains(claim_id) || !returned.insert(claim_id.to_string()) { bail!("governed Notary response contains an unknown or duplicate claim result"); @@ -821,6 +864,41 @@ fn validate_live_response( Ok(returned.into_iter().collect()) } +fn validate_live_result_timestamps( + result: ®istry_notary_core::ClaimResultView, + validation_window: GovernedLiveValidationWindow, +) -> Result<()> { + let issued_at = OffsetDateTime::parse(&result.issued_at, &Rfc3339) + .map_err(|_| anyhow!("governed Notary result timestamps do not match the public date-time schema"))?; + let expires_at = result + .expires_at + .as_deref() + .map(|expires_at| { + OffsetDateTime::parse(expires_at, &Rfc3339).map_err(|_| { + anyhow!( + "governed Notary result timestamps do not match the public date-time schema" + ) + }) + }) + .transpose()?; + if validation_window.response_received_at < validation_window.request_started_at { + bail!("governed Notary validation window is invalid"); + } + let earliest_issued_at = + validation_window.request_started_at - GOVERNED_LIVE_REMOTE_CLOCK_SKEW; + let latest_issued_at = + validation_window.response_received_at + GOVERNED_LIVE_REMOTE_CLOCK_SKEW; + if issued_at < earliest_issued_at || issued_at > latest_issued_at { + bail!("governed Notary result timestamps do not bind to the current live evaluation"); + } + if expires_at.is_some_and(|expires_at| { + expires_at <= validation_window.response_received_at || expires_at <= issued_at + }) { + bail!("governed Notary result timestamps do not bind to the current live evaluation"); + } + Ok(()) +} + // These properties are optional in the public OpenAPI schema, but their types // exclude null when present. `expires_at` is intentionally not listed because // the public schema requires that key and permits an explicit null. diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index a1d9743d..8082307e 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -733,6 +733,26 @@ outputs: ) } + fn governed_live_validation_window() -> GovernedLiveValidationWindow { + GovernedLiveValidationWindow { + request_started_at: time::macros::datetime!(2026-07-23 0:00 UTC), + response_received_at: time::macros::datetime!(2026-07-23 0:00:01 UTC), + } + } + + fn validate_live_response( + response: &Value, + request: &ValidatedLiveRequest, + expected: &Value, + ) -> Result> { + super::validate_live_response( + response, + request, + expected, + governed_live_validation_window(), + ) + } + fn set_governed_live_result_pointer(response: &mut Value, pointer: &str, value: Value) { let result = response .pointer_mut("/results/0") @@ -1013,6 +1033,76 @@ outputs: } } + #[test] + fn governed_live_result_timestamps_bind_to_the_current_request_window() { + let (request, expected, response) = governed_live_eligible_fixture(); + let validation_window = GovernedLiveValidationWindow { + request_started_at: time::macros::datetime!(2026-07-23 0:00 UTC), + response_received_at: time::macros::datetime!(2026-07-23 0:00:10 UTC), + }; + + for issued_at in ["2026-07-22T23:59:30Z", "2026-07-23T00:00:40Z"] { + let mut boundary = response.clone(); + set_governed_live_result_pointer(&mut boundary, "/issued_at", json!(issued_at)); + assert_eq!( + super::validate_live_response( + &boundary, + &request, + &expected, + validation_window, + ) + .expect("inclusive remote clock-skew boundary passes"), + request.claims + ); + } + + let mut unexpired = response.clone(); + set_governed_live_result_pointer( + &mut unexpired, + "/expires_at", + json!("2026-07-23T00:00:11Z"), + ); + assert_eq!( + super::validate_live_response(&unexpired, &request, &expected, validation_window) + .expect("expiry strictly after response receipt passes"), + request.claims + ); + + for (issued_at, expires_at) in [ + ("2026-07-22T23:59:29Z", None), + ("2026-07-23T00:00:41Z", None), + ( + "2026-07-23T00:00:00Z", + Some("2026-07-23T00:00:10Z"), + ), + ( + "2026-07-23T00:00:40Z", + Some("2026-07-23T00:00:20Z"), + ), + ( + "2026-07-23T00:00:20Z", + Some("2026-07-23T00:00:20Z"), + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, "/issued_at", json!(issued_at)); + if let Some(expires_at) = expires_at { + set_governed_live_result_pointer( + &mut invalid, + "/expires_at", + json!(expires_at), + ); + } + let error = + super::validate_live_response(&invalid, &request, &expected, validation_window) + .expect_err("stale, future, or expired result must fail closed") + .to_string(); + assert!(error.contains("current live evaluation")); + assert!(!error.contains(issued_at)); + assert!(expires_at.is_none_or(|expires_at| !error.contains(expires_at))); + } + } + #[test] fn governed_live_result_accepts_runtime_redaction_evidence() { let request = governed_live_validated_request(&["household-reference"]); @@ -1261,6 +1351,51 @@ outputs: request.claim_versions.keys().cloned().collect::>() ); + for (pointer, value, sensitive_value) in [ + ( + "/results/1/target_ref/handle", + json!(format!("rnref:v1:hmac-sha256:{}", "3".repeat(64))), + "33333333", + ), + ( + "/results/1/requester_ref/handle", + json!(format!("rnref:v1:hmac-sha256:{}", "4".repeat(64))), + "44444444", + ), + ( + "/results/1/target_ref/profile", + json!("IND-AB12CD34"), + "IND-AB12CD34", + ), + ( + "/results/1/target_ref/identifier_schemes", + json!(["sensitive_scheme"]), + "sensitive_scheme", + ), + ] { + let mut inconsistent = response.clone(); + *inconsistent + .pointer_mut(pointer) + .expect("second result reference field exists") = value; + let error = validate_live_response(&inconsistent, &request, &expected) + .expect_err("inconsistent multi-claim reference must fail closed") + .to_string(); + assert!(error.contains("inconsistent evaluation references")); + assert!(!error.contains(sensitive_value)); + } + + let mut missing_requester = response.clone(); + missing_requester["results"][1] + .as_object_mut() + .expect("second result is an object") + .remove("requester_ref"); + assert!( + validate_live_response(&missing_requester, &request, &expected) + .expect_err("mixed requester presence must fail closed") + .to_string() + .contains("inconsistent evaluation references") + ); + let mut mixed = response; mixed["results"][1]["evaluation_id"] = json!("eval-live-2"); mixed["results"][1]["provenance"]["generated_by"]["evaluation_id"] = @@ -1428,26 +1563,12 @@ outputs: for (pointer, value) in [ ("/requester_ref/identifier_schemes", json!([])), ("/requester_ref/profile", json!("requester")), - ("/provenance/generated_by/policy_id", json!("eligibility")), - ("/provenance/generated_by/policy_version", json!("1.0.0")), - ( - "/provenance/generated_by/policy_hash", - json!(format!("sha256:{}", "0".repeat(64))), - ), ] { set_governed_live_result_pointer(&mut response, pointer, value); } - for pointer in LIVE_RESULT_OPTIONAL_NON_NULL_PATHS { - assert!( - response - .pointer(&format!("/results/0{pointer}")) - .is_some_and(|value| !value.is_null()), - "canonical optional public field {pointer} is absent or null" - ); - } assert_eq!( validate_live_response(&response, &request, &expected) - .expect("non-null optional public fields pass"), + .expect("canonical optional public fields pass"), request.claims ); @@ -1465,6 +1586,52 @@ outputs: } } + #[test] + fn governed_live_api_key_result_rejects_named_policy_provenance() { + let (request, expected, response) = governed_live_eligible_fixture(); + for field in ["policy_id", "policy_version", "policy_hash"] { + assert!( + response + .pointer(&format!( + "/results/0/provenance/generated_by/{field}" + )) + .is_none(), + "machine-client runtime fixture unexpectedly carries {field}" + ); + } + assert_eq!( + validate_live_response(&response, &request, &expected) + .expect("API-key result without named policy provenance passes"), + request.claims + ); + + for (pointer, value, sensitive_value) in [ + ( + "/provenance/generated_by/policy_id", + json!("IND-AB12CD34"), + "IND-AB12CD34", + ), + ( + "/provenance/generated_by/policy_version", + json!("secret-policy-v1"), + "secret-policy-v1", + ), + ( + "/provenance/generated_by/policy_hash", + json!("sha256:sensitive-policy-hash"), + "sha256:sensitive-policy-hash", + ), + ] { + let mut invalid = response.clone(); + set_governed_live_result_pointer(&mut invalid, pointer, value); + let error = validate_live_response(&invalid, &request, &expected) + .expect_err("API-key result with named policy provenance must fail closed") + .to_string(); + assert!(error.contains("unexpected named policy provenance")); + assert!(!error.contains(sensitive_value)); + } + } + #[test] fn governed_live_result_requires_claim_result_format() { let (request, expected, mut response) = governed_live_eligible_fixture(); diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index 9854665b..578fdc65 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -218,12 +218,17 @@ provenance structures, including `pack_id` and `pack_version`; validates public `redacted_fields` against either that owner-approved canonical field set or the default claim-id marker, without exposing a listed field value; requires `target_ref` and optional `requester_ref` handles to use the Notary `rnref:v1` -pseudonymous SHA-256 shape; rejects nulls for optional public fields; requires -one evaluation id, the canonical claim-result format, and RFC3339 timestamps; -binds each provenance record and claim version to the returned result and -authored project; requires exact `value`, `satisfied`, and `disclosure` matches; -requires an empty `derived_from` array; and requires a non-zero Relay -consultation count. +pseudonymous SHA-256 shape; requires every result in the evaluation to carry +the same complete target and optional requester references; rejects nulls for +optional public fields; requires one evaluation id and the canonical +claim-result format; requires RFC3339 timestamps and binds `issued_at` to the +current POST and response-read window with a 30-second remote-clock allowance +in either direction; requires a non-null `expires_at` to remain strictly after +both response receipt and issuance while accepting the schema's explicit null; +rejects named-policy provenance on this API-key flow; binds each provenance +record and claim version to the returned result and authored project; requires +exact `value`, `satisfied`, and `disclosure` matches; requires an empty +`derived_from` array; and requires a non-zero Relay consultation count. The runner validates the public pseudonym shape but cannot recompute its keyed digest from the private owner record. These examples reflect only the committed synthetic fixture and must be