diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md
index 67cbba2a..1b7f0d1d 100644
--- a/crates/registryctl/CHANGELOG.md
+++ b/crates/registryctl/CHANGELOG.md
@@ -22,6 +22,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
`fingerprint.commitment` in YAML.
Generated configs reference fingerprint env vars only; local raw keys and matching
fingerprint values remain in `secrets/local.env`.
+- The generated benefits sample now uses a richer three-sheet workbook
+ (`Households`, `Persons`, `Applications`) and a broader Bruno collection covering
+ discovery, row reads, relationship expansion, purpose-header failures, and aggregates.
+- The generated Relay sample config now includes focused YAML comments that explain auth
+ fingerprints, source tables, public entities, relationships, filters, and aggregates.
+- `registryctl init relay
` no longer generates a duplicate split `relay/metadata.yaml`
+ manifest for the local sample; Relay derives standards metadata from `relay/config.yaml`
+ unless a project explicitly opts into split metadata.
+
+### Fixed
+
+- The generated Relay sample no longer binds `person.id` to the API-key principal id,
+ which made the Bruno "Read sample people" request return an empty result set.
## [0.1.0] - 2026-06-12
diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs
index fbc72fa9..1e2ef019 100644
--- a/crates/registryctl/src/lib.rs
+++ b/crates/registryctl/src/lib.rs
@@ -1353,7 +1353,6 @@ fn init_benefits_project(dir: &Path) -> Result<()> {
write_text(dir.join("README.md"), project_readme())?;
write_text(dir.join(".gitignore"), include_str!("templates/gitignore"))?;
write_text(dir.join("relay/config.yaml"), &relay_config(&credentials))?;
- write_text(dir.join("relay/metadata.yaml"), relay_metadata())?;
write_text(dir.join("secrets/local.env"), &credentials.env_file())?;
write_text(dir.join("output/.gitkeep"), "")?;
sample::write_benefits_workbook(&dir.join("data/benefits_casework.xlsx"))?;
@@ -2554,8 +2553,22 @@ fn generated_file(path: &str, contents: &str) -> GeneratedFile {
}
fn bruno_relay_files(relay_base_url: &str, _secrets: &LocalEnv) -> Vec {
+ let application_query_body = r#"{
+ "measures": ["application_count"],
+ "group_by": ["program", "application_status"],
+ "filters": {
+ "program": "cash_transfer"
+ }
+}"#;
+
vec![
- bruno_get("Relay/Health.bru", "Relay health", 1, "{{relay_base_url}}/healthz", &[]),
+ bruno_get(
+ "Relay/Health.bru",
+ "Relay health",
+ 1,
+ "{{relay_base_url}}/healthz",
+ &[],
+ ),
bruno_get("Relay/Ready.bru", "Relay ready", 2, "{{relay_base_url}}/ready", &[]),
bruno_get(
"Relay/OpenAPI.bru",
@@ -2578,16 +2591,163 @@ fn bruno_relay_files(relay_base_url: &str, _secrets: &LocalEnv) -> Vec Result {
#[derive(Serialize)]
struct RelaySection<'a> {
config: &'a str,
- metadata: &'a str,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ metadata: Option<&'a str>,
data: Vec<&'a str>,
}
@@ -3511,7 +3676,7 @@ fn registryctl_manifest(dir: &Path, kind: ProjectManifestKind<'_>) -> Result &'static str {
- include_str!("templates/relay_metadata.yaml")
-}
-
#[derive(Debug, Deserialize, Serialize)]
struct SmokeReport {
base_url: String,
@@ -3681,12 +3842,11 @@ fn run_smoke_checks(base_url: &str, secrets: &LocalEnv) -> SmokeReport {
400,
&[bearer_header(secrets.value("ROW_READER_RAW"))],
);
- record_smoke_check(
+ record_row_data_smoke_check(
&mut checks,
base_url,
"row reader can read filtered records",
"/v1/datasets/benefits_casework/entities/person/records?household_id=hh-1001",
- 200,
&[
bearer_header(secrets.value("ROW_READER_RAW")),
(
@@ -3835,6 +3995,44 @@ fn record_smoke_check(
}
}
+fn record_row_data_smoke_check(
+ checks: &mut Vec,
+ base_url: &str,
+ name: &'static str,
+ path: &'static str,
+ headers: &[(String, String)],
+) {
+ let url = format!("{base_url}{path}");
+ match http_get(&url, headers) {
+ Ok(response) => {
+ let has_rows = response.status == 200
+ && serde_json::from_str::(&response.body)
+ .ok()
+ .and_then(|value| value["data"].as_array().map(|data| !data.is_empty()))
+ .unwrap_or(false);
+ checks.push(SmokeCheck {
+ name: name.to_string(),
+ method: "GET".to_string(),
+ path: path.to_string(),
+ expected_status: 200,
+ actual_status: Some(response.status),
+ passed: has_rows,
+ error: (!has_rows)
+ .then(|| "row response did not include any sample records".to_string()),
+ });
+ }
+ Err(err) => checks.push(SmokeCheck {
+ name: name.to_string(),
+ method: "GET".to_string(),
+ path: path.to_string(),
+ expected_status: 200,
+ actual_status: None,
+ passed: false,
+ error: Some(redact_error(&err.to_string())),
+ }),
+ }
+}
+
fn record_notary_evaluation_check(
checks: &mut Vec,
base_url: &str,
@@ -4523,7 +4721,6 @@ workflows:
"README.md",
".gitignore",
"relay/config.yaml",
- "relay/metadata.yaml",
"data/benefits_casework.xlsx",
"secrets/local.env",
"output/.gitkeep",
@@ -4535,6 +4732,38 @@ workflows:
] {
assert!(project.join(path).exists(), "{path} should exist");
}
+ assert!(!project.join("relay/metadata.yaml").exists());
+
+ let config_text = fs::read_to_string(project.join("relay/config.yaml")).unwrap();
+ assert!(config_text.contains("# This file is the Relay contract"));
+ assert!(config_text.contains("# The raw bearer keys live in secrets/local.env."));
+ assert!(config_text.contains("# Tables describe the source workbook."));
+ assert!(config_text.contains("# Aggregates expose predeclared grouped statistics."));
+ assert!(config_text.contains("# Entities are the public API surface."));
+ let config: Value = serde_yaml::from_str(&config_text).unwrap();
+ let manifest: Value =
+ serde_yaml::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap())
+ .unwrap();
+ let compose = fs::read_to_string(project.join("compose.yaml")).unwrap();
+ assert!(config.get("metadata").is_none());
+ assert!(manifest["relay"].get("metadata").is_none());
+ assert!(!compose.contains("metadata.yaml"));
+ assert_eq!(
+ config["datasets"][0]["aggregates"][0]["access"]["aggregate_only_execution"],
+ true
+ );
+ assert_eq!(
+ config["datasets"][0]["aggregates"][0]["disclosure_control"]["min_group_size"],
+ 2
+ );
+ assert_eq!(
+ config["datasets"][0]["aggregates"][1]["access"]["aggregate_only_execution"],
+ true
+ );
+ assert_eq!(
+ config["datasets"][0]["aggregates"][1]["disclosure_control"]["min_group_size"],
+ 2
+ );
let readme = fs::read_to_string(project.join("README.md")).unwrap();
assert!(readme.contains("registryctl doctor --profile local --format json"));
@@ -4556,15 +4785,28 @@ workflows:
let request =
fs::read_to_string(project.join("bruno/registry-api/Relay/Read sample people.bru"))
.unwrap();
+ let aggregate_request = fs::read_to_string(
+ project.join("bruno/registry-api/Relay/Run households by district aggregate.bru"),
+ )
+ .unwrap();
+ let application_aggregate_request = fs::read_to_string(
+ project.join("bruno/registry-api/Relay/Query applications aggregate.bru"),
+ )
+ .unwrap();
let openapi_request =
fs::read_to_string(project.join("bruno/registry-api/Relay/OpenAPI.bru")).unwrap();
assert!(local_bru.contains(&env_value(&env, "METADATA_READER_RAW")));
assert!(local_bru.contains(&env_value(&env, "ROW_READER_RAW")));
+ assert!(local_bru.contains(&env_value(&env, "AGGREGATE_READER_RAW")));
assert!(example_bru.contains("replace-with-metadata_reader_raw"));
+ assert!(example_bru.contains("replace-with-aggregate_reader_raw"));
assert!(!request.contains(&env_value(&env, "METADATA_READER_RAW")));
assert!(!request.contains(&env_value(&env, "ROW_READER_RAW")));
+ assert!(!aggregate_request.contains(&env_value(&env, "AGGREGATE_READER_RAW")));
assert!(request.contains("{{relay_row_key}}"));
+ assert!(aggregate_request.contains("{{relay_aggregate_key}}"));
+ assert!(application_aggregate_request.contains("Data-Purpose"));
assert!(!openapi_request.contains("Authorization"));
assert!(!openapi_request.contains("{{relay_metadata_key}}"));
}
@@ -4927,7 +5169,9 @@ workflows:
"ghcr.io/registrystack/registry-relay",
);
assert_eq!(manifest["runtime"]["relay_base_url"], RELAY_BASE_URL);
+ assert!(manifest["relay"].get("metadata").is_none());
assert!(compose.contains(&format!("image: {RELAY_IMAGE}")));
+ assert!(!compose.contains("metadata.yaml"));
assert!(!compose.contains("registry-relay:snapshot"));
assert!(!compose.contains("registry-relay:latest"));
}
@@ -5050,6 +5294,8 @@ workflows:
"bruno/registry-api/environments/local.example.bru",
"bruno/registry-api/Relay/List datasets.bru",
"bruno/registry-api/Relay/Read sample people.bru",
+ "bruno/registry-api/Relay/Read approved applications.bru",
+ "bruno/registry-api/Relay/List aggregates.bru",
"bruno/registry-api/Notary/List claims.bru",
"bruno/registry-api/Notary/Evaluate person exists.bru",
] {
@@ -5362,7 +5608,6 @@ workflows:
"compose.yaml",
"README.md",
"relay/config.yaml",
- "relay/metadata.yaml",
] {
let contents = fs::read_to_string(project.join(path)).unwrap();
for secret in &secrets {
@@ -5385,7 +5630,9 @@ workflows:
let lossy = String::from_utf8_lossy(&workbook);
assert!(lossy.contains("Households"));
assert!(lossy.contains("Persons"));
+ assert!(lossy.contains("Applications"));
assert!(lossy.contains("hh-1001"));
+ assert!(lossy.contains("app-3001"));
}
#[test]
@@ -5506,12 +5753,7 @@ workflows:
)
);
let rendered = fs::read_to_string(&doctor_config).unwrap();
- assert!(rendered.contains(
- &project_dir
- .join("relay/metadata.yaml")
- .display()
- .to_string()
- ));
+ assert!(!rendered.contains("metadata.yaml"));
assert!(rendered.contains(
&project_dir
.join("data/benefits_casework.xlsx")
diff --git a/crates/registryctl/src/sample.rs b/crates/registryctl/src/sample.rs
index a3dd75ae..5bf4a16e 100644
--- a/crates/registryctl/src/sample.rs
+++ b/crates/registryctl/src/sample.rs
@@ -10,18 +10,495 @@ pub enum Sample {
Benefits,
}
+#[derive(Clone, Copy)]
+enum Cell {
+ Text(&'static str),
+ Integer(i64),
+ Bool(bool),
+}
+
pub fn write_benefits_workbook(path: &Path) -> Result<()> {
+ let households_sheet = sheet_xml(&[
+ &[
+ Cell::Text("household_id"),
+ Cell::Text("district"),
+ Cell::Text("ward"),
+ Cell::Text("poverty_band"),
+ Cell::Text("household_status"),
+ Cell::Text("registered_on"),
+ Cell::Text("declared_member_count"),
+ Cell::Text("address_line"),
+ ],
+ &[
+ Cell::Text("hh-1001"),
+ Cell::Text("south"),
+ Cell::Text("ward_7"),
+ Cell::Text("band_1"),
+ Cell::Text("active"),
+ Cell::Text("2024-01-12"),
+ Cell::Integer(4),
+ Cell::Text("595 River Rd, Southvale"),
+ ],
+ &[
+ Cell::Text("hh-1002"),
+ Cell::Text("north"),
+ Cell::Text("ward_2"),
+ Cell::Text("band_3"),
+ Cell::Text("active"),
+ Cell::Text("2023-11-03"),
+ Cell::Integer(2),
+ Cell::Text("524 Hill St, Northvale"),
+ ],
+ &[
+ Cell::Text("hh-1003"),
+ Cell::Text("east"),
+ Cell::Text("ward_5"),
+ Cell::Text("band_2"),
+ Cell::Text("review_hold"),
+ Cell::Text("2024-02-18"),
+ Cell::Integer(3),
+ Cell::Text("81 Market Ln, Eastport"),
+ ],
+ &[
+ Cell::Text("hh-1004"),
+ Cell::Text("west"),
+ Cell::Text("ward_1"),
+ Cell::Text("band_1"),
+ Cell::Text("active"),
+ Cell::Text("2022-08-30"),
+ Cell::Integer(1),
+ Cell::Text("140 Lakeside Ave, Westhaven"),
+ ],
+ &[
+ Cell::Text("hh-1005"),
+ Cell::Text("south"),
+ Cell::Text("ward_8"),
+ Cell::Text("band_2"),
+ Cell::Text("closed"),
+ Cell::Text("2021-04-22"),
+ Cell::Integer(1),
+ Cell::Text("22 Orchard Ct, Southvale"),
+ ],
+ &[
+ Cell::Text("hh-1006"),
+ Cell::Text("north"),
+ Cell::Text("ward_3"),
+ Cell::Text("band_1"),
+ Cell::Text("active"),
+ Cell::Text("2024-05-09"),
+ Cell::Integer(1),
+ Cell::Text("9 Cedar Loop, Northvale"),
+ ],
+ ]);
+ let persons_sheet = sheet_xml(&[
+ &[
+ Cell::Text("person_id"),
+ Cell::Text("household_id"),
+ Cell::Text("given_name"),
+ Cell::Text("family_name"),
+ Cell::Text("date_of_birth"),
+ Cell::Text("age_band"),
+ Cell::Text("relationship_to_head"),
+ Cell::Text("registration_status"),
+ Cell::Text("eligibility_status"),
+ Cell::Text("is_primary_applicant"),
+ Cell::Text("national_id"),
+ ],
+ &[
+ Cell::Text("per-2001"),
+ Cell::Text("hh-1001"),
+ Cell::Text("Fae"),
+ Cell::Text("Elm"),
+ Cell::Text("1989-05-14"),
+ Cell::Text("35-49"),
+ Cell::Text("head"),
+ Cell::Text("active"),
+ Cell::Text("eligible"),
+ Cell::Bool(true),
+ Cell::Text("FAKE-856648"),
+ ],
+ &[
+ Cell::Text("per-2002"),
+ Cell::Text("hh-1001"),
+ Cell::Text("Jo"),
+ Cell::Text("Elm"),
+ Cell::Text("2019-02-03"),
+ Cell::Text("5-17"),
+ Cell::Text("child"),
+ Cell::Text("active"),
+ Cell::Text("eligible"),
+ Cell::Bool(false),
+ Cell::Text("FAKE-806707"),
+ ],
+ &[
+ Cell::Text("per-2003"),
+ Cell::Text("hh-1001"),
+ Cell::Text("Kai"),
+ Cell::Text("Elm"),
+ Cell::Text("1954-09-21"),
+ Cell::Text("65+"),
+ Cell::Text("parent"),
+ Cell::Text("active"),
+ Cell::Text("eligible"),
+ Cell::Bool(false),
+ Cell::Text("FAKE-219346"),
+ ],
+ &[
+ Cell::Text("per-2004"),
+ Cell::Text("hh-1001"),
+ Cell::Text("Mina"),
+ Cell::Text("Elm"),
+ Cell::Text("1991-11-10"),
+ Cell::Text("35-49"),
+ Cell::Text("spouse"),
+ Cell::Text("active"),
+ Cell::Text("pending_review"),
+ Cell::Bool(false),
+ Cell::Text("FAKE-331902"),
+ ],
+ &[
+ Cell::Text("per-2005"),
+ Cell::Text("hh-1002"),
+ Cell::Text("Dee"),
+ Cell::Text("Iron"),
+ Cell::Text("1984-01-28"),
+ Cell::Text("35-49"),
+ Cell::Text("head"),
+ Cell::Text("active"),
+ Cell::Text("eligible"),
+ Cell::Bool(true),
+ Cell::Text("FAKE-748201"),
+ ],
+ &[
+ Cell::Text("per-2006"),
+ Cell::Text("hh-1002"),
+ Cell::Text("Ari"),
+ Cell::Text("Iron"),
+ Cell::Text("2016-07-18"),
+ Cell::Text("5-17"),
+ Cell::Text("child"),
+ Cell::Text("active"),
+ Cell::Text("eligible"),
+ Cell::Bool(false),
+ Cell::Text("FAKE-671240"),
+ ],
+ &[
+ Cell::Text("per-2007"),
+ Cell::Text("hh-1003"),
+ Cell::Text("Nia"),
+ Cell::Text("Stone"),
+ Cell::Text("1998-03-05"),
+ Cell::Text("18-34"),
+ Cell::Text("head"),
+ Cell::Text("pending"),
+ Cell::Text("pending_review"),
+ Cell::Bool(true),
+ Cell::Text("FAKE-503118"),
+ ],
+ &[
+ Cell::Text("per-2008"),
+ Cell::Text("hh-1003"),
+ Cell::Text("Sol"),
+ Cell::Text("Stone"),
+ Cell::Text("2022-12-12"),
+ Cell::Text("0-4"),
+ Cell::Text("child"),
+ Cell::Text("pending"),
+ Cell::Text("pending_review"),
+ Cell::Bool(false),
+ Cell::Text("FAKE-663910"),
+ ],
+ &[
+ Cell::Text("per-2009"),
+ Cell::Text("hh-1003"),
+ Cell::Text("Ren"),
+ Cell::Text("Stone"),
+ Cell::Text("1970-06-30"),
+ Cell::Text("50-64"),
+ Cell::Text("parent"),
+ Cell::Text("active"),
+ Cell::Text("ineligible"),
+ Cell::Bool(false),
+ Cell::Text("FAKE-447120"),
+ ],
+ &[
+ Cell::Text("per-2010"),
+ Cell::Text("hh-1004"),
+ Cell::Text("Ivo"),
+ Cell::Text("Reed"),
+ Cell::Text("1957-04-02"),
+ Cell::Text("65+"),
+ Cell::Text("head"),
+ Cell::Text("active"),
+ Cell::Text("eligible"),
+ Cell::Bool(true),
+ Cell::Text("FAKE-990231"),
+ ],
+ &[
+ Cell::Text("per-2011"),
+ Cell::Text("hh-1005"),
+ Cell::Text("Uma"),
+ Cell::Text("Vale"),
+ Cell::Text("1993-08-16"),
+ Cell::Text("18-34"),
+ Cell::Text("head"),
+ Cell::Text("closed"),
+ Cell::Text("ineligible"),
+ Cell::Bool(true),
+ Cell::Text("FAKE-125904"),
+ ],
+ &[
+ Cell::Text("per-2012"),
+ Cell::Text("hh-1006"),
+ Cell::Text("Lina"),
+ Cell::Text("Moss"),
+ Cell::Text("1982-10-25"),
+ Cell::Text("35-49"),
+ Cell::Text("head"),
+ Cell::Text("active"),
+ Cell::Text("eligible"),
+ Cell::Bool(true),
+ Cell::Text("FAKE-775120"),
+ ],
+ ]);
+ let applications_sheet = sheet_xml(&[
+ &[
+ Cell::Text("application_id"),
+ Cell::Text("household_id"),
+ Cell::Text("applicant_person_id"),
+ Cell::Text("program"),
+ Cell::Text("application_date"),
+ Cell::Text("intake_channel"),
+ Cell::Text("office_code"),
+ Cell::Text("application_status"),
+ Cell::Text("decision"),
+ Cell::Text("benefit_level"),
+ Cell::Text("review_due_on"),
+ Cell::Text("identity_verified"),
+ Cell::Text("residence_verified"),
+ Cell::Text("consent_reference"),
+ ],
+ &[
+ Cell::Text("app-3001"),
+ Cell::Text("hh-1001"),
+ Cell::Text("per-2001"),
+ Cell::Text("cash_transfer"),
+ Cell::Text("2024-01-20"),
+ Cell::Text("office"),
+ Cell::Text("SOUTH-01"),
+ Cell::Text("approved"),
+ Cell::Text("eligible"),
+ Cell::Text("enhanced"),
+ Cell::Text("2026-01-20"),
+ Cell::Bool(true),
+ Cell::Bool(true),
+ Cell::Text("consent-9001"),
+ ],
+ &[
+ Cell::Text("app-3002"),
+ Cell::Text("hh-1002"),
+ Cell::Text("per-2005"),
+ Cell::Text("food_support"),
+ Cell::Text("2024-02-10"),
+ Cell::Text("mobile_team"),
+ Cell::Text("NORTH-02"),
+ Cell::Text("approved"),
+ Cell::Text("eligible"),
+ Cell::Text("standard"),
+ Cell::Text("2025-08-10"),
+ Cell::Bool(true),
+ Cell::Bool(true),
+ Cell::Text("consent-9002"),
+ ],
+ &[
+ Cell::Text("app-3003"),
+ Cell::Text("hh-1003"),
+ Cell::Text("per-2007"),
+ Cell::Text("cash_transfer"),
+ Cell::Text("2024-03-05"),
+ Cell::Text("partner_referral"),
+ Cell::Text("EAST-01"),
+ Cell::Text("under_review"),
+ Cell::Text("pending_review"),
+ Cell::Text("none"),
+ Cell::Text("2024-06-30"),
+ Cell::Bool(true),
+ Cell::Bool(false),
+ Cell::Text("consent-9003"),
+ ],
+ &[
+ Cell::Text("app-3004"),
+ Cell::Text("hh-1004"),
+ Cell::Text("per-2010"),
+ Cell::Text("disability_support"),
+ Cell::Text("2023-09-15"),
+ Cell::Text("office"),
+ Cell::Text("WEST-01"),
+ Cell::Text("approved"),
+ Cell::Text("eligible"),
+ Cell::Text("enhanced"),
+ Cell::Text("2025-09-15"),
+ Cell::Bool(true),
+ Cell::Bool(true),
+ Cell::Text("consent-9004"),
+ ],
+ &[
+ Cell::Text("app-3005"),
+ Cell::Text("hh-1005"),
+ Cell::Text("per-2011"),
+ Cell::Text("emergency_grant"),
+ Cell::Text("2023-04-25"),
+ Cell::Text("online"),
+ Cell::Text("SOUTH-01"),
+ Cell::Text("closed"),
+ Cell::Text("ineligible"),
+ Cell::Text("none"),
+ Cell::Text("2023-07-25"),
+ Cell::Bool(true),
+ Cell::Bool(true),
+ Cell::Text("consent-9005"),
+ ],
+ &[
+ Cell::Text("app-3006"),
+ Cell::Text("hh-1006"),
+ Cell::Text("per-2012"),
+ Cell::Text("cash_transfer"),
+ Cell::Text("2024-05-12"),
+ Cell::Text("office"),
+ Cell::Text("NORTH-02"),
+ Cell::Text("submitted"),
+ Cell::Text("pending_review"),
+ Cell::Text("none"),
+ Cell::Text("2024-08-12"),
+ Cell::Bool(false),
+ Cell::Bool(true),
+ Cell::Text("consent-9006"),
+ ],
+ &[
+ Cell::Text("app-3007"),
+ Cell::Text("hh-1001"),
+ Cell::Text("per-2001"),
+ Cell::Text("school_meals"),
+ Cell::Text("2024-06-01"),
+ Cell::Text("office"),
+ Cell::Text("SOUTH-01"),
+ Cell::Text("approved"),
+ Cell::Text("eligible"),
+ Cell::Text("standard"),
+ Cell::Text("2026-06-01"),
+ Cell::Bool(true),
+ Cell::Bool(true),
+ Cell::Text("consent-9007"),
+ ],
+ &[
+ Cell::Text("app-3008"),
+ Cell::Text("hh-1002"),
+ Cell::Text("per-2005"),
+ Cell::Text("cash_transfer"),
+ Cell::Text("2024-06-15"),
+ Cell::Text("mobile_team"),
+ Cell::Text("NORTH-02"),
+ Cell::Text("approved"),
+ Cell::Text("eligible"),
+ Cell::Text("standard"),
+ Cell::Text("2026-06-15"),
+ Cell::Bool(true),
+ Cell::Bool(true),
+ Cell::Text("consent-9008"),
+ ],
+ &[
+ Cell::Text("app-3009"),
+ Cell::Text("hh-1003"),
+ Cell::Text("per-2007"),
+ Cell::Text("food_support"),
+ Cell::Text("2024-06-18"),
+ Cell::Text("partner_referral"),
+ Cell::Text("EAST-01"),
+ Cell::Text("approved"),
+ Cell::Text("eligible"),
+ Cell::Text("standard"),
+ Cell::Text("2026-06-18"),
+ Cell::Bool(true),
+ Cell::Bool(true),
+ Cell::Text("consent-9009"),
+ ],
+ ]);
+
let entries = [
ZipEntry::new("[Content_Types].xml", CONTENT_TYPES.as_bytes()),
ZipEntry::new("_rels/.rels", ROOT_RELS.as_bytes()),
ZipEntry::new("xl/workbook.xml", WORKBOOK.as_bytes()),
ZipEntry::new("xl/_rels/workbook.xml.rels", WORKBOOK_RELS.as_bytes()),
- ZipEntry::new("xl/worksheets/sheet1.xml", HOUSEHOLDS_SHEET.as_bytes()),
- ZipEntry::new("xl/worksheets/sheet2.xml", PERSONS_SHEET.as_bytes()),
+ ZipEntry::new("xl/worksheets/sheet1.xml", households_sheet.as_bytes()),
+ ZipEntry::new("xl/worksheets/sheet2.xml", persons_sheet.as_bytes()),
+ ZipEntry::new("xl/worksheets/sheet3.xml", applications_sheet.as_bytes()),
];
ZipStoreWriter::write(path, &entries)
}
+fn sheet_xml(rows: &[&[Cell]]) -> String {
+ let mut xml = String::from(
+ r#"
+
+
+"#,
+ );
+ for (row_idx, row) in rows.iter().enumerate() {
+ let row_number = row_idx + 1;
+ xml.push_str(&format!(" \n"));
+ for (col_idx, cell) in row.iter().enumerate() {
+ let reference = cell_reference(col_idx, row_number);
+ write_cell(&mut xml, &reference, *cell);
+ }
+ xml.push_str("
\n");
+ }
+ xml.push_str(" \n");
+ xml
+}
+
+fn write_cell(xml: &mut String, reference: &str, cell: Cell) {
+ match cell {
+ Cell::Text(value) => {
+ xml.push_str(&format!(
+ " {}\n",
+ escape_xml(value)
+ ));
+ }
+ Cell::Integer(value) => {
+ xml.push_str(&format!(" {value}\n"));
+ }
+ Cell::Bool(value) => {
+ let value = if value { 1 } else { 0 };
+ xml.push_str(&format!(
+ " {value}\n"
+ ));
+ }
+ }
+}
+
+fn cell_reference(mut col_idx: usize, row_number: usize) -> String {
+ let mut letters = Vec::new();
+ loop {
+ let remainder = col_idx % 26;
+ letters.push((b'A' + remainder as u8) as char);
+ col_idx /= 26;
+ if col_idx == 0 {
+ break;
+ }
+ col_idx -= 1;
+ }
+ letters.iter().rev().collect::() + &row_number.to_string()
+}
+
+fn escape_xml(value: &str) -> String {
+ value
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
const CONTENT_TYPES: &str = r#"
@@ -29,6 +506,7 @@ const CONTENT_TYPES: &str = r#"
+
"#;
const ROOT_RELS: &str = r#"
@@ -41,6 +519,7 @@ const WORKBOOK: &str = r#"
+
"#;
@@ -48,66 +527,5 @@ const WORKBOOK_RELS: &str = r#"
+
"#;
-
-const HOUSEHOLDS_SHEET: &str = r#"
-
-
-
- household_id
- district
- poverty_band
- address_line
-
-
- hh-1001
- south
- band_1
- 595 Fake St, southvale
-
-
- hh-1002
- north
- band_3
- 524 Fake St, northvale
-
-
-"#;
-
-const PERSONS_SHEET: &str = r#"
-
-
-
- person_id
- household_id
- age_band
- eligibility_status
- full_name
- national_id
-
-
- per-2001
- hh-1001
- 0-4
- eligible
- Fae Elm
- FAKE-856648
-
-
- per-2002
- hh-1001
- 65+
- eligible
- Jo Apple
- FAKE-806707
-
-
- per-2003
- hh-1002
- 18-64
- eligible
- Dee Iron
- FAKE-219346
-
-
-"#;
diff --git a/crates/registryctl/src/templates/compose-with-notary.yaml b/crates/registryctl/src/templates/compose-with-notary.yaml
index 834a0a8a..968ffea6 100644
--- a/crates/registryctl/src/templates/compose-with-notary.yaml
+++ b/crates/registryctl/src/templates/compose-with-notary.yaml
@@ -8,7 +8,6 @@ services:
- "4242:8080"
volumes:
- ./relay/config.yaml:/etc/registry-relay/config.yaml:ro
- - ./relay/metadata.yaml:/etc/registry-relay/metadata.yaml:ro
- ./data:/var/lib/registry-relay/data:ro
registry-notary:
diff --git a/crates/registryctl/src/templates/compose.yaml b/crates/registryctl/src/templates/compose.yaml
index 63fd3cf4..1f79ac6b 100644
--- a/crates/registryctl/src/templates/compose.yaml
+++ b/crates/registryctl/src/templates/compose.yaml
@@ -8,5 +8,4 @@ services:
- "4242:8080"
volumes:
- ./relay/config.yaml:/etc/registry-relay/config.yaml:ro
- - ./relay/metadata.yaml:/etc/registry-relay/metadata.yaml:ro
- ./data:/var/lib/registry-relay/data:ro
diff --git a/crates/registryctl/src/templates/notary_project_readme.md b/crates/registryctl/src/templates/notary_project_readme.md
index a9734a92..95ff0cff 100644
--- a/crates/registryctl/src/templates/notary_project_readme.md
+++ b/crates/registryctl/src/templates/notary_project_readme.md
@@ -5,10 +5,23 @@ This project was generated by `registryctl`.
## Start
```sh
-registryctl doctor --profile local --format json
registryctl start
registryctl notary smoke
+```
+
+Expected local URL:
+
+```text
+Notary API: http://127.0.0.1:4255
+API docs: http://127.0.0.1:4255/docs
+```
+
+## Inspect
+
+```sh
registryctl notary open
+sed -n '1,220p' notary/config.yaml
+registryctl doctor --profile local --format json
```
The generated local demo credentials live in `secrets/local.env`. They are for
diff --git a/crates/registryctl/src/templates/project_readme.md b/crates/registryctl/src/templates/project_readme.md
index 10049304..9020aea3 100644
--- a/crates/registryctl/src/templates/project_readme.md
+++ b/crates/registryctl/src/templates/project_readme.md
@@ -5,10 +5,40 @@ This project was generated by `registryctl`.
## Start
```sh
-registryctl doctor --profile local --format json
registryctl start
registryctl smoke
+```
+
+Expected local URL:
+
+```text
+Relay API: http://127.0.0.1:4242
+API docs: http://127.0.0.1:4242/docs
+```
+
+The sample workbook at `data/benefits_casework.xlsx` has three sheets:
+`Households`, `Persons`, and `Applications`.
+
+## Try one protected read
+
+```sh
+set -a
+. secrets/local.env
+set +a
+
+curl -sS -G \
+ -H "Authorization: Bearer $ROW_READER_RAW" \
+ -H "Data-Purpose: https://example.local/purpose/tutorial" \
+ --data-urlencode "household_id=hh-1001" \
+ http://127.0.0.1:4242/v1/datasets/benefits_casework/entities/person/records
+```
+
+## Inspect
+
+```sh
registryctl open
+sed -n '1,180p' relay/config.yaml
+registryctl doctor --profile local --format json
```
The generated local demo credentials live in `secrets/local.env`. They are for
diff --git a/crates/registryctl/src/templates/relay_config.yaml.tmpl b/crates/registryctl/src/templates/relay_config.yaml.tmpl
index df8902a4..fc7fefc2 100644
--- a/crates/registryctl/src/templates/relay_config.yaml.tmpl
+++ b/crates/registryctl/src/templates/relay_config.yaml.tmpl
@@ -1,17 +1,23 @@
# Generated by registryctl.
+# This file is the Relay contract for the local sample: service metadata,
+# API-key auth, source spreadsheet tables, aggregates, and public entities.
+# Edit tables, entities, and aggregates when your workbook or API shape changes.
+
+# server controls the listener inside the container. The generated compose file
+# maps it to http://127.0.0.1:4242 on your workstation.
server:
bind: 0.0.0.0:8080
openapi_requires_auth: false
-metadata:
- source:
- path: /etc/registry-relay/metadata.yaml
-
+# catalog values appear in dataset discovery, metadata responses, and OpenAPI.
catalog:
title: Benefits Casework Gateway (registryctl sample)
base_url: http://127.0.0.1:4242
publisher: Registryctl Local Sample
+# The raw bearer keys live in secrets/local.env. Public config keeps only the
+# fingerprint env var names, so it can be shared without leaking credentials.
+# If you rotate a generated key, update the raw key and fingerprint together.
auth:
mode: api_key
api_keys:
@@ -38,11 +44,14 @@ auth:
- benefits_casework:metadata
- benefits_casework:aggregate
+# Audit entries are emitted to container stdout as JSON Lines. The hash secret
+# lets Relay pseudonymize sensitive audit values without storing the secret here.
audit:
sink: stdout
format: jsonl
hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET
+# A dataset is the registry product exposed under /v1/datasets/.
datasets:
- id: benefits_casework
title: Benefits Casework
@@ -55,6 +64,9 @@ datasets:
refresh:
mode: manual
+ # Tables describe the source workbook. The sample uses one Excel file with
+ # three sheets; primary_key names the source column that uniquely identifies
+ # each row.
tables:
- id: households_table
source:
@@ -65,6 +77,9 @@ datasets:
sheet: Households
primary_key: household_id
schema:
+ # strict catches workbook drift by rejecting unexpected source
+ # columns. Mark source fields sensitive when they contain personal
+ # data. The sample maps only the public-safe subset into entities below.
strict: true
fields:
- name: household_id
@@ -73,9 +88,21 @@ datasets:
- name: district
type: string
nullable: false
+ - name: ward
+ type: string
+ nullable: false
- name: poverty_band
type: string
nullable: false
+ - name: household_status
+ type: string
+ nullable: false
+ - name: registered_on
+ type: date
+ nullable: false
+ - name: declared_member_count
+ type: integer
+ nullable: false
- name: address_line
type: string
nullable: true
@@ -98,21 +125,153 @@ datasets:
- name: household_id
type: string
nullable: false
+ - name: given_name
+ type: string
+ nullable: false
+ sensitive: true
+ - name: family_name
+ type: string
+ nullable: false
+ sensitive: true
+ - name: date_of_birth
+ type: date
+ nullable: false
+ sensitive: true
- name: age_band
type: string
nullable: false
+ - name: relationship_to_head
+ type: string
+ nullable: false
+ - name: registration_status
+ type: string
+ nullable: false
- name: eligibility_status
type: string
nullable: false
- - name: full_name
+ - name: is_primary_applicant
+ type: boolean
+ nullable: false
+ - name: national_id
type: string
nullable: true
sensitive: true
- - name: national_id
+
+ - id: applications_table
+ source:
+ type: file
+ path: /var/lib/registry-relay/data/benefits_casework.xlsx
+ format:
+ xlsx:
+ sheet: Applications
+ primary_key: application_id
+ schema:
+ strict: true
+ fields:
+ - name: application_id
type: string
- nullable: true
+ nullable: false
+ - name: household_id
+ type: string
+ nullable: false
+ - name: applicant_person_id
+ type: string
+ nullable: false
+ - name: program
+ type: string
+ nullable: false
+ - name: application_date
+ type: date
+ nullable: false
+ - name: intake_channel
+ type: string
+ nullable: false
+ - name: office_code
+ type: string
+ nullable: false
+ - name: application_status
+ type: string
+ nullable: false
+ - name: decision
+ type: string
+ nullable: false
+ - name: benefit_level
+ type: string
+ nullable: false
+ - name: review_due_on
+ type: date
+ nullable: false
+ - name: identity_verified
+ type: boolean
+ nullable: false
+ - name: residence_verified
+ type: boolean
+ nullable: false
+ - name: consent_reference
+ type: string
+ nullable: false
sensitive: true
+ # Aggregates expose predeclared grouped statistics. aggregate_only_execution
+ # allows aggregate readers to run these definitions without granting row
+ # access. disclosure_control omits small groups from the response.
+ aggregates:
+ - id: by_district
+ title: Households by district
+ description: Count of households by district
+ source_entity: household
+ access:
+ aggregate_only_execution: true
+ default_group_by:
+ - district
+ dimensions:
+ - id: district
+ label: District
+ field: district
+ indicators:
+ - id: household_count
+ label: Households
+ function: count
+ column: id
+ unit_measure: households
+ disclosure_control:
+ min_group_size: 2
+ suppression: omit
+
+ - id: applications_by_program_status
+ title: Applications by program and status
+ description: Count of applications by program and application status
+ source_entity: application
+ access:
+ aggregate_only_execution: true
+ default_group_by:
+ - program
+ - application_status
+ dimensions:
+ - id: program
+ label: Program
+ field: program
+ - id: application_status
+ label: Application status
+ field: application_status
+ indicators:
+ - id: application_count
+ label: Applications
+ function: count
+ column: id
+ unit_measure: applications
+ allowed_filters:
+ - field: program
+ ops: [eq, in]
+ - field: application_status
+ ops: [eq, in]
+ disclosure_control:
+ min_group_size: 2
+ suppression: omit
+
+ # Entities are the public API surface. They choose which table fields are
+ # returned, define relationships for expand=..., attach access scopes, and
+ # restrict filters so collection reads stay intentional.
entities:
- name: household
title: Household
@@ -123,13 +282,25 @@ datasets:
from: household_id
- name: district
from: district
+ - name: ward
+ from: ward
- name: poverty_band
from: poverty_band
+ - name: household_status
+ from: household_status
+ - name: registered_on
+ from: registered_on
+ - name: declared_member_count
+ from: declared_member_count
relationships:
- name: members
kind: has_many
target: person
foreign_key: household_id
+ - name: applications
+ kind: has_many
+ target: application
+ foreign_key: household_id
access:
metadata_scope: benefits_casework:metadata
aggregate_scope: benefits_casework:aggregate
@@ -144,19 +315,9 @@ datasets:
ops: [eq, in]
- field: poverty_band
ops: [eq, in]
- allowed_expansions: [members]
- aggregates:
- - id: by_district
- description: Count of households by district
- group_by:
- - district
- measures:
- - name: household_count
- function: count
- column: id
- disclosure_control:
- min_group_size: 1
- suppression: omit
+ - field: household_status
+ ops: [eq, in]
+ allowed_expansions: [members, applications]
- name: person
title: Person
@@ -169,13 +330,23 @@ datasets:
from: household_id
- name: age_band
from: age_band
+ - name: relationship_to_head
+ from: relationship_to_head
+ - name: registration_status
+ from: registration_status
- name: eligibility_status
from: eligibility_status
+ - name: is_primary_applicant
+ from: is_primary_applicant
relationships:
- name: household
kind: belongs_to
target: household
foreign_key: household_id
+ - name: applications
+ kind: has_many
+ target: application
+ foreign_key: applicant_person_id
access:
metadata_scope: benefits_casework:metadata
aggregate_scope: benefits_casework:aggregate
@@ -184,13 +355,6 @@ datasets:
default_limit: 100
max_limit: 1000
require_purpose_header: true
- required_filters:
- - id
- - household_id
- - eligibility_status
- required_filter_bindings:
- - field: id
- source: principal_id
allowed_filters:
- field: id
ops: [eq, in]
@@ -198,4 +362,73 @@ datasets:
ops: [eq, in]
- field: eligibility_status
ops: [eq, in]
- allowed_expansions: [household]
+ - field: registration_status
+ ops: [eq, in]
+ allowed_expansions: [household, applications]
+
+ - name: application
+ title: Application
+ description: A synthetic benefit application
+ table: applications_table
+ fields:
+ - name: id
+ from: application_id
+ - name: household_id
+ from: household_id
+ - name: applicant_person_id
+ from: applicant_person_id
+ - name: program
+ from: program
+ - name: application_date
+ from: application_date
+ - name: intake_channel
+ from: intake_channel
+ - name: office_code
+ from: office_code
+ - name: application_status
+ from: application_status
+ - name: decision
+ from: decision
+ - name: benefit_level
+ from: benefit_level
+ - name: review_due_on
+ from: review_due_on
+ - name: identity_verified
+ from: identity_verified
+ - name: residence_verified
+ from: residence_verified
+ relationships:
+ - name: household
+ kind: belongs_to
+ target: household
+ foreign_key: household_id
+ - name: applicant
+ kind: belongs_to
+ target: person
+ foreign_key: applicant_person_id
+ access:
+ metadata_scope: benefits_casework:metadata
+ aggregate_scope: benefits_casework:aggregate
+ read_scope: benefits_casework:rows
+ api:
+ default_limit: 100
+ max_limit: 1000
+ require_purpose_header: true
+ allowed_filters:
+ - field: id
+ ops: [eq, in]
+ - field: household_id
+ ops: [eq, in]
+ - field: applicant_person_id
+ ops: [eq, in]
+ - field: program
+ ops: [eq, in]
+ - field: application_status
+ ops: [eq, in]
+ - field: decision
+ ops: [eq, in]
+ - field: benefit_level
+ ops: [eq, in]
+ - field: review_due_on
+ ops: [gte, lte, between]
+ allowed_expansions: [household, applicant]
diff --git a/crates/registryctl/src/templates/relay_metadata.yaml b/crates/registryctl/src/templates/relay_metadata.yaml
deleted file mode 100644
index 8504ec48..00000000
--- a/crates/registryctl/src/templates/relay_metadata.yaml
+++ /dev/null
@@ -1,64 +0,0 @@
-# Generated by registryctl.
-schema_version: registry-manifest/v1
-catalog:
- id: registryctl-benefits-sample
- base_url: http://127.0.0.1:4242
- title: Benefits Casework Gateway (registryctl sample)
- publisher:
- name: Registryctl Local Sample
- standards:
- dcat: '3.0'
- shacl: '1.1'
- json_schema: 2020-12
-datasets:
- - id: benefits_casework
- title: Benefits Casework
- description: Synthetic benefits casework registry for local registryctl evaluation.
- owner: Registryctl Local Sample
- sensitivity: personal
- access_rights: restricted
- update_frequency: monthly
- entities:
- - name: household
- title: Household
- description: A synthetic benefits household
- identifiers:
- - name: id
- kind: primary
- fields:
- - name: id
- type: string
- required: true
- - name: district
- type: string
- required: true
- - name: poverty_band
- type: string
- required: true
- relationships:
- - name: members
- target_entity: person
- cardinality: many
- - name: person
- title: Person
- description: A synthetic person in a benefits household
- identifiers:
- - name: id
- kind: primary
- fields:
- - name: id
- type: string
- required: true
- - name: household_id
- type: string
- required: true
- - name: age_band
- type: string
- required: true
- - name: eligibility_status
- type: string
- required: true
- relationships:
- - name: household
- target_entity: household
- cardinality: zero_or_one
diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs
index fa60de1d..79f44582 100644
--- a/docs/site/astro.config.mjs
+++ b/docs/site/astro.config.mjs
@@ -67,8 +67,8 @@ export default defineConfig({
// quickstart's "Choose by question" router merged into the homepage (2026-06).
'/start/quickstart/': internalRedirect('/'),
'/start/your-first-call/': internalRedirect('/tutorials/first-run-with-registry-lab/'),
- // verify-claim-own-api merged into the claim-verification tutorial (2026-06).
- '/tutorials/verify-claim-own-api/': internalRedirect('/tutorials/verify-claim-registry-api/'),
+ // verify-claim-own-api moved into the Apply to your stack path (2026-06).
+ '/tutorials/verify-claim-own-api/': internalRedirect('/tutorials/run-notary-standalone-for-api/'),
'/tutorials/verify-opencrvs-dci-claims/': internalRedirect('/tutorials/verify-opencrvs-claims/'),
// Problems -> marketing /why
'/problems/': `${marketing}/why/`,
@@ -204,8 +204,9 @@ export default defineConfig({
//
// "Get started" is orientation only: Overview (which carries the
// "Choose by question" router), the zero-install demo, and the
- // evaluation page. The hands-on pages live under Tutorials, ordered by
- // weight: the lightest local run first, the full multi-service lab last.
+ // evaluation page. The core Tutorials path stays on one generated local
+ // project; operator paths live under Apply to your stack, and named
+ // source-system paths live under Integrate existing systems.
sidebar: [
{
label: 'Get started',
@@ -220,11 +221,23 @@ export default defineConfig({
{
label: 'Tutorials',
items: [
- { label: 'Publish a spreadsheet', slug: 'tutorials/publish-spreadsheet-secured-registry-api' },
- { label: 'Verify a claim', slug: 'tutorials/verify-claim-registry-api' },
- { label: 'OpenCRVS claims', slug: 'tutorials/verify-opencrvs-claims' },
+ { label: 'Run a protected API', slug: 'tutorials/publish-spreadsheet-secured-registry-api' },
+ { label: 'Evaluate a claim', slug: 'tutorials/verify-claim-registry-api' },
+ ],
+ },
+ {
+ label: 'Apply to your stack',
+ items: [
+ { label: 'Notary for a Registry Data API', slug: 'tutorials/run-notary-standalone-for-api' },
{ label: 'Deploy with own data', slug: 'tutorials/deploy-standalone-with-own-data' },
{ label: 'Run the lab', slug: 'tutorials/first-run-with-registry-lab' },
+ ],
+ },
+ {
+ label: 'Integrate existing systems',
+ items: [
+ { label: 'OpenCRVS claims', slug: 'tutorials/verify-opencrvs-claims' },
+ { label: 'DHIS2 claim checks', slug: 'tutorials/configure-dhis2-claim-checks' },
{ label: 'FHIR evidence', slug: 'tutorials/getting-started-fhir-evidence' },
],
},
diff --git a/docs/site/scripts/check-tutorial.sh b/docs/site/scripts/check-tutorial.sh
index eff1a67d..b5cde043 100755
--- a/docs/site/scripts/check-tutorial.sh
+++ b/docs/site/scripts/check-tutorial.sh
@@ -150,8 +150,8 @@ done
# inside `sh` fences. Bump the expected count when you intentionally add or
# remove a documented command.
REGISTRYCTL_TUTORIALS=(
- "publish-spreadsheet-secured-registry-api:31"
- "verify-claim-registry-api:74"
+ "publish-spreadsheet-secured-registry-api:29"
+ "verify-claim-registry-api:55"
)
count_sh_command_lines() {
diff --git a/docs/site/src/components/HomeLanding.astro b/docs/site/src/components/HomeLanding.astro
index 5d19dbac..3719c488 100644
--- a/docs/site/src/components/HomeLanding.astro
+++ b/docs/site/src/components/HomeLanding.astro
@@ -51,10 +51,10 @@ const cards: RouteCard[] = [
id: 'try-locally',
question: 'How do I try it on my own machine?',
start: {
- label: 'Publish a spreadsheet as a secured registry API',
+ label: 'Run a protected registry API locally',
href: '/tutorials/publish-spreadsheet-secured-registry-api/',
},
- then: [{ label: 'Verify a claim with Registry Notary', href: '/tutorials/verify-claim-registry-api/' }],
+ then: [{ label: 'Evaluate a claim with Registry Notary', href: '/tutorials/verify-claim-registry-api/' }],
},
{
id: 'full-demo',
@@ -64,10 +64,16 @@ const cards: RouteCard[] = [
},
{
id: 'fhir',
- question: 'How do I try claim checks from FHIR-shaped health data?',
+ question: 'How do I integrate FHIR-shaped health data?',
start: { label: 'Getting started with FHIR evidence', href: '/tutorials/getting-started-fhir-evidence/' },
then: [{ label: 'Integration patterns', href: '/explanation/integration-patterns/' }],
},
+ {
+ id: 'dhis2',
+ question: 'How do I configure claim checks from DHIS2?',
+ start: { label: 'Configure DHIS2 claim checks', href: '/tutorials/configure-dhis2-claim-checks/' },
+ then: [{ label: 'Integration patterns', href: '/explanation/integration-patterns/' }],
+ },
{
id: 'own-data',
question: 'How do I deploy Relay against my own data, not a sample?',
@@ -79,16 +85,16 @@ const cards: RouteCard[] = [
},
{
id: 'existing-api',
- question: 'I already have an API. How do I verify claims from it?',
+ question: 'I already have a Registry Data API source. How do I evaluate claims from it?',
start: {
- label: 'Run Notary standalone for an API you operate',
- href: '/tutorials/verify-claim-registry-api/#run-notary-standalone-for-an-api-you-operate',
+ label: 'Connect Notary to a Registry Data API source',
+ href: '/tutorials/run-notary-standalone-for-api/',
},
then: [{ label: 'Registry Notary', href: '/products/registry-notary/' }],
},
{
id: 'opencrvs',
- question: 'How do I verify claims from OpenCRVS?',
+ question: 'How do I integrate OpenCRVS claim checks?',
start: { label: 'Verify OpenCRVS claims with registryctl', href: '/tutorials/verify-opencrvs-claims/' },
then: [
{
diff --git a/docs/site/src/content/docs/explanation/integration-patterns.mdx b/docs/site/src/content/docs/explanation/integration-patterns.mdx
index eaec76db..bef3cc6e 100644
--- a/docs/site/src/content/docs/explanation/integration-patterns.mdx
+++ b/docs/site/src/content/docs/explanation/integration-patterns.mdx
@@ -74,8 +74,8 @@ Wire Registry Stack in alongside a domain platform when:
## Workflow engines
-Examples: [OpenFn](https://www.openfn.org/) (cross-system orchestration popular in humanitarian and
-government data flows), [Camunda](https://camunda.com/), [Flowable](https://www.flowable.com/).
+Examples: [Camunda](https://camunda.com/), [Flowable](https://www.flowable.com/), and other
+casework or process orchestration engines.
Workflow engines own process state, task assignment, timers, branching, retries, escalation, and
history.
@@ -90,8 +90,8 @@ Wire Registry Stack in alongside a workflow engine when:
## Exchange layers
-Examples: [X-Road](https://x-road.global/), [OpenFn](https://www.openfn.org/) (when used as the
-cross-institution data flow), GovStack-style reference exchanges.
+Examples: [X-Road](https://x-road.global/), GovStack-style reference exchanges, and
+country-specific interoperability layers.
Exchange layers own participant onboarding, message transport, mutual trust, routing, addressing,
and cross-institution policy.
diff --git a/docs/site/src/content/docs/reference/apis/registry-notary.mdx b/docs/site/src/content/docs/reference/apis/registry-notary.mdx
index 661159d2..0c7149b5 100644
--- a/docs/site/src/content/docs/reference/apis/registry-notary.mdx
+++ b/docs/site/src/content/docs/reference/apis/registry-notary.mdx
@@ -93,10 +93,8 @@ under `source_connections`:
- **SP-DCI** connector sends DCI-shaped search requests and parses the response using configured
field paths.
- **Source adapter sidecar** connector calls a private sidecar over HTTP, using a static
- bearer token, for sources reached through the built-in `http_json`, `http_flow`, or `fhir`
- engines. (Note: OpenFn as an external caller, where OpenFn workflows call Registry Notary, remains
- supported; what is retired is the OpenFn Node worker pool as a source-reading engine inside the
- sidecar.)
+ bearer token, for sources reached through the built-in `http_json`, `http_flow`, `fhir`, or
+ `script_rhai` engines.
There is no generic file-based or database connector; source connectors must reach their targets
over HTTP.
@@ -113,9 +111,9 @@ path cannot affect another.
- **Holder binding.** `did:jwk` is the only supported proof-of-possession binding method.
- **Audit events.** Every evaluated request emits an `EvidenceAuditEvent` in a Registry Platform
audit envelope to a configured sink (stdout, file, or JSONL).
-- **Demo helpers.** Demo OpenFn snippets and generated workflow helpers are examples, not production
- replay-protection profiles. Add freshness, expiry, or nonce checks before copying a helper into a
- production workflow.
+- **Demo helpers.** Generated workflow helpers are examples, not production replay-protection
+ profiles. Add freshness, expiry, or nonce checks before copying a helper into a production
+ workflow.
:::caution
Audit write failures surface as request errors, not as silent log entries. If the audit sink is
diff --git a/docs/site/src/content/docs/reference/environment-variables.mdx b/docs/site/src/content/docs/reference/environment-variables.mdx
index d621ac5a..5d932e76 100644
--- a/docs/site/src/content/docs/reference/environment-variables.mdx
+++ b/docs/site/src/content/docs/reference/environment-variables.mdx
@@ -47,7 +47,7 @@ The audit hash secret and other secret material are read from operator-named var
## registryctl
-registryctl reads the variables below. It also reads operator-provided source and credential variables whose names are passed to `registryctl openfn` and `registryctl init notary` flags; those are not fixed names.
+registryctl reads the variables below. It also reads operator-provided source and credential variables whose names are passed to `registryctl init notary` and legacy compatibility flags; those are not fixed names.
| Name | Purpose | Default or required |
| --- | --- | --- |
@@ -56,14 +56,14 @@ registryctl reads the variables below. It also reads operator-provided source an
| `REGISTRYCTL_VERSION` | Pinned release the installer downloads. Read by the install script, not the running binary. | Defaults to the installer's pinned release. |
| `CI` | When set to a non-empty value other than `0` or `false`, disables the automatic update check. | Optional. |
-registryctl also passes through operator-named source and sidecar variables. The defaults below are the variable names registryctl writes into generated projects and OpenFn snippets; the operator can override them with flags.
+registryctl also passes through operator-named source and sidecar variables. The defaults below are the variable names registryctl writes into generated projects and compatibility snippets; the operator can override them with flags.
| Name | Purpose | Default or required |
| --- | --- | --- |
| `EVIDENCE_SOURCE_API_TOKEN` | Default source API bearer token variable for a `registry-data-api` Notary starter project. | Default name; override with `init notary --source-token-env`. |
| `FHIR_SIDECAR_TOKEN` | Default source token variable for a `fhir-sidecar` Notary starter project. | Default name; override with `init notary --source-token-env`. |
-| `OPENFN_TOKEN` | OpenFn API token for `registryctl openfn import` URL imports. | Default name; override with `--openfn-token-env`. |
-| `OPENFN_SIDECAR_TOKEN` | Raw notary-to-sidecar bearer token written into the generated snippet. | Default name; override with `--sidecar-token-env`. |
+| `OPENFN_TOKEN` | Legacy OpenFn API token name for `registryctl openfn import` URL imports. | Default name; override with `--openfn-token-env`. |
+| `OPENFN_SIDECAR_TOKEN` | Legacy notary-to-sidecar bearer token name written into compatibility snippets. | Default name; override with `--sidecar-token-env`. |
| `DEV_SIDECAR_TOKEN_HASH` | Notary-to-sidecar bearer token hash. | Default name; override with `--auth-hash-env`. |
| `REGISTRY_NOTARY_BASE_URL`, `REGISTRY_NOTARY_BEARER_TOKEN`, `REGISTRY_NOTARY_API_KEY`, `REGISTRY_NOTARY_PURPOSE` | Shell exports emitted by `registryctl lab env` for hosted-lab SDK quickstarts. | Emitted by `lab env`; these are public synthetic lab values. |
diff --git a/docs/site/src/content/docs/reference/registryctl.mdx b/docs/site/src/content/docs/reference/registryctl.mdx
index 6caec390..6587217b 100644
--- a/docs/site/src/content/docs/reference/registryctl.mdx
+++ b/docs/site/src/content/docs/reference/registryctl.mdx
@@ -89,9 +89,12 @@ Smoke commands run built-in local checks and write a JSON result file.
| `notary smoke` | Run built-in local Notary smoke checks. |
| `notary open` | Open or print the local Notary API docs URL. |
-## OpenFn
+## Legacy OpenFn conversion
-`openfn` converts OpenFn workflow exports into Registry Notary OpenFn sidecar runtime files. The flags below apply to both `openfn import` and `openfn convert` unless noted.
+`openfn` is a compatibility command for converting existing OpenFn workflow exports into sidecar
+runtime files. Current source-adapter projects use the built-in `http_json`, `http_flow`, `fhir`,
+or `script_rhai` engines instead. The flags below apply to both `openfn import` and `openfn convert`
+unless noted.
| Subcommand or flag | Purpose |
| --- | --- |
diff --git a/docs/site/src/content/docs/start/see-it-live.mdx b/docs/site/src/content/docs/start/see-it-live.mdx
index ad2d6c13..0a7819c3 100644
--- a/docs/site/src/content/docs/start/see-it-live.mdx
+++ b/docs/site/src/content/docs/start/see-it-live.mdx
@@ -6,7 +6,7 @@ owner: registry-docs
source_repos:
- registry-lab
- registry-registryctl
-last_reviewed: "2026-06-13"
+last_reviewed: "2026-06-28"
doc_type: tutorial
locale: en
standards_referenced:
@@ -21,8 +21,7 @@ will read a protected registry API from your terminal (**Registry Relay**, which
authorized callers), then have **Registry Notary** issue a signed credential, delivered to a hosted
demo wallet, that answers a question without exposing the record. The Relay reads are pure `curl`
with zero install; the credential step is a guided browser flow using the lab's hosted wallet, so it
-needs no install either; only the optional developer round-trip at the end also uses `registryctl`,
-`jq`, and `node`. Everything runs in the hosted lab at
+needs no install either. Everything runs in the hosted lab at
[lab.registrystack.org](https://lab.registrystack.org), so the main flow has no setup on your
machine.
@@ -30,7 +29,7 @@ machine.
outcome="One protected registry read and one signed, privacy-preserving credential, against the public hosted lab."
time="About 10 minutes"
level="Hosted lab, zero install"
- prerequisites={['A web browser', 'Optional: curl, registryctl, jq, node']}
+ prerequisites={['A web browser', 'Optional: curl']}
/>
This lab uses synthetic data and public demo-only credentials by design.
@@ -45,7 +44,7 @@ The lab runs three services that you will touch directly:
- A citizen **Notary**: an issuer that hands out a privacy-preserving credential instead of the raw
record, at [citizen-notary.lab.registrystack.org](https://citizen-notary.lab.registrystack.org).
- A health-program **Notary** in front of a demo DHIS2 health information system, used only by the
- optional developer round-trip at the end of this page, at
+ DHIS2 integration tutorial, at
[dhis2-notary.lab.registrystack.org](https://dhis2-notary.lab.registrystack.org).
For how these connect to the rest of the stack, see the
@@ -187,155 +186,25 @@ refuses: it will not issue a credential for a subject you did not authenticate a
be talked into vouching for someone else. For more on this service, see
[Registry Notary](../../products/registry-notary/).
-### Developer API round-trip
+### DHIS2 integration path
-For a pure API credential flow, use the hosted DHIS2 Notary: the lab runs a demo
-[DHIS2](https://dhis2.org/) health information system with a Notary in front of it, so the same
-issuance contract can be exercised against a second, independent source system. This path does
-not use the browser wallet sign-in, and unlike the curl-only reads above it also needs
-`registryctl`, `jq`, and `node` on your machine. It evaluates DHIS2 child-program claims, issues an
-`application/dc+sd-jwt` credential, fetches the issuer JWKS, verifies the Ed25519 signature, and
-checks that each disclosure hash is listed in the issuer-signed JWT.
-
-Load the hosted Notary URL, current demo bearer token, and purpose URI from the lab manifest.
-The `dhis2-bearer` credential is the demo evidence-client token for this Notary.
-
-```sh
-eval "$(registryctl lab env --credential dhis2-bearer)"
-```
-
-Evaluate the credential claims for the live demo tracked entity:
-
-```sh
-cat > evaluation-request.json <<'JSON'
-{
- "target": {
- "type": "TrackedEntity",
- "identifiers": [
- { "scheme": "dhis2_tracked_entity", "value": "PQfMcpmXeFE" }
- ]
- },
- "claims": [
- "dhis2-tracked-entity-first-name",
- "dhis2-tracked-entity-last-name",
- "dhis2-child-program-active"
- ],
- "disclosure": "value",
- "format": "application/dc+sd-jwt"
-}
-JSON
-
-curl -fsS -X POST "$REGISTRY_NOTARY_BASE_URL/v1/evaluations" \
- -H "Authorization: Bearer $REGISTRY_NOTARY_BEARER_TOKEN" \
- -H "Content-Type: application/json" \
- -H "Data-Purpose: $REGISTRY_NOTARY_PURPOSE" \
- --data @evaluation-request.json \
- -o evaluation.json
-
-jq '.results[] | {claim_id, value, evaluation_id, format}' evaluation.json
-```
-
-Issue the SD-JWT VC from that evaluation:
-
-```sh
-EVALUATION_ID="$(jq -r '.results[0].evaluation_id' evaluation.json)"
-
-jq -n --arg evaluation_id "$EVALUATION_ID" '{
- evaluation_id: $evaluation_id,
- credential_profile: "dhis2_child_program_sd_jwt",
- format: "application/dc+sd-jwt",
- claims: [
- "dhis2-tracked-entity-first-name",
- "dhis2-tracked-entity-last-name",
- "dhis2-child-program-active"
- ],
- disclosure: "value"
-}' > credential-request.json
-
-curl -fsS -X POST "$REGISTRY_NOTARY_BASE_URL/v1/credentials" \
- -H "Authorization: Bearer $REGISTRY_NOTARY_BEARER_TOKEN" \
- -H "Content-Type: application/json" \
- --data @credential-request.json \
- -o credential.json
-
-jq '{credential_id, credential_profile, issuer, format, disclosure_count: (.disclosures | length)}' credential.json
-```
-
-Fetch the issuer keys and verify the credential.
-The JWKS endpoint is public: any verifier can fetch the issuer keys without a credential.
-
-```sh
-curl -fsS "$REGISTRY_NOTARY_BASE_URL/.well-known/evidence/jwks.json" -o jwks.json
-
-node <<'NODE'
-const { createHash, createPublicKey, verify } = require('node:crypto');
-const { readFileSync } = require('node:fs');
-
-const credential = JSON.parse(readFileSync('credential.json', 'utf8'));
-const jwks = JSON.parse(readFileSync('jwks.json', 'utf8'));
-const [header64, payload64, signature64] = credential.issuer_signed_jwt.split('.');
-const header = JSON.parse(Buffer.from(header64, 'base64url').toString('utf8'));
-const payload = JSON.parse(Buffer.from(payload64, 'base64url').toString('utf8'));
-const keys = Array.isArray(jwks.keys) ? jwks.keys : [];
-const key = keys.find((candidate) => candidate.kid === header.kid);
-
-if (!key) throw new Error(`Missing JWKS key ${header.kid}`);
-
-const signed = Buffer.from(`${header64}.${payload64}`);
-const signature = Buffer.from(signature64, 'base64url');
-const signatureValid = verify(null, signed, createPublicKey({ key, format: 'jwk' }), signature);
-
-if (!signatureValid) throw new Error('Bad issuer signature');
-
-const disclosureDigests = new Set(payload._sd || []);
-const disclosureClaims = [];
-
-for (const disclosure of credential.disclosures || []) {
- const digest = createHash('sha256').update(disclosure).digest('base64url');
- if (!disclosureDigests.has(digest)) {
- throw new Error(`Disclosure digest not present in _sd: ${digest}`);
- }
- const decoded = JSON.parse(Buffer.from(disclosure, 'base64url').toString('utf8'));
- disclosureClaims.push(decoded[1]);
-}
-
-console.log(JSON.stringify({
- issuer: payload.iss,
- vct: payload.vct,
- signature_valid: signatureValid,
- disclosure_count: disclosureClaims.length,
- disclosure_claims: disclosureClaims.sort()
-}, null, 2));
-NODE
-```
-
-Expected verification summary:
-
-```json
-{
- "issuer": "did:web:dhis2-notary.lab.registrystack.org",
- "vct": "https://dhis2-notary.lab.registrystack.org/credentials/dhis2/child-program/v1",
- "signature_valid": true,
- "disclosure_count": 3,
- "disclosure_claims": [
- "dhis2-child-program-active",
- "dhis2-tracked-entity-first-name",
- "dhis2-tracked-entity-last-name"
- ]
-}
-```
+The lab also runs a DHIS2-backed Notary for API credential and claim-evaluation work.
+Because that path is about integrating an existing source system, it lives in its own tutorial:
+[Configure DHIS2 claim checks](../../tutorials/configure-dhis2-claim-checks/).
## Now run your own
You have seen the two payoffs against the hosted lab. To run the same shapes on your own machine,
start with the local single-node tutorials:
-- [Publish a spreadsheet as a secured registry API](../../tutorials/publish-spreadsheet-secured-registry-api/):
+- [Run a protected registry API locally](../../tutorials/publish-spreadsheet-secured-registry-api/):
stand up your own protected Relay from a sample workbook.
-- [Verify a claim with Registry Notary](../../tutorials/verify-claim-registry-api/): run a Notary
- against the Relay you published and evaluate a claim. Already have your own API? The same
- tutorial ends with a
- [standalone Notary path](../../tutorials/verify-claim-registry-api/#run-notary-standalone-for-an-api-you-operate).
+- [Evaluate a claim with Registry Notary](../../tutorials/verify-claim-registry-api/): run a Notary
+ against the Relay you published and evaluate a claim.
+- [Connect Notary to a Registry Data API source](../../tutorials/run-notary-standalone-for-api/):
+ run Notary in a separate project against a Registry Data API-shaped source.
+- [Configure DHIS2 claim checks](../../tutorials/configure-dhis2-claim-checks/): connect Registry
+ Notary to a DHIS2 source adapter and inspect the fields to change for your own DHIS2 deployment.
Only assessing fit? You do not need to install anything:
[When to use Registry Stack](../when-to-use/) covers fit and non-goals, and
diff --git a/docs/site/src/content/docs/tutorials/configure-dhis2-claim-checks.mdx b/docs/site/src/content/docs/tutorials/configure-dhis2-claim-checks.mdx
new file mode 100644
index 00000000..7b6e7dfe
--- /dev/null
+++ b/docs/site/src/content/docs/tutorials/configure-dhis2-claim-checks.mdx
@@ -0,0 +1,334 @@
+---
+title: Configure DHIS2 claim checks
+description: Run the DHIS2 source-adapter profile, evaluate DHIS2 Tracker claims with Registry Notary, and identify the config blocks to adapt for your own DHIS2 deployment.
+status: current
+owner: registry-docs
+source_repos:
+ - registry-lab
+ - registry-notary
+last_reviewed: "2026-06-28"
+doc_type: tutorial
+locale: en
+standards_referenced:
+ - openapi
+ - sd-jwt-vc
+---
+
+import QuickstartMeta from '../../../components/QuickstartMeta.astro';
+
+Use this tutorial when DHIS2 is already your source system and you want Registry Notary to answer
+narrow programme-status claims without sharing full DHIS2 records.
+The tutorial uses the public DHIS2 play instance as the source, runs a private source-adapter
+sidecar, and exposes only the Registry Notary API to the caller.
+
+You will configure three pieces:
+
+- The DHIS2 source adapter, which calls the DHIS2 Tracker API through the built-in `http_json`
+ engine.
+- Registry Notary source bindings, which map projected DHIS2 fields into claim rules.
+- Credential profiles, which issue SD-JWT VCs from selected DHIS2 claim results.
+
+
+
+The demo uses the public DHIS2 sandbox at `https://play.im.dhis2.org/stable-2-43-0-1`.
+The same configuration shape applies to a private DHIS2 deployment once you replace the source URL,
+credentials, Tracker query, field projection, and claim rules.
+
+## Before you start
+
+You need:
+
+- Docker with Compose v2.20 or later.
+- The `just` command runner.
+- `jq` and `node` for manual checks and holder-proof generation.
+- A Registry Lab checkout, which is the `lab` directory of the `registry-stack` monorepo.
+- Network access to the DHIS2 source you are testing.
+
+Clone the monorepo if you do not already have it:
+
+```sh
+git clone https://github.com/registrystack/registry-stack.git
+cd registry-stack/lab
+just setup
+```
+
+For an existing checkout, update submodules before running the profile:
+
+```sh
+cd registry-stack/lab
+just setup
+```
+
+## Set DHIS2 source access
+
+The local demo defaults to the public DHIS2 play credentials. The current lab profile still uses
+legacy `OPENFN_*` variable names for this DHIS2 sidecar. To point the profile at another DHIS2
+environment, set these values before you run the profile:
+
+```sh
+export OPENFN_DHIS2_HOST_URL="https://play.im.dhis2.org/stable-2-43-0-1"
+export OPENFN_DHIS2_USERNAME="admin"
+export OPENFN_DHIS2_PASSWORD="district"
+```
+
+In a private deployment, replace those values with a DHIS2 base URL and a reader account scoped to
+the Tracker data you need. Do not use public sandbox credentials outside the demo.
+
+The Compose profile turns those values into the credential JSON consumed by the sidecar:
+
+```json
+{
+ "baseUrl": "https://play.im.dhis2.org/stable-2-43-0-1",
+ "username": "admin",
+ "password": "district"
+}
+```
+
+## Generate local credentials
+
+Generate local Notary tokens, sidecar tokens, signing material, and static metadata:
+
+```sh
+just generate
+```
+
+This writes `.env`, including:
+
+- `DHIS2_EVIDENCE_CLIENT_BEARER`, used by callers when they call Registry Notary.
+- `OPENFN_SIDECAR_TOKEN_RAW`, the legacy variable name Registry Notary uses when it calls the
+ private DHIS2 sidecar.
+- `OPENFN_SIDECAR_TOKEN_HASH`, the legacy variable name the sidecar uses to authenticate Notary.
+
+Do not use generated demo credentials outside the local lab.
+
+## Run the DHIS2 profile
+
+Build the lab images:
+
+```sh
+just build
+```
+
+Run the DHIS2 smoke:
+
+```sh
+just dhis2
+```
+
+The profile starts:
+
+| Service | Local URL | Role |
+| --- | --- | --- |
+| `openfn-dhis2-sidecar` | private Compose network only | DHIS2 source adapter that calls DHIS2 Tracker through `http_json` |
+| `dhis2-health-notary` | `http://127.0.0.1:4326` | Registry Notary claim evaluator and SD-JWT VC issuer |
+
+The service name keeps the legacy `openfn` prefix, but this path uses the built-in `http_json`
+engine. There is no Node worker pool or OpenFn workflow runtime in the DHIS2 lookup path.
+
+Expected ending:
+
+```text
+DHIS2 health evidence and VC smoke passed
+```
+
+## Inspect the results
+
+The smoke writes artifacts under:
+
+```text
+output/dhis2-openfn/
+```
+
+Inspect the predicate checks:
+
+```sh
+jq '{claim_id: .results[0].claim_id, satisfied: .results[0].satisfied}' \
+ output/dhis2-openfn/smoke-dhis2-child-program-active.json
+```
+
+Inspect the programme participation credential summary:
+
+```sh
+jq '{
+ credential_profile,
+ issuer,
+ format,
+ disclosure_count,
+ disclosure_claim_ids,
+ programme_active,
+ reconciliation_ref_available
+}' output/dhis2-openfn/smoke-dhis2-programme-participation-credential-summary.json
+```
+
+The full credential response and holder proof are also written under `output/dhis2-openfn/`.
+Treat those files as local debug artifacts and review them before sharing.
+
+## Call Notary manually
+
+Load the generated local credentials:
+
+```sh
+set -a
+. .env
+set +a
+```
+
+Call Notary discovery:
+
+```sh
+curl -fsS http://127.0.0.1:4326/.well-known/evidence-service \
+ -H "Authorization: Bearer ${DHIS2_EVIDENCE_CLIENT_BEARER}" | jq
+```
+
+Evaluate the programme participation claims for the demo tracked entity:
+
+```sh
+curl -fsS -X POST http://127.0.0.1:4326/v1/evaluations \
+ -H "Authorization: Bearer ${DHIS2_EVIDENCE_CLIENT_BEARER}" \
+ -H "Content-Type: application/json" \
+ -H "Data-Purpose: https://demo.example.gov/purpose/dhis2-openfn-health-evidence" \
+ -o output/dhis2-openfn/manual-programme-evaluation.json \
+ -d '{
+ "target": {
+ "type": "TrackedEntity",
+ "identifiers": [
+ { "scheme": "dhis2_tracked_entity", "value": "PQfMcpmXeFE" }
+ ]
+ },
+ "claims": [
+ "dhis2-tracked-entity-first-name",
+ "dhis2-tracked-entity-last-name",
+ "dhis2-child-age-band",
+ "dhis2-programme-code",
+ "dhis2-child-program-active",
+ "dhis2-reconciliation-ref"
+ ],
+ "disclosure": "value",
+ "format": "application/dc+sd-jwt"
+ }'
+
+jq '.results[] | {claim_id, value, satisfied, evaluation_id}' \
+ output/dhis2-openfn/manual-programme-evaluation.json
+```
+
+## Issue the programme credential
+
+Generate a holder proof for the returned evaluation:
+
+```sh
+EVALUATION_ID="$(
+ jq -r '.results[0].evaluation_id' \
+ output/dhis2-openfn/manual-programme-evaluation.json
+)"
+
+CLAIMS='[
+ "dhis2-tracked-entity-first-name",
+ "dhis2-tracked-entity-last-name",
+ "dhis2-child-age-band",
+ "dhis2-programme-code",
+ "dhis2-child-program-active",
+ "dhis2-reconciliation-ref"
+]'
+
+scripts/generate-holder-proof.js \
+ --audience dhis2-health-notary \
+ --evaluation-id "$EVALUATION_ID" \
+ --credential-profile dhis2_programme_participation_sd_jwt \
+ --disclosure value \
+ --claims-json "$CLAIMS" \
+ > output/dhis2-openfn/manual-programme-holder.json
+```
+
+Issue the SD-JWT VC:
+
+```sh
+curl -fsS -X POST http://127.0.0.1:4326/v1/credentials \
+ -H "Authorization: Bearer ${DHIS2_EVIDENCE_CLIENT_BEARER}" \
+ -H "Content-Type: application/json" \
+ -o output/dhis2-openfn/manual-programme-credential.json \
+ -d "$(jq -nc \
+ --arg evaluation_id "$EVALUATION_ID" \
+ --argjson claims "$CLAIMS" \
+ --slurpfile holder output/dhis2-openfn/manual-programme-holder.json \
+ '{
+ evaluation_id: $evaluation_id,
+ credential_profile: "dhis2_programme_participation_sd_jwt",
+ format: "application/dc+sd-jwt",
+ claims: $claims,
+ disclosure: "value",
+ holder: $holder[0].holder
+ }')"
+
+jq '{credential_id, credential_profile, issuer, format, disclosure_count: (.disclosures | length)}' \
+ output/dhis2-openfn/manual-programme-credential.json
+```
+
+The issued credential is holder-bound to the generated `did:jwk` proof and uses the
+`dhis2_programme_participation_sd_jwt` profile.
+
+## How the adapter is configured
+
+The DHIS2 source adapter is configured in `config/source-adapter/dhis2-health-sidecar.yaml`.
+
+Key settings:
+
+| Setting | What it does |
+| --- | --- |
+| `sources.dhis2_health.engine` | Uses the built-in `http_json` engine. |
+| `sources.dhis2_health.credential_env` | Reads DHIS2 `baseUrl`, `username`, and `password` from the legacy `OPENFN_DHIS2_DEMO_CREDENTIAL_JSON` variable. |
+| `sources.dhis2_health.allowed_base_urls` | Pins which DHIS2 base URLs the sidecar may call. |
+| `sources.dhis2_health.http_json.path` | Calls `/api/tracker/trackedEntities`. |
+| `sources.dhis2_health.http_json.query` | Maps the Notary lookup value into DHIS2 Tracker query parameters. |
+| `sources.dhis2_health.http_json.response.records` | Projects the DHIS2 Tracker JSON response into the fields Notary can evaluate. |
+
+The demo maps one DHIS2 tracked entity into fields such as `first_name`, `last_name`,
+`child_program_active`, `child_age_band`, `programme_code`, and `reconciliation_ref`.
+For your deployment, change the DHIS2 base URL allow-list, program IDs, attribute IDs, source
+projection, and smoke lookup subject to match your DHIS2 configuration.
+
+## How Notary is configured
+
+Registry Notary is configured in `config/notary/dhis2-health-notary.yaml`.
+
+Key settings:
+
+| Setting | What it does |
+| --- | --- |
+| `evidence.source_connections.dhis2_openfn.base_url` | Points Notary at the private sidecar. |
+| `evidence.source_connections.dhis2_openfn.token_env` | Sends the shared sidecar token from the legacy `OPENFN_SIDECAR_TOKEN_RAW` variable. |
+| `evidence.allowed_purposes` | Restricts which `Data-Purpose` values may be used. |
+| `evidence.claims[].source_bindings` | Binds each claim to the projected DHIS2 fields. |
+| `evidence.claims[].rule` | Extracts a value or evaluates a predicate from the projected fields. |
+| `evidence.credential_profiles` | Controls which claims can be issued as SD-JWT VCs, validity, issuer, disclosure, and holder binding. |
+
+For your deployment, update the claims to match the programme facts your relying services need.
+Keep the sidecar private; expose Notary, not DHIS2, to callers.
+
+## Stop the profile
+
+Stop the DHIS2 services:
+
+```sh
+docker compose -f compose.yaml --profile dhis2 down
+```
+
+## Troubleshooting
+
+| Symptom | Cause | Fix |
+| --- | --- | --- |
+| `missing .env` | Local credentials have not been generated. | Run `just generate`. |
+| Notary discovery returns `401` | The request is missing the local bearer token. | Add `Authorization: Bearer ${DHIS2_EVIDENCE_CLIENT_BEARER}`. |
+| The sidecar cannot reach DHIS2 | The source URL, credentials, or network path is wrong, or the public sandbox reset. | Check `OPENFN_DHIS2_HOST_URL`, `OPENFN_DHIS2_USERNAME`, `OPENFN_DHIS2_PASSWORD`, then rerun `just dhis2`. |
+| A claim is unsatisfied | The Tracker record does not contain the projected field or programme state. | Inspect the JSON artifact in `output/dhis2-openfn/`, then compare the field with `config/source-adapter/dhis2-health-sidecar.yaml` and `config/notary/dhis2-health-notary.yaml`. |
+| `child_age_band` looks coarse | The public child programme demo does not expose date of birth for the tracked entity. | Treat `5_to_17` as lab-derived programme context, not a clinical age calculation. |
+
+## Next
+
+- [OpenCRVS claims](../verify-opencrvs-claims/)
+- [Getting started with FHIR evidence](../getting-started-fhir-evidence/)
+- [Integration patterns](../../explanation/integration-patterns/)
diff --git a/docs/site/src/content/docs/tutorials/deploy-standalone-with-own-data.mdx b/docs/site/src/content/docs/tutorials/deploy-standalone-with-own-data.mdx
index 5533c563..7e8a9aa9 100644
--- a/docs/site/src/content/docs/tutorials/deploy-standalone-with-own-data.mdx
+++ b/docs/site/src/content/docs/tutorials/deploy-standalone-with-own-data.mdx
@@ -19,7 +19,7 @@ Use this guide when you operate your own registry data and want to run Registry
optionally, Registry Notary) as self-hosted services against that data, rather than the
`registryctl` sample project.
The sample-based tutorial,
-[Publish a spreadsheet as a secured registry API](../publish-spreadsheet-secured-registry-api/),
+[Run a protected registry API locally](../publish-spreadsheet-secured-registry-api/),
generates a project from the built-in `benefits` sample.
There is no `registryctl` initializer for your own dataset today: `registryctl init relay`
accepts only `--sample benefits`.
@@ -601,7 +601,7 @@ docker run --rm \
fingerprints, and broken source references. `explain-config --format json` prints the resolved
configuration and the env vars it requires.
For exercising the claim end to end (request shapes, outcomes, and credential issuance on top),
-work through [Verify a claim with Registry Notary](../verify-claim-registry-api/), which runs
+work through [Evaluate a claim with Registry Notary](../verify-claim-registry-api/), which runs
the same claim lifecycle against a `registryctl` sample project.
### Go deeper on Notary
diff --git a/docs/site/src/content/docs/tutorials/first-run-with-registry-lab.mdx b/docs/site/src/content/docs/tutorials/first-run-with-registry-lab.mdx
index a9548141..643cea40 100644
--- a/docs/site/src/content/docs/tutorials/first-run-with-registry-lab.mdx
+++ b/docs/site/src/content/docs/tutorials/first-run-with-registry-lab.mdx
@@ -33,8 +33,8 @@ stack locally, start with [See it live](../../start/see-it-live/) instead. This
local tour.
For normal local adopter projects, start with the `registryctl` tutorials instead:
-[publish a secured registry API](../publish-spreadsheet-secured-registry-api/) or
-[verify a claim with Registry Notary](../verify-claim-registry-api/). Clone `registry-lab` only when
+[run a protected registry API](../publish-spreadsheet-secured-registry-api/) or
+[evaluate a claim with Registry Notary](../verify-claim-registry-api/). Clone `registry-lab` only when
you want the full multi-service demo topology rather than a generated project.
+
+This tutorial uses synthetic data and local demo credentials.
+Do not use the generated local keys in production.
+
+## Start with a running source
+
+For a local reproducible source, complete the first tutorial and leave the Relay project running:
+
+```sh
+registryctl init relay my-first-api --sample benefits
+cd my-first-api
+registryctl start
+registryctl smoke
+set -a
+. secrets/local.env
+set +a
+```
+
+The `set -a` block exports `ROW_READER_RAW`, which the standalone Notary project uses once when it
+is generated.
+
+## Create the Notary project
+
+From the parent directory of `my-first-api`, create the standalone Notary project:
+
+```sh
+cd ..
+registryctl init notary my-standalone-notary \
+ --source-url http://registry-relay:8080 \
+ --source-network my-first-api_default \
+ --source-token-from-env ROW_READER_RAW
+cd my-standalone-notary
+```
+
+`--source-token-from-env ROW_READER_RAW` reads the token value from your current shell and writes it
+into the new project's `secrets/local.env`.
+
+`my-first-api_default` is the Compose network name Docker derives from the Relay project directory.
+If you named that directory differently, use that name with the `_default` suffix.
+
+## Start Notary
+
+Start the standalone project:
+
+```sh
+registryctl start
+```
+
+`registryctl` starts Notary, waits for readiness, and prints the local API URL:
+
+```text
+Notary API: http://127.0.0.1:4255
+API docs: http://127.0.0.1:4255/docs
+```
+
+Run the smoke check:
+
+```sh
+registryctl notary smoke
+```
+
+The smoke check lists claims, evaluates the generated starter claim, and writes a detailed report:
+
+```text
+output/notary-smoke-results.json
+```
+
+## Evaluate the starter claim
+
+Load this project's local keys:
+
+```sh
+set -a
+. secrets/local.env
+set +a
+```
+
+Evaluate the starter claim:
+
+```sh
+curl -sS -X POST \
+ -H "x-api-key: $REGISTRY_NOTARY_TUTORIAL_EVALUATOR_RAW" \
+ -H "Content-Type: application/json" \
+ -H "Accept: application/vnd.registry-notary.claim-result+json" \
+ -d '{
+ "target": { "type": "person", "id": "per-2001" },
+ "claims": ["benefits-person-exists"],
+ "disclosure": "predicate",
+ "purpose": "https://example.local/purpose/tutorial"
+ }' \
+ http://127.0.0.1:4255/v1/evaluations
+```
+
+Notary returns a claim result without returning the source row.
+
+## Use another Registry Data API source
+
+Change the source options at project creation time.
+The three `<...>` values must match the Registry Data API lookup route your source exposes:
+
+```sh
+registryctl init notary my-notary \
+ --source-url https://api.example.com \
+ --source-token-env EVIDENCE_SOURCE_API_TOKEN \
+ --source-dataset \
+ --source-entity \
+ --source-lookup-field
+```
+
+Notary will read:
+
+```text
+GET /v1/datasets/{dataset}/entities/{entity}/records?{lookup_field}={lookup_value}&fields=...&limit=2
+Authorization: Bearer
+Data-Purpose:
+```
+
+Successful responses must use:
+
+```json
+{ "data": [{ "field": "value" }] }
+```
+
+`--source-token-env` names the environment variable the Notary container reads at runtime.
+After project creation, edit `secrets/local.env` and set `EVIDENCE_SOURCE_API_TOKEN` to the source
+token.
+
+If the source API is another Compose service, pass `--source-network ` so Notary
+can join that network.
+
+## Adapt a custom HTTP source
+
+Use a source-adapter sidecar when your source API has its own routes, auth, response envelopes, or
+matching behavior.
+
+The adapter runs privately next to Notary and exposes the Registry Data API-shaped route that Notary
+expects.
+
+Start with the smallest engine that fits:
+
+| Engine | Use when |
+| --- | --- |
+| `http_json` | One governed HTTP JSON request can return the fields Notary needs. |
+| `http_flow` | You need 2 to 5 dependent GET requests, such as search first and fetch detail second. |
+| `fhir` | You are projecting bounded FHIR R4 resources into Notary-ready facts. |
+| `script_rhai` | You need POST, fallback behavior, or small custom branching that the built-in declarative engines cannot express. |
+
+Keep the source-specific logic in the adapter.
+Keep claim semantics, disclosure, caller policy, and credential issuance in Notary.
+
+## Clean up
+
+Stop the standalone Notary project from its directory:
+
+```sh
+registryctl stop
+```
+
+If you also started the sample Relay source, stop it from the `my-first-api` directory.
+
+## Next
+
+- [Evaluate a claim with Registry Notary](../verify-claim-registry-api/): learn the core local
+ path where Relay and Notary run in one generated project.
+- [Configure DHIS2 claim checks](../configure-dhis2-claim-checks/): see a named source-system
+ integration backed by a source-adapter `http_json` path.
+- [Getting started with FHIR evidence](../getting-started-fhir-evidence/): see the FHIR
+ source-adapter path.
+- [Deploy Relay and Notary standalone with your own data](../deploy-standalone-with-own-data/):
+ move from a local tutorial to an operator-shaped deployment.
+- [Source and claim modeling](../../products/registry-notary/source-claim-modeling-guide/):
+ configure source connections, source adapters, and claim boundaries.
+
+## Troubleshooting
+
+| Symptom | Cause | Resolution |
+| --- | --- | --- |
+| `registryctl init notary` cannot read `ROW_READER_RAW` | The Relay keys were not exported in this shell. | Run the `set -a` block from the Relay project directory, then retry. |
+| `registryctl start` fails with `network ... not found` | The source Compose network name is wrong or the source project is not running. | Start the source project and pass the correct `_default` network name. |
+| Claim evaluation returns a source auth error | Notary cannot authenticate to the source. | Confirm the source token in `secrets/local.env` and restart Notary. |
+| Claim evaluation returns `409 Evidence not available` | The target id is not available from the source, or the dataset, entity, or lookup field does not match the source contract. | Use a known target id or inspect the Registry Data API source contract. |
diff --git a/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx b/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx
index 4c0eaec5..15527029 100644
--- a/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx
+++ b/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx
@@ -1,13 +1,13 @@
---
-title: Verify a claim with Registry Notary
-description: Add Registry Notary to the local registry API project, evaluate one claim, compare a claim result with a Relay row read, and run Notary standalone against an API you operate.
+title: Evaluate a claim with Registry Notary
+description: Add Registry Notary to the local registry API project, evaluate one claim, and compare the claim result with a protected row read.
status: current
owner: registry-docs
source_repos:
- registry-registryctl
- registry-relay
- registry-notary
-last_reviewed: "2026-06-26"
+last_reviewed: "2026-06-28"
doc_type: tutorial
locale: en
standards_referenced: []
@@ -15,27 +15,26 @@ standards_referenced: []
import QuickstartMeta from '../../../components/QuickstartMeta.astro';
-Use this tutorial after you publish the spreadsheet registry API.
-You will add Registry Notary to the same local project, start Relay and Notary together, prove
-anonymous claim access is denied, and evaluate one claim backed by the registry API.
-A final section, [Run Notary standalone for an API you operate](#run-notary-standalone-for-an-api-you-operate),
-shows the same flow with Notary in its own project pointed at a source API you choose.
+Use this tutorial after you run the protected registry API locally.
+You will add Registry Notary to the same project, evaluate one claim, and see the difference
+between a source row read and a minimized claim result.
This tutorial uses synthetic data and local demo credentials.
Do not use the generated local keys in production.
-Estimated time: about 10 minutes after the first tutorial passes.
-## Before you start
+## Start from the local API
-Complete [Publish a spreadsheet as a secured registry API](../publish-spreadsheet-secured-registry-api/)
-first:
+Complete [Run a protected registry API locally](../publish-spreadsheet-secured-registry-api/)
+first.
+
+If you need to recreate the project:
```sh
registryctl init relay my-first-api --sample benefits
@@ -44,14 +43,9 @@ registryctl start
registryctl smoke
```
-The Relay smoke test must pass before you add Notary.
-
-If you already completed that tutorial and `my-first-api` is still on disk, do not rerun the
-`init` line: `registryctl init` refuses to overwrite a directory that is not empty.
-From `my-first-api`, run `registryctl start` and `registryctl smoke` to confirm the project is
-healthy, then continue.
+The Relay smoke check must pass before you add Notary.
-## Add claim verification
+## Add Notary
From the Relay project directory, add Notary:
@@ -59,47 +53,11 @@ From the Relay project directory, add Notary:
registryctl add notary --from local-relay
```
-The command updates the local project:
-
-```text
-my-first-api/
- registryctl.yaml
- compose.yaml
- README.md
- relay/
- config.yaml
- metadata.yaml
- notary/
- config.yaml
- data/
- benefits_casework.xlsx
- bruno/
- registry-api/
- secrets/
- local.env
- output/
- .gitignore
-```
+The command adds `notary/config.yaml`, updates `compose.yaml`, refreshes the Bruno collection, and
+adds Notary demo keys to `secrets/local.env`.
-`registryctl` keeps Relay and Notary credentials in `secrets/local.env`.
-The Relay and Notary runtime configs contain only fingerprint references and environment
-variable names.
-This local demo uses generated API keys only; it does not require OIDC, eSignet, or an
-assisted-access service.
-
-The generated Compose file also starts a local Redis replay store for Notary readiness.
-It is part of the local demo runtime and does not require manual configuration.
-
-Notary reads from Relay through the Compose network:
-
-```text
-http://registry-relay:8080
-```
-
-`registry-relay` is the service name `registryctl` writes into the generated `compose.yaml`;
-it is fixed by the generator, not derived from your project directory name.
-
-Local browser and curl examples use the host URL:
+Notary reads from Relay through the local Compose network.
+You still call Notary from your workstation at:
```text
http://127.0.0.1:4255
@@ -113,8 +71,7 @@ Start the project again:
registryctl start
```
-The command starts both services and, after the Docker Compose progress lines, waits for both
-health and readiness checks:
+`registryctl` waits for both services and prints the local URLs:
```text
Relay API: http://127.0.0.1:4242
@@ -123,26 +80,7 @@ Notary API: http://127.0.0.1:4255
API docs: http://127.0.0.1:4255/docs
```
-Check the project status:
-
-```sh
-registryctl status
-```
-
-The Relay and Notary services report healthy and ready when startup is complete.
-
-Validate both generated runtime configs with the product doctors:
-
-```sh
-registryctl doctor --format json
-```
-
-The JSON report uses `schema_version: registryctl.validation.report.v1`. It contains separate
-Relay and Notary product reports under `products[]`; each embedded product report uses
-`schema_version: registry.config.diagnostic_report.v1` and a `status` of `ok`, `warning`,
-`error`, or `not_run`.
-
-## Run the Notary smoke test
+## Run the Notary smoke check
Run the Notary smoke checks:
@@ -150,7 +88,7 @@ Run the Notary smoke checks:
registryctl notary smoke
```
-The smoke test passes with these checks:
+You should see the core checks pass:
```text
PASS notary healthz is public
@@ -162,15 +100,12 @@ PASS notary evaluator can list claims
PASS notary evaluator can verify benefits person exists
```
-`registryctl` writes detailed results to:
+`registryctl` writes the detailed report to:
```text
output/notary-smoke-results.json
```
-The smoke result must not contain raw API keys, the Relay source token, local env values, Relay
-source rows, or sensitive sample column values.
-
## Load local demo keys
Load the generated local keys into your shell:
@@ -181,26 +116,11 @@ set -a
set +a
```
-The Notary tutorial adds these local values:
-
-| Value | Environment variable | What it is for |
-| --- | --- | --- |
-| Notary evaluator key | `REGISTRY_NOTARY_TUTORIAL_EVALUATOR_RAW` | Lets you call Notary evaluation routes |
-| Notary evaluator fingerprint | `REGISTRY_NOTARY_TUTORIAL_EVALUATOR_HASH` | Lets Notary verify the local evaluator key |
-| Notary audit hash secret | `REGISTRY_NOTARY_AUDIT_HASH_SECRET` | Lets Notary hash audit subjects without logging raw values |
-| Relay source token for Notary | `EVIDENCE_SOURCE_REGISTRY_RELAY_TOKEN` | Lets Notary read the Relay source API |
-| Notary demo issuer JWK | `REGISTRY_NOTARY_ISSUER_JWK` | Local demo signing key used only to make Notary readiness pass |
-| Notary replay Redis URL | `REGISTRY_NOTARY_REPLAY_REDIS_URL` | Points Notary at the local demo Redis replay store |
-
The Notary evaluator key is for you calling Notary.
The Relay source token is for Notary calling Relay.
They are intentionally separate.
-The same `secrets/local.env` still carries the keys from the Relay tutorial, including the
-row-reader key `ROW_READER_RAW`, which this page uses later to compare a row read with a claim
-result.
-
-## Prove anonymous claim access is denied
+## Make one denied claim request
Call the claim list without a credential:
@@ -210,6 +130,8 @@ curl -i http://127.0.0.1:4255/v1/claims
Notary returns `401 Unauthorized`.
+## List the available claim
+
Call the same route with the Notary evaluator key:
```sh
@@ -218,10 +140,9 @@ curl -sS \
http://127.0.0.1:4255/v1/claims
```
-The response is a JSON list of the configured claim definitions.
-The starter claim appears in it with `"id": "benefits-person-exists"`.
+The starter claim appears with `"id": "benefits-person-exists"`.
-## Evaluate a claim from registry data
+## Evaluate one claim
Evaluate whether the synthetic person `per-2001` exists in the Relay-backed benefits dataset:
@@ -231,10 +152,7 @@ curl -sS -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/vnd.registry-notary.claim-result+json" \
-d '{
- "target": {
- "type": "person",
- "id": "per-2001"
- },
+ "target": { "type": "person", "id": "per-2001" },
"claims": ["benefits-person-exists"],
"disclosure": "predicate",
"purpose": "https://example.local/purpose/tutorial"
@@ -242,58 +160,28 @@ curl -sS -X POST \
http://127.0.0.1:4255/v1/evaluations
```
-Notary returns a successful claim result for `benefits-person-exists`.
-The response shows the claim outcome, not the Relay source row.
-It has this shape (identifiers, timestamps, and the target handle vary per run):
+Notary returns a claim result.
+The response contains the outcome, provenance, timestamps, and a pseudonymous target reference.
+It does not return the Relay source row.
+
+The important fields look like this:
```json
{
"results": [
{
"claim_id": "benefits-person-exists",
- "claim_version": "2026-06",
"disclosure": "predicate",
- "evaluation_id": "01KTS319VPVAJWKT8SZF4R8ZVN",
- "expires_at": null,
"format": "application/vnd.registry-notary.claim-result+json",
- "issued_at": "2026-06-10T15:39:53Z",
- "provenance": {
- "schema_version": "registry-notary-claim-provenance/v1",
- "generated_by": {
- "type": "claim_evaluation",
- "service_id": "registryctl.benefits.notary",
- "evaluation_id": "01KTS319VPVAJWKT8SZF4R8ZVN",
- "claim_id": "benefits-person-exists",
- "claim_version": "2026-06"
- },
- "used": {
- "source_count": 1,
- "source_versions": {},
- "source_runtimes": []
- },
- "derived_from": []
- },
"satisfied": true,
"subject_type": "person",
- "target_ref": {
- "handle": "rnref:v1:hmac-sha256:...",
- "type": "person"
- },
"value": true
}
]
}
```
-What happened:
-
-- You called Notary with the Notary evaluator key.
-- Notary checked that the key can evaluate the configured claim.
-- Notary used its internal Relay source token to query Relay.
-- Relay enforced its row-read scope and purpose-header rules.
-- Notary returned the configured claim result without exposing the spreadsheet row.
-
-## Evaluate a claim for an unknown person
+## Try an unknown person
Run the same evaluation for an id that is not in the sample workbook:
@@ -311,24 +199,10 @@ curl -sS -X POST \
http://127.0.0.1:4255/v1/evaluations
```
-Notary does not return `satisfied: false` for a missing person.
-It returns a `409` problem document, because there is no evidence to evaluate:
-
-```json
-{
- "code": "evidence.not_available",
- "detail": "the evidence is not available",
- "request_id": "01KTS31QC7184F5487VW0P7XM3",
- "status": 409,
- "title": "Evidence not available",
- "type": "https://docs.registry-notary.dev/problems/evidence/not_available"
-}
-```
-
-This distinction matters to integrators: a claim result answers the configured question about a
-known subject, while a missing subject is an evidence error, not a negative answer.
+Notary returns `409 Evidence not available`.
+A missing subject is an evidence error, not a negative claim result.
-## Compare a row read and a claim result
+## Compare row read and claim result
Relay still exposes the source-facing consultation API:
@@ -340,7 +214,7 @@ curl -sS -G \
http://127.0.0.1:4242/v1/datasets/benefits_casework/entities/person/records
```
-Notary exposes a claim API:
+Notary exposes the claim API:
```sh
curl -sS -X POST \
@@ -357,137 +231,34 @@ curl -sS -X POST \
```
Use Relay when a caller is allowed to consult configured records.
-Use Notary when a caller receives a narrow claim result.
-
-## Explore requests in Bruno
+Use Notary when a caller should receive a narrow claim result.
-`registryctl add notary --from local-relay` refreshes the generated Bruno collection with Notary
-requests.
-Bruno is optional.
-The tutorial and API work without it.
+## Inspect the generated claim
-Open the generated collection:
+Now that the evaluation works, inspect the generated Notary contract:
```sh
-registryctl bruno open
+sed -n '1,220p' notary/config.yaml
```
-If Bruno is installed, the collection opens with Relay and Notary folders.
-If Bruno is not installed, the command prints the collection path and an install link.
+The generated config defines the source connection to Relay, the evaluator key fingerprint, the
+starter claim, and the disclosure format.
+Raw evaluator keys and Relay source tokens stay in `secrets/local.env`.
-If the Bruno CLI is installed, you can run the collection:
-
-```sh
-registryctl bruno run
-```
-
-If `bru` is not installed, the command prints a fallback and exits without blocking Relay or
-Notary.
-
-## Open the Notary API reference
-
-Open the Notary API surface:
+Open the local Notary API reference:
```sh
registryctl notary open
```
-The command opens the docs page in your default browser.
-In an environment that cannot launch one, such as a remote shell, it prints the URLs instead:
+In an environment that cannot launch a browser, the command prints:
```text
Notary API docs: http://127.0.0.1:4255/docs
OpenAPI JSON: http://127.0.0.1:4255/openapi.json
```
-## Run Notary standalone for an API you operate
-
-Everything so far ran Notary inside the Relay project.
-When you already operate an API and only want claim verification, create a standalone Notary
-project instead and point it at your source API.
-The source must expose the Registry Data API-shaped record route Notary is configured to call.
-
-If you arrived here directly, for example from the routing table on the docs homepage, note that the
-reproducible demo below uses the local registry API from the sections above as its stand-in
-source, so it needs that project on disk and running (the [Before you start](#before-you-start)
-block creates it in about two minutes).
-If you already have a live source API of your own, skim the demo for the shape of the flow,
-then start from [For an API you operate](#for-an-api-you-operate) below and substitute your own
-URL, dataset, and token.
-
-To keep the steps reproducible without external credentials, this section reuses the local
-registry API as the stand-in source.
-Leave the Relay project running, and load its demo keys in your current shell (the `set -a`
-block in [Load local demo keys](#load-local-demo-keys)); the project-creation step reads
-`ROW_READER_RAW` from your shell.
-
-From the parent directory of `my-first-api`, create the Notary project:
-
-```sh
-cd ..
-registryctl init notary my-standalone-notary \
- --source-url http://registry-relay:8080 \
- --source-network my-first-api_default \
- --source-token-from-env ROW_READER_RAW
-cd my-standalone-notary
-```
-
-`--source-token-from-env ROW_READER_RAW` reads the token value from your current shell once, at
-project-creation time, and writes it into the new project's `secrets/local.env`.
-`my-first-api_default` is the Compose network name Docker derives from the Relay project
-directory name; if you named that directory differently, use that name with the `_default`
-suffix.
-The generated Notary config contains the source API URL, source dataset, entity, lookup field,
-and environment variable names.
-The raw keys and tokens live only in this project's own `secrets/local.env`.
-
-### For an API you operate
-
-Change the source options at project creation time.
-The three `<...>` values are placeholders for what your source API exposes; the demo values
-above (`benefits_casework`, `person`, `id`) are also the CLI defaults, so leaving these flags
-off wires Notary to a dataset that does not exist on your API:
-
-```sh
-registryctl init notary my-notary \
- --source-url https://api.example.com \
- --source-token-env EVIDENCE_SOURCE_API_TOKEN \
- --source-dataset \
- --source-entity \
- --source-lookup-field
-```
-
-`--source-token-env` differs from `--source-token-from-env` above: it does not read a value
-from your shell now, it names the environment variable the Notary container resolves at
-runtime.
-Supply the value afterwards by editing `secrets/local.env` and setting
-`EVIDENCE_SOURCE_API_TOKEN` to the source token.
-If the source API is another Compose service, pass `--source-network ` so
-Notary can join that network.
-
-### For a FHIR source-adapter sidecar
-
-If you already have a FHIR source-adapter sidecar running, start from the
-FHIR profile instead of the Registry Data API defaults:
-
-```sh
-registryctl init notary my-fhir-notary --source-kind fhir-sidecar
-```
-
-This generates a starter `patient-record-exists` claim, points Notary at the
-sidecar URL `http://host.docker.internal:4360`, and uses
-`FHIR_SIDECAR_TOKEN` in `secrets/local.env`. Change `--source-url`,
-`--source-token-env`, `--source-dataset`, `--source-entity`, or
-`--source-lookup-field` if your sidecar uses different local names.
-
-From the standalone project directory, the rest of the flow is the same as the co-located
-sections earlier on this page: `registryctl start`, `registryctl notary smoke`, load this
-project's keys with the `set -a` block, and evaluate the claim against
-`http://127.0.0.1:4255/v1/evaluations`.
-The standalone project has its own `secrets/local.env`; load it from this directory, not the
-Relay project's.
-
-## Clean up
+## Stop the stack
When you are done:
@@ -495,22 +266,17 @@ When you are done:
registryctl stop
```
-This stops both local services.
+This stops the local services.
It does not delete your workbook, generated configs, local keys, or smoke results.
-If you created the standalone Notary project, stop it from its own directory, then stop the
-source registry API from `my-first-api`.
-
## Next
-- [Source and claim modeling](../../products/registry-notary/source-claim-modeling-guide/):
- configure source connections and claim boundaries.
-- [Registry Relay client integration](../../products/registry-relay/client-integration/): call
- Relay from an application.
-- [See it live](../../start/see-it-live/): explore the hosted demo to see Relay, Notary, and
- cross-authority flows in action.
-- [First run with Registry Lab](../first-run-with-registry-lab/): run the full multi-service
- topology locally, with three Relays, cross-authority Notaries, and an identity provider.
+- Define policy for this claim: purpose binding, context constraints, denial reasons, and audit
+ inspection are the next local rung.
+- [Connect Notary to a Registry Data API source](../run-notary-standalone-for-api/): run Notary in
+ a separate project against a Registry Data API-shaped source.
+- [See it live](../../start/see-it-live/): explore the hosted demo to see credential issuance and
+ cross-authority flows.
## Troubleshooting
@@ -519,11 +285,6 @@ source registry API from `my-first-api`.
| `registryctl add notary --from local-relay` cannot find a Relay project | The current directory does not contain a generated `registryctl.yaml` with a Relay section. | Run the command from the Relay tutorial project directory. |
| `registryctl add notary --from local-relay` cannot find a source token | `secrets/local.env` is missing or does not contain the Relay row-reader key. | Recreate the Relay project or restore the generated local env file. |
| `registryctl start` starts Relay but Notary is not ready | Notary config, source token, or Compose service wiring is invalid. | Run `registryctl status`, then `registryctl logs` and check the Notary service errors. |
-| The Notary container log shows `failed to parse config YAML ... unknown field` | The locally cached container image does not match the digest-pinned image in the generated `compose.yaml`. | Run `docker compose pull` in the project directory to refresh the pinned images, then `registryctl start` again. |
-| Notary `/ready` is degraded while `/healthz` is healthy | The local replay store or demo issuer key is not available to Notary. | Run `registryctl stop`, then `registryctl start`; if it persists, inspect `registryctl logs`. |
| `registryctl notary smoke` returns `401` for authorized calls | The Notary evaluator key was not loaded or does not match the generated Notary fingerprint. | Run `. secrets/local.env`, then retry. |
| Claim evaluation returns a source auth error | Notary cannot authenticate to Relay with `EVIDENCE_SOURCE_REGISTRY_RELAY_TOKEN`. | Confirm `secrets/local.env` has the source token and Relay is running. |
| Claim evaluation returns `409 Evidence not available` | The target id is not in the sample workbook or the Relay entity lookup changed. | Use `per-2001` for the tutorial target, or inspect the Relay `person` entity. |
-| `registryctl init notary` fails with `failed to read source token from $ROW_READER_RAW: environment variable not found` | The Relay keys were loaded in a different shell session, or not at all. | Run the `set -a; . secrets/local.env; set +a` block from the Relay project directory in this shell, then rerun the command. |
-| Standalone `registryctl start` fails and the Notary log shows `network ... not found` | The Relay project is not running, or the `--source-network` name does not match the Relay project directory. | Start the Relay project, and pass the Compose network named after that directory with the `_default` suffix. |
-| Standalone `registryctl notary smoke` returns `401` for authorized calls | The shell still holds the Relay project's keys, not the standalone Notary keys. | Run `set -a; . secrets/local.env; set +a` from the standalone project directory, then retry. |
diff --git a/products/notary/docs/README.md b/products/notary/docs/README.md
index 103cd253..afb65d3d 100644
--- a/products/notary/docs/README.md
+++ b/products/notary/docs/README.md
@@ -10,7 +10,9 @@ format, or issues a short-lived SD-JWT VC credential.
Pick your path below. New to Registry Notary? Start with the hosted walkthrough or a runnable local tutorial. If you are configuring or operating Notary, start with the [architecture overview](architecture-overview.md).
- [See it live](https://docs.registrystack.org/start/see-it-live/): watch Notary issue a privacy-preserving credential against a hosted lab, with zero install.
-- [Verify a claim with Registry Notary](https://docs.registrystack.org/tutorials/verify-claim-registry-api/): add Notary to a local registry API project with `registryctl`. Its final section, [Run Notary standalone for an API you operate](https://docs.registrystack.org/tutorials/verify-claim-registry-api/#run-notary-standalone-for-an-api-you-operate), covers creating a standalone Notary project for a source API you operate.
+- [Verify a claim with Registry Notary](https://docs.registrystack.org/tutorials/verify-claim-registry-api/): add Notary to a local registry API project with `registryctl`.
+- [Connect Notary to a Registry Data API source](https://docs.registrystack.org/tutorials/run-notary-standalone-for-api/): run Notary in a separate project against a Registry Data API-shaped source.
+- [Configure DHIS2 claim checks](https://docs.registrystack.org/tutorials/configure-dhis2-claim-checks/): use the built-in `http_json` source adapter to evaluate DHIS2 Tracker claims and issue an SD-JWT VC from the result.
- [Architecture overview](architecture-overview.md): what Registry Notary is, the request lifecycle, and how the layers relate.
- [Capability matrix](https://github.com/jeremi/registry-notary/blob/f182385a5065873aac030c41d9fe020704afc4e2/docs/notary-capability-matrix.md): which flows Notary supports today, by persona and system.
@@ -21,7 +23,6 @@ Pick your path below. New to Registry Notary? Start with the hosted walkthrough
For application and wallet developers calling the API or the SDKs.
- [Client SDK guide](client-sdk-guide.md): evaluate claims and issue credentials from Rust, Python, and Node.js.
-- [Call Notary from OpenFn](openfn-notary-caller-guide.md): use the Registry Stack OpenFn Notary adaptor to branch a workflow on a minimized claim result or certified value claim.
- [API reference](https://github.com/jeremi/registry-notary/blob/f182385a5065873aac030c41d9fe020704afc4e2/docs/api-reference.md): the route-to-client-method matrix and the stable problem-code registry.
- [Wallet interop with OID4VCI](oid4vci-wallet-interop.md): the OpenID4VCI wallet facade contract and compatibility checklist.
- [SD-JWT VC conformance](sd-jwt-vc-conformance-profile.md): the supported credential wire contract and the explicit non-support list.
@@ -38,6 +39,7 @@ For operators deploying, configuring, and running a Registry Notary.
- [Configuration reference](operator-config-reference.md): the config blocks for auth, evidence, sources, replay, status, self-attestation, OID4VCI, and federation.
- [Model sources and claims](source-claim-modeling-guide.md): design source connectors, source adapter sidecars, claim boundaries, disclosure, and batch reads.
+- [DHIS2 source adapter tutorial](https://docs.registrystack.org/tutorials/configure-dhis2-claim-checks/): configure a sidecar that calls DHIS2 Tracker through the built-in `http_json` engine.
- [FHIR source adapter](fhir-source-adapter-guide.md): project bounded FHIR R4 graphs into Notary-ready source facts without exposing raw FHIR Bundles.
- [Script (Rhai) source adapter](script-rhai-source-adapter-guide.md): run a sandboxed, orchestration-only Rhai script for sources that need a little branching across a few governed reads.
- [Signing key providers](signing-key-provider.md): SD-JWT VC signing-key configuration, rotation, and PKCS#11 setup.
diff --git a/products/notary/docs/source-claim-modeling-guide.md b/products/notary/docs/source-claim-modeling-guide.md
index 32d0238e..5ed59d52 100644
--- a/products/notary/docs/source-claim-modeling-guide.md
+++ b/products/notary/docs/source-claim-modeling-guide.md
@@ -26,7 +26,7 @@ Keep source connectors narrow and keep claim semantics in Notary config.
| --- | --- | --- |
| DCI | The upstream speaks a DCI-style search envelope | `connector: dci` |
| Registry Data API | The upstream exposes `/v1/datasets/{dataset}/entities/{entity}/records` lookups | `connector: registry_data_api` |
-| Source adapter sidecar | A private sidecar must normalize a target system outside Notary, using built-in `http_json`, `http_flow`, or `fhir` source engines | `connector: source_adapter_sidecar` |
+| Source adapter sidecar | A private sidecar must normalize a target system outside Notary, using built-in `http_json`, `http_flow`, `fhir`, or `script_rhai` source engines | `connector: source_adapter_sidecar` |
Prefer the simplest direct source. Add a sidecar when the target system needs
private credentials, governed request shaping, or output normalization outside
@@ -118,8 +118,10 @@ The source adapter sidecar is a separate private process that normalizes a targe
system into Notary's source-read contracts. The Notary connector value remains
`source_adapter_sidecar`. Inside the sidecar, a source can use the built-in
`http_json` engine for straightforward HTTP JSON APIs, the built-in `http_flow`
-engine for short dependent GET-only HTTP JSON reads, or the built-in `fhir`
-engine. Use the first-class connector for new configs:
+engine for short dependent GET-only HTTP JSON reads, the built-in `fhir`
+engine, or the sandboxed `script_rhai` engine for small branching cases that
+do not fit the declarative engines. Use the first-class connector for new
+configs:
```yaml
evidence:
@@ -188,7 +190,9 @@ Boundary rules:
- The sidecar must be reachable only over localhost or a private pod network
from Notary. Do not expose it publicly or place it behind an internet-facing
ingress.
-- Pin worker runtime and adaptor versions for source-adapter sources.
+- Pin the sidecar image and governed runtime config for source-adapter sources.
+ If you use a compatibility path that carries separate runtime or adaptor
+ versions, pin those too.
- Store sidecar target credentials in sidecar env, not in Notary config.
- Return no more than two records for a lookup.
- Return only normalized fields needed by Notary.