diff --git a/crates/registry-relay/docs/configuration.md b/crates/registry-relay/docs/configuration.md
index 7757bc58..9e14eb91 100644
--- a/crates/registry-relay/docs/configuration.md
+++ b/crates/registry-relay/docs/configuration.md
@@ -270,7 +270,7 @@ Keep standard-facing meaning in the manifest: catalog, datasets, entities,
fields, constraints, vocabularies, codelists, profiles, conformance claims, and
descriptive ODRL policy metadata.
-See [metadata.md](https://github.com/jeremi/registry-relay/blob/3938fdf3930e134d6dca97360baf64ac3a16bed2/docs/metadata.md) for the manifest schema, static publication, and
+See [metadata.md](metadata.md) for the manifest schema, static publication, and
the `metadata.manifest.*` / `runtime.binding.*` startup error codes.
ODRL policy belongs in the portable metadata manifest, not in runtime dataset
@@ -499,7 +499,7 @@ Token verification failures map to specific `auth.*` codes so audit pipelines ca
| `auth.invalid_credential` | 401 | JWT decode failure not covered by a more specific variant |
| `auth.jwks_unavailable` | 503 | JWKS fetch failed; Registry Relay cannot verify any token |
-For a worked example of running Registry Relay against a local OIDC provider (using the project's dev Zitadel stack), see [development.md](https://github.com/jeremi/registry-relay/blob/3938fdf3930e134d6dca97360baf64ac3a16bed2/docs/development.md).
+For a worked example of running Registry Relay against a local OIDC provider (using the project's dev Zitadel stack), see [development.md](development.md).
## Audit
@@ -977,7 +977,7 @@ publicschema:
| `schema_url` | `https://publicschema.org/schemas/{target}.schema.json` | `credentialSchema.id` in the issued VC |
| `credential_type` | `{target}` | `type[1]` value in the issued VC |
-See [provenance.md](https://github.com/jeremi/registry-relay/blob/3938fdf3930e134d6dca97360baf64ac3a16bed2/docs/provenance.md) for CEL context variables, issuance behavior, audit records, and the build and test commands for this feature.
+See [provenance.md](provenance.md) for CEL context variables, issuance behavior, audit records, and the build and test commands for this feature.
## Aggregates
@@ -1058,7 +1058,7 @@ The `provenance` block is optional. When absent or `enabled: false`, the gateway
The key is named `provenance` for compatibility; it governs the response-credential issuer (DID, signing key, claim validity, and accepted media types). These credentials are W3C VCDM 2.0 VC-JWT with a Registry Relay JSON-LD context; they are not W3C PROV-O.
-See [provenance.md](https://github.com/jeremi/registry-relay/blob/3938fdf3930e134d6dca97360baf64ac3a16bed2/docs/provenance.md) for the full signer, DID, schema, context, and rotation contract.
+See [provenance.md](provenance.md) for the full signer, DID, schema, context, and rotation contract.
## Production checklist
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/AGENTS.md b/docs/site/AGENTS.md
index c56809d5..3d303d3e 100644
--- a/docs/site/AGENTS.md
+++ b/docs/site/AGENTS.md
@@ -18,7 +18,9 @@ This repo is an Astro and Starlight documentation site.
Read `docs/style-guide.md` before drafting or editing any page. It covers voice,
structure, frontmatter, page types, the banned-word list, claim levels for
standards, and the GitLab rules we adopt, adapt, or skip. The visual design
-language is recorded separately in `../design-registry-docs.md`.
+language is recorded separately in `design-registry-docs.md`, maintained
+alongside the repository, not published in it; the binding visual rules for
+diagrams are summarized in the style guide's "Images and diagrams" section.
Every factual claim about a source repo must be anchored in code, tests,
fixtures, OpenAPI, or an upstream standard. When evidence is missing, mark the
diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs
index fa60de1d..091062b7 100644
--- a/docs/site/astro.config.mjs
+++ b/docs/site/astro.config.mjs
@@ -41,6 +41,16 @@ const base = process.env.DOCS_BASE || undefined;
const basePath = base?.replace(/\/$/, '');
const isArchivedBuild = Boolean(basePath);
const productSidebar = loadProductSidebar();
+
+// Lift a generated per-product group to the top level of the sidebar.
+// Fails the build loudly if the generator's labels change, so the nav can
+// never silently lose a product section.
+/** @param {string} label */
+function generatedProduct(label) {
+ const group = productSidebar.find((/** @type {{ label: string }} */ entry) => entry.label === label);
+ if (!group) throw new Error(`generated sidebar group "${label}" not found`);
+ return group;
+}
const disabledSitemap = {
name: '@astrojs/sitemap',
hooks: {},
@@ -64,11 +74,14 @@ export default defineConfig({
// to their new homes so old links and search results keep resolving.
redirects: {
'/start/': internalRedirect('/'),
+ '/start/see-it-live/': internalRedirect('/start/quickstart/'),
+ '/explanation/trust-posture-and-security-guarantees/': internalRedirect('/security/'),
+ '/reference/security-self-assessment/': internalRedirect('/security/self-assessment/'),
+ '/reference/openssf-evidence/': internalRedirect('/security/openssf-evidence/'),
// 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/`,
@@ -109,7 +122,7 @@ export default defineConfig({
'/products/registry-notary/opencrvs-dci-onboarding/': 'https://docs.registrystack.org/products/registry-notary/opencrvs-onboarding/',
// registry-manifest, registry-atlas, registry-platform, registry-lab projects/*
// redirects removed: targets are deferred from the MVP docs cut.
- '/projects/registry-lab/demo-flow/': internalRedirect('/start/see-it-live/'),
+ '/projects/registry-lab/demo-flow/': internalRedirect('/start/quickstart/'),
// The API reference moved from static Redoc HTML to native, theme-aware,
// searchable pages. Keep the old shareable links working.
'/api/registry-relay.html': internalRedirect('/reference/apis/relay/'),
@@ -193,19 +206,19 @@ export default defineConfig({
},
],
// Diataxis IA: Get started, Tutorials, Products, Explanation, Reference.
- // The per-product groups inside Products are generated from
- // src/data/repo-docs.yaml by scripts/generate-sidebar.mjs (the
- // productSidebar array), so the menu follows each product's
- // doc_type/nav_order and never drifts from the manifest. Within a
- // product, pages are sub-grouped by Diataxis type once the product grows
- // past a threshold; smaller products stay flat. Product labels drop the
- // shared "Registry" prefix (Relay, Notary, ...) since the site title and
- // the Products group already supply that context.
+ // The per-product groups are generated from src/data/repo-docs.yaml by
+ // scripts/generate-sidebar.mjs (the productSidebar array), so each
+ // product menu follows its doc_type/nav_order and never drifts from the
+ // manifest. Within a product, pages are sub-grouped by Diataxis type
+ // once the product grows past a threshold; smaller products stay flat.
+ // generatedProduct() lifts each group into its own top-level product
+ // section; hand-authored operator tutorials append after the generated
+ // items.
//
- // "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.
+ // "Get started" is the first-run journey in reading order: the hosted
+ // quickstart and credential tour, then the two local first-run
+ // tutorials, then the evaluation and lab pages. Named source-system
+ // paths live under Integrations.
sidebar: [
{
label: 'Get started',
@@ -213,38 +226,75 @@ export default defineConfig({
// Short nav labels to avoid wrapping in the narrow sidebar; page
// titles keep the full wording.
{ label: 'Overview', link: '/' },
- { label: 'See it live', slug: 'start/see-it-live' },
+ { label: 'Quickstart', slug: 'start/quickstart' },
+ { label: 'Credential tour', slug: 'start/credential-tour' },
+ { label: 'Your first Relay', slug: 'tutorials/publish-spreadsheet-secured-registry-api' },
+ { label: 'Your first Notary', slug: 'tutorials/verify-claim-registry-api' },
{ label: 'When to use', slug: 'start/when-to-use' },
+ { label: 'Run the lab', slug: 'tutorials/first-run-with-registry-lab' },
],
},
{
- label: 'Tutorials',
+ label: 'Registry Relay',
+ collapsed: true,
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' },
+ ...generatedProduct('Relay').items,
{ label: 'Deploy with own data', slug: 'tutorials/deploy-standalone-with-own-data' },
- { label: 'Run the lab', slug: 'tutorials/first-run-with-registry-lab' },
- { label: 'FHIR evidence', slug: 'tutorials/getting-started-fhir-evidence' },
],
},
{
- label: 'Products',
- items: productSidebar,
+ label: 'Registry Notary',
+ collapsed: true,
+ items: [
+ ...generatedProduct('Notary').items,
+ { label: 'Notary for a Registry Data API', slug: 'tutorials/run-notary-standalone-for-api' },
+ ],
+ },
+ {
+ label: 'Registry Manifest',
+ collapsed: true,
+ items: generatedProduct('Manifest').items,
+ },
+ {
+ label: 'Integrations',
+ 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' },
+ ],
},
{
- label: 'Explanation',
+ label: 'Concepts',
collapsed: true,
items: [
{ label: 'Architecture', slug: 'explanation/architecture' },
+ { label: 'Records stay home', slug: 'explanation/records-stay-home' },
{ label: 'Boundaries and map', slug: 'map/boundaries-and-map' },
{ label: 'Consultation flow', slug: 'explanation/consultation-flow' },
{ label: 'Evidence issuance', slug: 'explanation/evidence-issuance' },
+ { label: 'Disclosure modes', slug: 'explanation/disclosure-modes-and-computed-answers' },
+ { label: 'Data minimization', slug: 'explanation/data-minimization-and-purpose-limitation' },
{ label: 'Trusted context', slug: 'explanation/trusted-context-constraints' },
{ label: 'Integration patterns', slug: 'explanation/integration-patterns' },
{ label: 'DPI safeguards', slug: 'explanation/dpi-safeguards-alignment' },
],
},
+ {
+ // The reviewer/auditor-facing rail: the enforced model, the threat
+ // model, the canonical limits hub, and the public evidence a
+ // security reviewer can check.
+ label: 'Security',
+ collapsed: true,
+ items: [
+ { label: 'Overview', slug: 'security' },
+ { label: 'Threat model', slug: 'explanation/threat-model' },
+ { label: 'Known limitations', slug: 'explanation/known-limitations' },
+ { label: 'Harden a deployment', slug: 'security/hardening-checklist' },
+ { label: 'Report a vulnerability', slug: 'security/report-a-vulnerability' },
+ { label: 'Self-assessment', slug: 'security/self-assessment' },
+ { label: 'OpenSSF evidence', slug: 'security/openssf-evidence' },
+ ],
+ },
{
label: 'Reference',
collapsed: true,
@@ -265,8 +315,6 @@ export default defineConfig({
{ label: 'Environment variables', slug: 'reference/environment-variables' },
{ label: 'Contracts', slug: 'reference/contracts' },
{ label: 'Standards', slug: 'reference/standards' },
- { label: 'OpenSSF evidence', slug: 'reference/openssf-evidence' },
- { label: 'Security self-assessment', slug: 'reference/security-self-assessment' },
{ label: 'ITB and SEMIC evidence', slug: 'reference/itb-semic-evidence' },
{ label: 'Glossary', slug: 'reference/glossary' },
],
diff --git a/docs/site/docs/style-guide.md b/docs/site/docs/style-guide.md
index e770bac7..d1a952e3 100644
--- a/docs/site/docs/style-guide.md
+++ b/docs/site/docs/style-guide.md
@@ -128,7 +128,7 @@ Preferred terms.
- Do not capitalize the target page's title inside link text unless it is a proper noun.
- Cap one paragraph at three links. Cap one page at fifteen. If you need more, the page should be a list.
- Link to upstream standards bodies first, then to mirrors or summaries.
-- Link to a commit SHA, not a branch, when linking to code that may move.
+- Pin links to code to a release tag (`v0.8.3`) or a commit SHA, never a branch, when the claim depends on the code state.
## Tables
@@ -200,7 +200,7 @@ This applies to every page that touches a standard or a contract.
- No animated GIFs or videos in v0.
- No screenshots as the only source of instructions.
- No real user data, real production hostnames, or real tokens, even in `example` blocks.
-- No relative links into source repos. Use full URLs with a commit SHA.
+- No relative links into source repos. Use full URLs pinned to a release tag or commit SHA.
- No nested admonitions. No admonition immediately under H1.
- No anchor links into other pages; link to the page and use the sidebar's on-this-page index.
- No `should` as a promise. Either it does or it does not.
diff --git a/docs/site/package.json b/docs/site/package.json
index 44146a5d..85aade7c 100644
--- a/docs/site/package.json
+++ b/docs/site/package.json
@@ -19,7 +19,7 @@
"check:content": "node scripts/check-doc-frontmatter.mjs",
"check:svg": "node scripts/check-svg-a11y.mjs",
"check:markdown": "markdownlint-cli2",
- "check:style": "node scripts/run-vale.mjs --glob=!**/products/** src/content/docs README.md",
+ "check:style": "node scripts/run-vale.mjs src/content/docs README.md",
"check:style:fixtures": "node scripts/check-vale-fixtures.mjs",
"check:openapi": "redocly lint registry-relay registry-notary",
"check:config-vocabulary": "scripts/check-stale-config-vocabulary.sh",
diff --git a/docs/site/scripts/check-stale-config-vocabulary.sh b/docs/site/scripts/check-stale-config-vocabulary.sh
index d0a5639d..9f3726cf 100755
--- a/docs/site/scripts/check-stale-config-vocabulary.sh
+++ b/docs/site/scripts/check-stale-config-vocabulary.sh
@@ -12,7 +12,7 @@ PATHS=(
status=0
if command -v rg >/dev/null 2>&1; then
- rg -n --glob '!scripts/check-stale-config-vocabulary.sh' "$PATTERN" "${PATHS[@]}" || status=$?
+ rg -n --no-ignore --glob '!scripts/check-stale-config-vocabulary.sh' "$PATTERN" "${PATHS[@]}" || status=$?
else
grep -RInE --exclude='check-stale-config-vocabulary.sh' "$PATTERN" "${PATHS[@]}" || status=$?
fi
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/scripts/generate-data.mjs b/docs/site/scripts/generate-data.mjs
index 3370cf9b..322d5c78 100644
--- a/docs/site/scripts/generate-data.mjs
+++ b/docs/site/scripts/generate-data.mjs
@@ -42,6 +42,9 @@ async function loadYaml(name) {
throw new Error(`${name}.yaml entry ${index + 1} is missing ${key}`);
}
}
+ if (name === 'standards' && 'evidence_gap' in item && typeof item.evidence_gap !== 'boolean') {
+ throw new Error(`standards.yaml entry ${index + 1} evidence_gap must be true or false`);
+ }
}
return parsed;
}
diff --git a/docs/site/src/components/HomeLanding.astro b/docs/site/src/components/HomeLanding.astro
deleted file mode 100644
index 5d19dbac..00000000
--- a/docs/site/src/components/HomeLanding.astro
+++ /dev/null
@@ -1,156 +0,0 @@
----
-// The homepage section index. Renders the "Choose by question" router as a
-// stack of register cards plus a primary-action row, reusing the shared card
-// classes (.card-header, .card-meta, .card-footer) so it inherits the
-// civic-print look and dark mode automatically. Editorial routing data lives
-// here (like DocsList.astro's descriptions map), so index.mdx stays prose.
-const base = import.meta.env.BASE_URL === '/' ? '' : import.meta.env.BASE_URL.replace(/\/$/, '');
-
-function withBase(path: string) {
- return path.startsWith('/') ? `${base}${path}` : path;
-}
-
-interface Cta {
- label: string;
- href: string;
-}
-
-interface RouteCard {
- id: string;
- question: string;
- start: Cta;
- then: Cta[];
-}
-
-const primary: Cta = { label: 'See it live', href: '/start/see-it-live/' };
-const secondary: Cta[] = [
- { label: 'When to use', href: '/start/when-to-use/' },
- { label: 'API reference', href: '/reference/apis/' },
-];
-
-const cards: RouteCard[] = [
- {
- id: 'see-it-work',
- question: 'I want to see it work right now.',
- start: { label: 'See it live', href: '/start/see-it-live/' },
- then: [{ label: 'When to use Registry Stack', href: '/start/when-to-use/' }],
- },
- {
- id: 'what-and-when',
- question: 'What is Registry Stack, and when should I use it?',
- start: { label: 'When to use Registry Stack', href: '/start/when-to-use/' },
- then: [{ label: 'Architecture overview', href: '/explanation/architecture/' }],
- },
- {
- id: 'why-and-who',
- question: 'Why does this matter, and who benefits?',
- start: { label: 'Why (registrystack.org)', href: 'https://registrystack.org/why/' },
- then: [{ label: 'Use cases (registrystack.org)', href: 'https://registrystack.org/use-cases/' }],
- },
- {
- id: 'try-locally',
- question: 'How do I try it on my own machine?',
- start: {
- label: 'Publish a spreadsheet as a secured registry API',
- href: '/tutorials/publish-spreadsheet-secured-registry-api/',
- },
- then: [{ label: 'Verify a claim with Registry Notary', href: '/tutorials/verify-claim-registry-api/' }],
- },
- {
- id: 'full-demo',
- question: 'How do I run the full multi-service demo locally?',
- start: { label: 'First run with Registry Lab', href: '/tutorials/first-run-with-registry-lab/' },
- then: [{ label: 'Architecture overview', href: '/explanation/architecture/' }],
- },
- {
- id: 'fhir',
- question: 'How do I try claim checks from 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: 'own-data',
- question: 'How do I deploy Relay against my own data, not a sample?',
- start: {
- label: 'Deploy Relay and Notary standalone with your own data',
- href: '/tutorials/deploy-standalone-with-own-data/',
- },
- then: [{ label: 'Relay configuration', href: '/products/registry-relay/configuration/' }],
- },
- {
- id: 'existing-api',
- question: 'I already have an API. How do I verify 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',
- },
- then: [{ label: 'Registry Notary', href: '/products/registry-notary/' }],
- },
- {
- id: 'opencrvs',
- question: 'How do I verify claims from OpenCRVS?',
- start: { label: 'Verify OpenCRVS claims with registryctl', href: '/tutorials/verify-opencrvs-claims/' },
- then: [
- {
- label: 'OpenCRVS onboarding model',
- href: 'https://docs.registrystack.org/products/registry-notary/opencrvs-onboarding/',
- },
- ],
- },
- {
- id: 'make-usable',
- question: 'How do existing files, extracts, databases, or legacy systems become usable?',
- start: { label: 'Protected Registry APIs with Registry Relay', href: '/products/registry-relay/' },
- then: [{ label: 'Relay API reference', href: '/reference/apis/registry-relay/' }],
- },
- {
- id: 'verify-without-record',
- question: 'How can a service verify a status or claim without receiving the full record?',
- start: { label: 'Evidence Gateway with Registry Notary', href: '/products/registry-notary/' },
- then: [{ label: 'Evidence issuance', href: '/explanation/evidence-issuance/' }],
- },
- {
- id: 'how-it-fits',
- question: 'How do the pieces fit together?',
- start: { label: 'Architecture overview', href: '/explanation/architecture/' },
- then: [
- { label: 'Registry Relay', href: '/products/registry-relay/' },
- { label: 'Registry Notary', href: '/products/registry-notary/' },
- ],
- },
-];
----
-
-
-
-Choose by question
-
-
- {cards.map((card) => (
-
-
-
- - Start with
- - {card.start.label}
- - Then read
- -
- {card.then.map((link, index) => (
- <>
- {link.label}
- {index < card.then.length - 1 ? · : null}
- >
- ))}
-
-
-
- ))}
-
diff --git a/docs/site/src/components/StandardsTable.astro b/docs/site/src/components/StandardsTable.astro
index af15f3a2..276bfd6f 100644
--- a/docs/site/src/components/StandardsTable.astro
+++ b/docs/site/src/components/StandardsTable.astro
@@ -1,9 +1,12 @@
---
import standards from '../data/generated/standards.json';
+
+// evidence_gap is an optional flag; absent from the JSON unless a row sets it.
+const rows = standards as ((typeof standards)[number] & { evidence_gap?: boolean })[];
---
- {standards.map((standard) => (
+ {rows.map((standard) => (