diff --git a/crates/registry-relay-v2/src/compiler.rs b/crates/registry-relay-v2/src/compiler.rs index 24d1d887f..9e0ae6fdb 100644 --- a/crates/registry-relay-v2/src/compiler.rs +++ b/crates/registry-relay-v2/src/compiler.rs @@ -1026,7 +1026,11 @@ impl<'a> Compiler<'a> { "effective privacy and institutional classifications must be non-empty", ); } - self.validate_review_status(&classification, &format!("{location}.classification")); + self.validate_review_status( + &classification, + &format!("{location}.classification"), + None, + ); let semantic_iri = match expand_local_term( &self.contract.semantics.local_vocabulary, &property.semantic_term, @@ -2270,7 +2274,7 @@ impl<'a> Compiler<'a> { classification: &EffectiveClassification, location: &str, ) { - self.validate_review_status(classification, &format!("{location}.classification")); + self.validate_review_status(classification, &format!("{location}.classification"), None); if classification.privacy != "non-personal" { self.error( "statistics.personal_output_forbidden", @@ -2348,17 +2352,20 @@ impl<'a> Compiler<'a> { if column_uses.len() > 1 && !source_override.is_some_and(explicit_reviewed_classification) { - let location = format!("{root}.sourceColumnClassifications.{column}"); + let location = column_accounting_location(root, &column, source_override, None); + let message = format!( + "a multiply-bound statistical source column requires its own complete reviewed classification for source column '{column}'" + ); match self.profile { CompileProfile::Authoring => self.warning( "classification.column_explicit_review_required", &location, - "a multiply-bound statistical source column requires its own complete reviewed classification", + &message, ), CompileProfile::Production => self.error( "classification.column_explicit_review_required", &location, - "a multiply-bound statistical source column requires its own complete reviewed classification", + &message, ), } } @@ -2371,8 +2378,10 @@ impl<'a> Compiler<'a> { else { self.error( "classification.column_incomplete", - &format!("{root}.sourceColumnClassifications.{column}"), - "an accounted statistical source column has no complete classification", + &column_accounting_location(root, &column, source_override, None), + &format!( + "an accounted statistical source column has no complete classification for source column '{column}'" + ), ); continue; }; @@ -2380,21 +2389,26 @@ impl<'a> Compiler<'a> { if classification.privacy != component.privacy { self.error( "classification.column_privacy_mismatch", - &format!("{root}.sourceColumnClassifications.{column}.privacy"), - "a statistical component and its source column require exact privacy agreement", + &column_accounting_location(root, &column, source_override, Some("privacy")), + &format!( + "a statistical component and its source column require exact privacy agreement for source column '{column}'" + ), ); } if classification.handling < component.handling { self.error( "classification.column_weaker_than_property", - &format!("{root}.sourceColumnClassifications.{column}.handling"), - "a source-column classification cannot weaken component handling", + &column_accounting_location(root, &column, source_override, Some("handling")), + &format!( + "a source-column classification cannot weaken component handling for source column '{column}'" + ), ); } } self.validate_review_status( &classification, - &format!("{root}.sourceColumnClassifications.{column}"), + &column_accounting_location(root, &column, source_override, None), + Some(&column), ); accounts.push(ColumnAccount { column, @@ -2760,7 +2774,7 @@ impl<'a> Compiler<'a> { if !order.insert(property_name.as_str()) { self.error( "list.order_duplicate", - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), "fixed order keys must be unique", ); } @@ -2788,21 +2802,21 @@ impl<'a> Compiler<'a> { if !property.source_required { self.error( "list.order_property_optional", - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), "fixed order properties must be required in the governed source contract", ); } if !cursor_order_type_supported(binding.data_type) { self.error( "list.order_property_type_unsupported", - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), "fixed order properties must use a cursor-supported string, integer, or boolean value shape", ); } if !order_columns.insert(binding.source_column.as_str()) { self.error( "list.order_column_duplicate", - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), "fixed order properties must resolve to distinct source columns", ); } @@ -2810,13 +2824,13 @@ impl<'a> Compiler<'a> { observed_view, &binding.source_column, binding.data_type, - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), ); operation.query.order_by.push(binding.source_column.clone()); } None => self.error( "list.order_property_unknown", - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), "a fixed order key must name a published property", ), } @@ -2936,7 +2950,7 @@ impl<'a> Compiler<'a> { if !order.insert(property_name.as_str()) { self.error( "search.order_duplicate", - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), "fixed search order keys must be unique", ); } @@ -2964,21 +2978,21 @@ impl<'a> Compiler<'a> { if !property.source_required { self.error( "search.order_property_optional", - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), "fixed search order properties must be required", ); } if !cursor_order_type_supported(binding.data_type) { self.error( "search.order_property_type_unsupported", - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), "fixed search order properties must use a cursor-supported scalar shape", ); } if !order_columns.insert(binding.source_column.as_str()) { self.error( "search.order_column_duplicate", - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), "fixed search order properties must resolve to distinct source columns", ); } @@ -2986,13 +3000,13 @@ impl<'a> Compiler<'a> { observed_view, &binding.source_column, binding.data_type, - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), ); operation.query.order_by.push(binding.source_column.clone()); } None => self.error( "search.order_property_unknown", - &format!("{location}.orderBy"), + &format!("{location}.orderBy[{index}]"), "a fixed search order key must name a published property", ), } @@ -3441,17 +3455,22 @@ impl<'a> Compiler<'a> { if requires_explicit_review && !source_override.is_some_and(explicit_reviewed_classification) { + let location = column_accounting_location(root, column, source_override, None); if self.profile == CompileProfile::Production { self.error( "classification.column_explicit_review_required", - &format!("{root}.sourceColumnClassifications.{column}"), - "a transformed or multiply-bound source column requires its own complete reviewed classification", + &location, + &format!( + "a transformed or multiply-bound source column requires its own complete reviewed classification for source column '{column}'" + ), ); } else { self.warning( "classification.column_explicit_review_required", - &format!("{root}.sourceColumnClassifications.{column}"), - "a transformed or multiply-bound source column still requires its own complete reviewed classification", + &location, + &format!( + "a transformed or multiply-bound source column still requires its own complete reviewed classification for source column '{column}'" + ), ); } } @@ -3478,8 +3497,10 @@ impl<'a> Compiler<'a> { let Some(classification) = classification else { self.error( "classification.column_incomplete", - &format!("{root}.sourceColumnClassifications"), - "an accounted source column has no complete classification", + &column_accounting_location(root, column, source_override, None), + &format!( + "an accounted source column has no complete classification for source column '{column}'" + ), ); continue; }; @@ -3493,8 +3514,10 @@ impl<'a> Compiler<'a> { { self.error( "classification.geometry_carrier_privacy_mismatch", - &format!("{root}.sourceColumnClassifications.{column}.privacy"), - "a Point property and each carrier require exact reviewed privacy agreement", + &column_accounting_location(root, column, source_override, Some("privacy")), + &format!( + "a Point property and each carrier require exact reviewed privacy agreement for source column '{column}'" + ), ); } } @@ -3507,14 +3530,17 @@ impl<'a> Compiler<'a> { if strongest_direct.is_some_and(|handling| classification.handling < handling) { self.error( "classification.column_weaker_than_property", - &format!("{root}.sourceColumnClassifications.{column}.handling"), - "a source-column classification cannot weaken a direct property handling floor", + &column_accounting_location(root, column, source_override, Some("handling")), + &format!( + "a source-column classification cannot weaken a direct property handling floor for source column '{column}'" + ), ); } } self.validate_review_status( &classification, - &format!("{root}.sourceColumnClassifications"), + &column_accounting_location(root, column, source_override, None), + Some(column), ); accounts.push(ColumnAccount { column: column.to_owned(), @@ -3693,18 +3719,26 @@ impl<'a> Compiler<'a> { } } - fn validate_review_status(&mut self, classification: &EffectiveClassification, location: &str) { + fn validate_review_status( + &mut self, + classification: &EffectiveClassification, + location: &str, + column: Option<&str>, + ) { if classification.status != ReviewStatus::Reviewed { + let detail = column + .map(|column| format!(" for source column '{column}'")) + .unwrap_or_default(); match self.profile { CompileProfile::Authoring => self.warning( "classification.unreviewed", location, - "classification suggestions require institutional review", + &format!("classification suggestions require institutional review{detail}"), ), CompileProfile::Production => self.error( "classification.unreviewed", location, - "production compilation requires reviewed classification", + &format!("production compilation requires reviewed classification{detail}"), ), } } @@ -4574,6 +4608,28 @@ fn explicit_reviewed_classification(value: &ClassificationPartial) -> bool { && value.status == Some(ReviewStatus::Reviewed) } +/// Locates a column-accounting diagnostic about one accounted column. When the +/// resource or dataset authored a source-column classification entry for the +/// column, the location targets that entry, or one of its fields; otherwise +/// the column's classification came entirely from `classificationDefaults`, +/// so the location targets that always-authored field instead, since no +/// `sourceColumnClassifications` entry names the column in the authored +/// document. +fn column_accounting_location( + root: &str, + column: &str, + source_override: Option<&ClassificationPartial>, + field: Option<&str>, +) -> String { + match source_override { + Some(_) => match field { + Some(field) => format!("{root}.sourceColumnClassifications.{column}.{field}"), + None => format!("{root}.sourceColumnClassifications.{column}"), + }, + None => format!("{root}.classificationDefaults"), + } +} + fn validate_disclosure_access( report: &mut CompileReport, disclosure: &CompiledDisclosureProfile, @@ -5593,6 +5649,71 @@ pub(crate) mod tests { .all(|component| component.source_column != "tenant")); } + #[test] + fn unreviewed_statistical_source_columns_without_override_point_at_classification_defaults() { + let suggested = statistical_contract().replace( + "classificationDefaults: {privacy: non-personal, institutional: public, handling: public, status: reviewed}", + "classificationDefaults: {privacy: non-personal, institutional: public, handling: public, status: suggested}", + ); + let contract = + RegistryContract::parse_yaml(&suggested).expect("strict statistical contract"); + let report = compile_contract( + &contract, + &[statistical_observed_schema()], + CompileProfile::Production, + ) + .expect_err("unreviewed statistical source columns refuse production compilation"); + let unreviewed = report + .diagnostics + .iter() + .filter(|diagnostic| { + diagnostic.code == "classification.unreviewed" + && diagnostic.location == "statisticalDatasets[0].classificationDefaults" + }) + .collect::>(); + for column in [ + "ref_area", + "sex", + "time_period", + "obs_value", + "unit_measure", + ] { + assert!( + unreviewed + .iter() + .any(|diagnostic| diagnostic.message.contains(&format!("'{column}'"))), + "expected a classification.unreviewed diagnostic naming source column '{column}': {unreviewed:?}" + ); + } + } + + #[test] + fn unreviewed_statistical_source_column_with_override_points_at_its_own_entry() { + let yaml = statistical_contract().replace( + " sourceColumnClassifications: {}", + " sourceColumnClassifications:\n ref_area: {privacy: non-personal, institutional: public, handling: public, status: suggested}", + ); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict statistical contract"); + let report = compile_contract( + &contract, + &[statistical_observed_schema()], + CompileProfile::Production, + ) + .expect_err( + "an unreviewed statistical source-column override refuses production compilation", + ); + let diagnostic = report + .diagnostics + .iter() + .find(|diagnostic| { + diagnostic.code == "classification.unreviewed" + && diagnostic.location + == "statisticalDatasets[0].sourceColumnClassifications.ref_area" + }) + .expect("an authored override still resolves to its own entry"); + assert!(diagnostic.message.contains("'ref_area'")); + } + #[test] fn complete_governed_closure_compiles_reproducibly() { let contract = RegistryContract::parse_yaml(valid_contract()).expect("strict contract"); @@ -5822,6 +5943,83 @@ pub(crate) mod tests { ); } + #[test] + fn fixed_list_order_diagnostics_name_each_order_position() { + let yaml = valid_contract() + .replace( + "read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + "list:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n filters: []\n allowUnfiltered: true\n orderBy: [name, name, absent]\n pagination: {defaultPageSize: 1, maximumPageSize: 10}", + ) + .replace("operationRefs: [read]", "operationRefs: [list]"); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict list contract"); + let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("repeated and unknown fixed order keys are refused"); + let located = |code: &str| { + report + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == code) + .map(|diagnostic| diagnostic.location.clone()) + .unwrap_or_else(|| panic!("stable {code} diagnostic")) + }; + assert_eq!( + located("list.order_duplicate"), + "resources[0].operations.list.orderBy[1]" + ); + assert_eq!( + located("list.order_column_duplicate"), + "resources[0].operations.list.orderBy[1]" + ); + assert_eq!( + located("list.order_property_unknown"), + "resources[0].operations.list.orderBy[2]" + ); + } + + #[test] + fn fixed_search_order_diagnostics_name_each_order_position() { + let yaml = point_contract() + .replace( + "disclosureProfiles: {public: {properties: [name]}}", + "disclosureProfiles: {public: {properties: [name, location]}}", + ) + .replace( + " read:\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}", + " searches:\n - id: within-bbox\n query: {kind: point-bbox, maximumLongitudeSpanDegrees: 2, maximumLatitudeSpanDegrees: 2}\n defaultAccessProfile: public\n accessProfiles:\n public: {access: public, disclosureProfile: public}\n orderBy: [name, name, absent]\n pagination: {defaultPageSize: 10, maximumPageSize: 100}", + ) + .replace( + "operationRefs: [read]", + "operationRefs: [search:within-bbox]", + ); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict spatial contract"); + let report = compile_contract( + &contract, + &[point_observed_schema("INTEGER", "REAL")], + CompileProfile::Production, + ) + .expect_err("repeated and unknown fixed search order keys are refused"); + let located = |code: &str| { + report + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == code) + .map(|diagnostic| diagnostic.location.clone()) + .unwrap_or_else(|| panic!("stable {code} diagnostic")) + }; + assert_eq!( + located("search.order_duplicate"), + "resources[0].operations.searches[0].orderBy[1]" + ); + assert_eq!( + located("search.order_column_duplicate"), + "resources[0].operations.searches[0].orderBy[1]" + ); + assert_eq!( + located("search.order_property_unknown"), + "resources[0].operations.searches[0].orderBy[2]" + ); + } + #[test] fn sqlite_view_nullable_metadata_does_not_override_required_order_contract() { let yaml = valid_contract() @@ -6800,6 +6998,83 @@ pub(crate) mod tests { })); } + #[test] + fn unreviewed_source_columns_without_override_name_each_column_in_the_message() { + let suggested = valid_contract().replace( + "classificationDefaults: {privacy: non-personal, institutional: public, handling: public, status: reviewed}", + "classificationDefaults: {privacy: non-personal, institutional: public, handling: public, status: suggested}", + ); + let contract = RegistryContract::parse_yaml(&suggested).expect("strict contract"); + let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("unreviewed source columns refuse production compilation"); + let unreviewed = report + .diagnostics + .iter() + .filter(|diagnostic| { + diagnostic.code == "classification.unreviewed" + && diagnostic.location == "resources[0].classificationDefaults" + }) + .collect::>(); + for column in ["id", "lifecycle", "name", "recorded_at", "revision"] { + assert!( + unreviewed + .iter() + .any(|diagnostic| diagnostic.message.contains(&format!("'{column}'"))), + "expected a classification.unreviewed diagnostic naming source column '{column}': {unreviewed:?}" + ); + } + } + + #[test] + fn unreviewed_source_column_with_override_points_at_its_own_entry() { + let yaml = valid_contract().replace( + " sourceColumnClassifications: {}", + " sourceColumnClassifications:\n name: {privacy: non-personal, institutional: public, handling: public, status: suggested}", + ); + let contract = RegistryContract::parse_yaml(&yaml).expect("strict contract"); + let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("an unreviewed source-column override refuses production compilation"); + let diagnostic = report + .diagnostics + .iter() + .find(|diagnostic| { + diagnostic.code == "classification.unreviewed" + && diagnostic.location == "resources[0].sourceColumnClassifications.name" + }) + .expect("an authored override still resolves to its own entry"); + assert!(diagnostic.message.contains("'name'")); + } + + #[test] + fn incomplete_source_column_classifications_name_each_column_in_the_message() { + let incomplete = valid_contract().replace( + "classificationDefaults: {privacy: non-personal, institutional: public, handling: public, status: reviewed}", + "classificationDefaults: {privacy: non-personal, institutional: public, status: reviewed}", + ); + let contract = RegistryContract::parse_yaml(&incomplete).expect("strict contract"); + let report = compile_contract(&contract, &[observed_schema()], CompileProfile::Production) + .expect_err("incomplete source columns refuse production compilation"); + let incomplete_diagnostics = report + .diagnostics + .iter() + .filter(|diagnostic| { + diagnostic.code == "classification.column_incomplete" + && diagnostic.location == "resources[0].classificationDefaults" + }) + .collect::>(); + // A published property with an incomplete classification is refused + // before column accounting, so the accounted columns left to report + // are the four Registry Core carriers. + for column in ["id", "lifecycle", "recorded_at", "revision"] { + assert!( + incomplete_diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains(&format!("'{column}'"))), + "expected a classification.column_incomplete diagnostic naming source column '{column}': {incomplete_diagnostics:?}" + ); + } + } + #[test] fn access_profile_default_and_transform_parameters_fail_closed() { let invalid_default = governed_access_profiles_contract().replace( diff --git a/crates/registry-relay-v2/src/fixtures.rs b/crates/registry-relay-v2/src/fixtures.rs index d4e38091e..9065aa8c0 100644 --- a/crates/registry-relay-v2/src/fixtures.rs +++ b/crates/registry-relay-v2/src/fixtures.rs @@ -1085,7 +1085,8 @@ fn sdmx_json_rows(document: &Value) -> Option>> { sdmx_decode_values(&mut row, values, &measures, &attributes)?; rows.push(row); } - } else if let Some(series) = data_set.get("series").and_then(Value::as_object) { + } else { + let series = data_set.get("series").and_then(Value::as_object)?; for (series_key, series_document) in series { let series_values = sdmx_decode_key(series_key, &series_dimensions)?; let observations = series_document.get("observations")?.as_object()?; @@ -1100,8 +1101,6 @@ fn sdmx_json_rows(document: &Value) -> Option>> { rows.push(row); } } - } else { - return None; } Some(rows) } @@ -1704,6 +1703,27 @@ dataflow,EXAMPLE:RATES(1.0.0),R,EX-A,2024-Q1,65.5,PERCENT\n"; ); } + #[test] + fn sdmx_json_rows_refuse_a_data_set_without_observations_or_series() { + let structures = json!([{ + "dimensions": { + "series": [{"id": "REF_AREA", "values": [{"id": "EX-A"}]}], + "observation": [{"id": "TIME_PERIOD", "values": [{"value": "2024-Q1"}]}] + }, + "measures": {"observation": [{"id": "OBS_VALUE"}]}, + "attributes": {"observation": [{ + "id": "UNIT_MEASURE", "values": [{"id": "PERCENT"}] + }]} + }]); + + let neither = json!({"data": {"dataSets": [{}], "structures": structures}}); + assert_eq!(sdmx_json_rows(&neither), None); + + let unusable_series = + json!({"data": {"dataSets": [{"series": []}], "structures": structures}}); + assert_eq!(sdmx_json_rows(&unusable_series), None); + } + #[test] fn fixture_tokens_have_a_bounded_jwt_shape_without_exposing_claims() { let token = fixture_token("fixture-a"); diff --git a/crates/registry-relayctl/src/lib.rs b/crates/registry-relayctl/src/lib.rs index f338deeb1..9ee5d2f7c 100644 --- a/crates/registry-relayctl/src/lib.rs +++ b/crates/registry-relayctl/src/lib.rs @@ -10,11 +10,13 @@ use std::io::{self, Write}; use std::process::ExitCode; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; -use serde::Serialize; +mod report; mod shared; mod tooling_editor; +use crate::shared::ToolingReport; + const DOMAIN_REFUSAL_EXIT: u8 = 1; const USAGE_EXIT: u8 = 2; const OPERATIONAL_FAILURE_EXIT: u8 = 3; @@ -212,7 +214,6 @@ where command => command, }; - let command_name = command.name(); let report = match shared::execute(command) { Ok(report) => report, Err(error) => { @@ -221,7 +222,7 @@ where } }; - if render_report(command_name, &report, json, stdout).is_err() { + if render_report(&report, json, stdout).is_err() { let _ = writeln!(stderr, "relayctl: output could not be written"); return ExitCode::from(OPERATIONAL_FAILURE_EXIT); } @@ -233,21 +234,6 @@ where } } -impl Command { - fn name(&self) -> &'static str { - match self { - Self::Init(_) => "init", - Self::Inspect(_) => "inspect", - Self::Check(_) => "check", - Self::Generate(_) => "generate", - Self::Test(_) => "test", - Self::Diff(_) => "diff", - Self::Package(_) => "package", - Self::Tooling(_) => "tooling", - } - } -} - fn run_tooling( command: ToolingCommand, json: bool, @@ -297,21 +283,17 @@ fn run_tooling( } } -fn render_report( - command: &str, - report: &T, - json: bool, - output: &mut dyn Write, -) -> io::Result<()> { +/// Write one shared report: the machine document under `--json`, and the plain +/// adopter rendering otherwise. The shared report is the sole source of command +/// details in both. Rendering it does not reinterpret compiler outcomes or +/// change classes, and the JSON document is the same one either way. +fn render_report(report: &ToolingReport, json: bool, output: &mut dyn Write) -> io::Result<()> { if json { serde_json::to_writer_pretty(&mut *output, report).map_err(io::Error::other)?; writeln!(output) } else { - writeln!(output, "relayctl {command}")?; - // The shared report is the sole source of command details. Rendering - // it here does not reinterpret compiler outcomes or change classes. - serde_json::to_writer_pretty(&mut *output, report).map_err(io::Error::other)?; - writeln!(output) + let rendered = report::render_human(report).map_err(io::Error::other)?; + output.write_all(rendered.as_bytes()) } } @@ -545,28 +527,188 @@ mod tests { .starts_with(".relay-v2-editor-transaction-"))); } + /// One shared report of every kind the tooling facade can return, as the + /// exact JSON document `--json` has always written. + const REPORT_DOCUMENTS: [&str; 7] = [ + concat!( + "{\n", + " \"status\": \"success\",\n", + " \"diagnostics\": [],\n", + " \"details\": {\n", + " \"kind\": \"initialized\",\n", + " \"files\": [\n", + " \"registry.yaml\"\n", + " ]\n", + " }\n", + "}" + ), + concat!( + "{\n", + " \"status\": \"success\",\n", + " \"diagnostics\": [],\n", + " \"details\": {\n", + " \"kind\": \"schema-inspection\",\n", + " \"fingerprint\": \"sha256:aaaa\",\n", + " \"objects\": [\n", + " {\n", + " \"kind\": \"table\",\n", + " \"name\": \"source_records\",\n", + " \"tableName\": \"source_records\",\n", + " \"columns\": [\n", + " {\n", + " \"name\": \"record_identifier\",\n", + " \"declaredType\": \"TEXT\",\n", + " \"nullable\": false,\n", + " \"primaryKey\": true\n", + " }\n", + " ]\n", + " }\n", + " ],\n", + " \"starter_file\": null\n", + " }\n", + "}" + ), + concat!( + "{\n", + " \"status\": \"refused\",\n", + " \"diagnostics\": [\n", + " {\n", + " \"severity\": \"error\",\n", + " \"code\": \"runtime.issuer_missing\",\n", + " \"location\": \"runtime.yaml.authentication.issuer\",\n", + " \"message\": \"a Registry with protected operations requires one configured issuer\"\n", + " }\n", + " ],\n", + " \"details\": {\n", + " \"kind\": \"check\",\n", + " \"contract_revision\": null,\n", + " \"production\": true,\n", + " \"configuration_key_paths\": null\n", + " }\n", + "}" + ), + concat!( + "{\n", + " \"status\": \"success\",\n", + " \"diagnostics\": [],\n", + " \"details\": {\n", + " \"kind\": \"generate\",\n", + " \"contract_revision\": \"sha256:bbbb\",\n", + " \"artifacts\": [\n", + " {\n", + " \"id\": \"capability-inventory\",\n", + " \"path\": \"artifacts/capabilities.json\",\n", + " \"sha256\": \"sha256:cccc\"\n", + " }\n", + " ]\n", + " }\n", + "}" + ), + concat!( + "{\n", + " \"status\": \"success\",\n", + " \"diagnostics\": [],\n", + " \"details\": {\n", + " \"kind\": \"test\",\n", + " \"contract_revision\": \"sha256:dddd\",\n", + " \"report\": {\n", + " \"registryIdentifier\": \"urn:example:registry:records\",\n", + " \"selectedFixture\": null,\n", + " \"steps\": [\n", + " {\n", + " \"id\": \"first-page\",\n", + " \"operationIdentifier\": \"record.list\",\n", + " \"expectedStatus\": 200,\n", + " \"actualStatus\": 200,\n", + " \"actualCode\": null,\n", + " \"passed\": true\n", + " }\n", + " ],\n", + " \"diagnostics\": []\n", + " }\n", + " }\n", + "}" + ), + concat!( + "{\n", + " \"status\": \"success\",\n", + " \"diagnostics\": [],\n", + " \"details\": {\n", + " \"kind\": \"diff\",\n", + " \"report\": {\n", + " \"previousRevision\": \"sha256:eeee\",\n", + " \"currentRevision\": \"sha256:ffff\",\n", + " \"changes\": [\n", + " {\n", + " \"class\": \"filter-removed\",\n", + " \"impact\": \"breaking\",\n", + " \"location\": \"resources[0].operations.list.filters\",\n", + " \"description\": \"a request filter was removed\"\n", + " }\n", + " ]\n", + " }\n", + " }\n", + "}" + ), + concat!( + "{\n", + " \"status\": \"refused\",\n", + " \"diagnostics\": [\n", + " {\n", + " \"severity\": \"error\",\n", + " \"code\": \"classification.unreviewed\",\n", + " \"location\": \"resources[0].sourceColumnClassifications\",\n", + " \"message\": \"production compilation requires reviewed classification\"\n", + " }\n", + " ],\n", + " \"details\": {\n", + " \"kind\": \"package\",\n", + " \"manifest\": null\n", + " }\n", + "}" + ), + ]; + + fn parsed_report(document: &str) -> ToolingReport { + serde_json::from_str(document).expect("the report document parses") + } + + fn written(report: &ToolingReport, json: bool) -> String { + let mut output = Vec::new(); + render_report(report, json, &mut output).expect("report renders"); + String::from_utf8(output).expect("output is UTF-8") + } + #[test] fn json_reports_are_one_valid_document() { - #[derive(Serialize)] - struct Report<'a> { - status: &'a str, - summary: &'a str, + let output = written(&parsed_report(REPORT_DOCUMENTS[1]), true); + + let value: serde_json::Value = serde_json::from_str(&output).expect("valid JSON"); + assert_eq!(value["status"], "success"); + assert_eq!(value["details"]["kind"], "schema-inspection"); + } + + #[test] + fn json_output_stays_byte_identical_to_the_shared_report_document() { + for document in REPORT_DOCUMENTS { + let report = parsed_report(document); + + assert_eq!(written(&report, true), format!("{document}\n")); } + } - let mut output = Vec::new(); - render_report( - "inspect", - &Report { - status: "accepted", - summary: "schema structure inspected", - }, - true, - &mut output, - ) - .expect("report renders"); + #[test] + fn the_default_output_is_the_plain_rendering_and_never_the_document() { + for document in REPORT_DOCUMENTS { + let report = parsed_report(document); + + let output = written(&report, false); - let value: serde_json::Value = serde_json::from_slice(&output).expect("valid JSON"); - assert_eq!(value["status"], "accepted"); + assert_eq!(output, report::render_human(&report).expect("renders")); + assert!(!output.starts_with('{'), "default output opened a document"); + assert!(!output.contains("\"status\""), "default output kept JSON"); + assert!(output.ends_with('\n')); + } } #[test] @@ -581,23 +723,13 @@ mod tests { #[test] fn json_rendering_is_deterministic_and_has_one_trailing_newline() { - #[derive(Serialize)] - struct Report<'a> { - status: &'a str, - summary: &'a str, - } + let report = parsed_report(REPORT_DOCUMENTS[1]); - let mut first = Vec::new(); - let mut second = Vec::new(); - let report = Report { - status: "accepted", - summary: "schema structure inspected", - }; - render_report("inspect", &report, true, &mut first).expect("report renders"); - render_report("inspect", &report, true, &mut second).expect("report repeats"); + let first = written(&report, true); + let second = written(&report, true); assert_eq!(first, second); - assert!(first.ends_with(b"\n")); - assert!(!first.ends_with(b"\n\n")); + assert!(first.ends_with('\n')); + assert!(!first.ends_with("\n\n")); } } diff --git a/crates/registry-relayctl/src/report.rs b/crates/registry-relayctl/src/report.rs new file mode 100644 index 000000000..753359436 --- /dev/null +++ b/crates/registry-relayctl/src/report.rs @@ -0,0 +1,1428 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Plain-text rendering of the shared Relay V2 tooling report. +//! +//! The report is the sole source of every line below. This module reads the +//! values the shared library already returned; it never recompiles a project, +//! reinterprets a compiler outcome, reclassifies a change, or adds a value the +//! JSON rendering does not already carry. Every enumeration label is the label +//! serde writes into that JSON, so the two renderings share one vocabulary. + +use serde::ser::Error as _; +use serde::Serialize; + +use crate::shared::{ + ChangeImpact, ChangeImpactReport, Diagnostic, DiagnosticSeverity, FixturePlanReport, + InspectedObject, PackageManifest, ToolingDetails, ToolingReport, ToolingStatus, +}; + +/// Ordinary detail indent, and the width of one nesting level. +const INDENT: &str = " "; +/// Widest severity label, so a mixed list keeps one message column. +const SEVERITY_WIDTH: usize = 7; +/// Widest change-impact label, so a mixed list keeps one description column. +const IMPACT_WIDTH: usize = 13; +/// Separator between the columns of one rendered line. +const GAP: &str = " "; +/// Widest a data-derived column may be padded. One long value must not push +/// every other line past a terminal width and wrap the whole list. +const ALIGN_LIMIT: usize = 40; + +/// Render one report as the plain lead sentence, its indented detail, and the +/// diagnostics the report carries. Every diagnostic is rendered, in a stable +/// order, so this rendering stays lossless against the JSON one. +pub(crate) fn render_human(report: &ToolingReport) -> Result { + let refused = report.status == ToolingStatus::Refused; + let mut lines = vec![lead(report, refused)]; + detail_lines(&report.details, &mut lines)?; + if !report.diagnostics.is_empty() { + lines.push(String::new()); + diagnostic_lines(&report.diagnostics, &mut lines)?; + lines.push(String::new()); + lines.push(diagnostic_summary(&report.diagnostics)); + } + lines.push(String::new()); + // Every value a report carries can originate in adopter input: a schema + // identifier read from SQLite, a key path, or a diagnostic message that + // interpolates an authored name. A report line is built from spaces and + // report text only, so escaping each finished line here neutralizes every + // such value at one boundary, whatever produced it. Escaped text is plain + // ASCII, so a value already escaped for column alignment passes through + // unchanged. + let escaped: Vec = lines.iter().map(|line| escape_report_text(line)).collect(); + Ok(escaped.join("\n")) +} + +fn lead(report: &ToolingReport, refused: bool) -> String { + match &report.details { + ToolingDetails::Initialized { files } => { + if refused { + "Project initialization refused.".to_owned() + } else { + format!( + "Initialized an authoring project. {} written.", + counted(files.len(), "file") + ) + } + } + ToolingDetails::SchemaInspection { objects, .. } => { + if refused { + "Schema inspection refused.".to_owned() + } else { + format!( + "Inspected the SQLite structure. {}.", + counted(objects.len(), "object") + ) + } + } + ToolingDetails::Check { production, .. } => { + let profile = if *production { + "Production check" + } else { + "Authoring check" + }; + let outcome = if refused { "refused" } else { "passed" }; + format!("{profile} {outcome}.") + } + ToolingDetails::Generate { artifacts, .. } => { + if refused { + "Artifact generation refused.".to_owned() + } else { + format!("Generated {}.", counted(artifacts.len(), "artifact")) + } + } + ToolingDetails::Test { report, .. } => match (refused, report) { + (true, _) => "Fixture run refused.".to_owned(), + (false, Some(plan)) => { + format!("Fixture run passed. {}.", counted(plan.steps.len(), "step")) + } + (false, None) => "Fixture run passed.".to_owned(), + }, + ToolingDetails::Diff { report } => match (refused, report) { + (true, _) => "Change classification refused.".to_owned(), + (false, Some(impact)) if impact.changes.is_empty() => "No contract changes.".to_owned(), + (false, Some(impact)) => format!( + "{} classified.", + counted(impact.changes.len(), "contract change") + ), + (false, None) => "Change classification reported nothing.".to_owned(), + }, + ToolingDetails::Package { manifest } => match (refused, manifest) { + (true, _) => "Packaging refused.".to_owned(), + (false, Some(manifest)) => format!( + "Sealed a deployment package. {}, {}.", + counted(manifest.artifacts.len(), "artifact"), + counted(manifest.files.len(), "file") + ), + (false, None) => "Sealed a deployment package.".to_owned(), + }, + } +} + +fn detail_lines( + details: &ToolingDetails, + lines: &mut Vec, +) -> Result<(), serde_json::Error> { + match details { + ToolingDetails::Initialized { files } => { + for file in files { + lines.push(format!("{INDENT}{file}")); + } + } + ToolingDetails::SchemaInspection { + fingerprint, + objects, + starter_file, + statistical_starter_file, + } => { + let mut pairs = vec![("fingerprint", fingerprint.clone())]; + if let Some(file) = starter_file { + pairs.push(("starter", file.clone())); + } + if let Some(file) = statistical_starter_file { + pairs.push(("statistical starter", file.clone())); + } + push_pairs(lines, INDENT, &pairs); + if !objects.is_empty() { + lines.push(String::new()); + } + for object in objects { + push_object(lines, object)?; + } + } + ToolingDetails::Check { + contract_revision, + configuration_key_paths, + .. + } => { + let mut pairs = Vec::new(); + if let Some(revision) = contract_revision { + pairs.push(("contract revision", revision.clone())); + } + if let Some(paths) = configuration_key_paths { + pairs.push(("registry key paths", paths.registry.len().to_string())); + pairs.push(("runtime key paths", paths.runtime.len().to_string())); + } + push_pairs(lines, INDENT, &pairs); + } + ToolingDetails::Generate { + contract_revision, + artifacts, + } => { + let mut pairs = Vec::new(); + if let Some(revision) = contract_revision { + pairs.push(("contract revision", revision.clone())); + } + push_pairs(lines, INDENT, &pairs); + if !artifacts.is_empty() { + lines.push(String::new()); + } + let width = align_width(artifacts.iter().map(|artifact| artifact.id.len())); + for artifact in artifacts { + let id = &artifact.id; + lines.push(format!("{INDENT}{id: { + let mut pairs = Vec::new(); + if let Some(revision) = contract_revision { + pairs.push(("contract revision", revision.clone())); + } + if let Some(plan) = report { + pairs.push(("registry", plan.registry_identifier.clone())); + if let Some(fixture) = &plan.selected_fixture { + pairs.push(("fixture", fixture.clone())); + } + } + push_pairs(lines, INDENT, &pairs); + if let Some(plan) = report { + push_steps(lines, plan); + } + } + ToolingDetails::Diff { report } => { + if let Some(impact) = report { + push_pairs( + lines, + INDENT, + &[ + ("previous revision", impact.previous_revision.clone()), + ("current revision", impact.current_revision.clone()), + ], + ); + push_changes(lines, impact)?; + } + } + ToolingDetails::Package { manifest } => { + if let Some(manifest) = manifest { + push_manifest(lines, manifest); + } + } + } + Ok(()) +} + +/// Nothing restricts what an adopter-supplied name may contain. SQLite places no restriction on an +/// identifier or a declared type, and an authored key path or column name reaches a diagnostic +/// message unchanged, so any of them can carry a line break, a terminal escape sequence, or a +/// Unicode bidirectional override that would forge a report line or redraw the text around it. This +/// replaces each such character with a visible, all-ASCII escape, so the value stays on the one line +/// it was given and cannot issue an instruction to the terminal or the reader. Ordinary printable +/// Unicode, including non-English identifiers, passes through unchanged, and text that is already +/// escaped is unchanged by a second pass. +fn escape_report_text(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if is_unsafe_for_a_report_line(character) { + escaped.extend(character.escape_default()); + } else { + escaped.push(character); + } + } + escaped +} + +/// C0 and C1 control characters (including newline, carriage return, tab, and escape) and DEL are +/// unsafe for any terminal. The Unicode bidirectional formatting and override characters are unsafe +/// for the same reason `rustc` denies them in a source literal: one of them can redraw the characters +/// that follow it, so a name carrying one can read as different text than the bytes it is. +fn is_unsafe_for_a_report_line(character: char) -> bool { + matches!( + character, + '\u{0000}'..='\u{001f}' + | '\u{007f}'..='\u{009f}' + | '\u{200e}'..='\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' + ) +} + +fn push_object(lines: &mut Vec, object: &InspectedObject) -> Result<(), serde_json::Error> { + let kind = wire_label(&object.kind)?; + let mut header = vec![kind, escape_report_text(&object.name)]; + if object.table_name != object.name { + header.push(format!("on {}", escape_report_text(&object.table_name))); + } + lines.push(format!("{INDENT}{}", header.join(GAP))); + + let columns: Vec<(String, String)> = object + .columns + .iter() + .map(|column| { + ( + escape_report_text(&column.name), + escape_report_text(&column.declared_type), + ) + }) + .collect(); + let name_width = align_width(columns.iter().map(|(name, _)| name.len())); + let type_width = align_width(columns.iter().map(|(_, declared)| declared.len())); + for (column, (name, declared)) in object.columns.iter().zip(&columns) { + let nullable = if column.nullable { + "nullable" + } else { + "not null" + }; + let key = if column.primary_key { + format!("{GAP}primary key") + } else { + String::new() + }; + lines.push(format!( + "{INDENT}{INDENT}{name:, plan: &FixturePlanReport) { + if plan.steps.is_empty() { + return; + } + lines.push(String::new()); + for step in &plan.steps { + let outcome = match step.passed { + Some(true) => "pass", + Some(false) => "fail", + None => "-", + }; + let operation = step.operation_identifier.as_deref().unwrap_or("-"); + let actual = step + .actual_status + .map_or_else(|| "-".to_owned(), |status| status.to_string()); + let code = step.actual_code.as_deref().unwrap_or_default(); + let columns = [ + format!("{outcome:<4}"), + step.id.clone(), + operation.to_owned(), + format!("expected {}", step.expected_status), + format!("actual {actual}"), + code.to_owned(), + ]; + lines.push(format!("{INDENT}{}", join_columns(&columns))); + } +} + +fn push_changes( + lines: &mut Vec, + report: &ChangeImpactReport, +) -> Result<(), serde_json::Error> { + if report.changes.is_empty() { + return Ok(()); + } + lines.push(String::new()); + for change in &report.changes { + let impact = wire_label(&change.impact)?; + let class = wire_label(&change.class)?; + lines.push(format!( + "{INDENT}{impact:, manifest: &PackageManifest) { + push_pairs( + lines, + INDENT, + &[ + ("package version", manifest.package_version.clone()), + ("package revision", manifest.package_revision.clone()), + ("contract revision", manifest.contract_revision.clone()), + ( + "artifact bindings", + manifest.operation_artifact_bindings.len().to_string(), + ), + ], + ); + if manifest.source_schema_fingerprints.is_empty() { + return; + } + lines.push(String::new()); + lines.push(format!("{INDENT}source schema fingerprints")); + let pairs = manifest + .source_schema_fingerprints + .iter() + .map(|(source, fingerprint)| (source.as_str(), fingerprint.clone())) + .collect::>(); + push_pairs(lines, &format!("{INDENT}{INDENT}"), &pairs); +} + +fn diagnostic_lines( + diagnostics: &[Diagnostic], + lines: &mut Vec, +) -> Result<(), serde_json::Error> { + for severity in [DiagnosticSeverity::Error, DiagnosticSeverity::Warning] { + for item in diagnostics.iter().filter(|item| item.severity == severity) { + let label = wire_label(&item.severity)?; + lines.push(format!( + "{INDENT}{label: String { + let errors = diagnostics + .iter() + .filter(|item| item.severity == DiagnosticSeverity::Error) + .count(); + format!( + "{}, {}.", + counted(errors, "error"), + counted(diagnostics.len() - errors, "warning") + ) +} + +/// Render aligned `label value` detail lines under one lead sentence. +fn push_pairs(lines: &mut Vec, indent: &str, pairs: &[(&str, String)]) { + let width = pairs + .iter() + .map(|(label, _)| label.len()) + .max() + .unwrap_or_default(); + for (label, value) in pairs { + lines.push(format!("{indent}{label:) -> usize { + widths.max().unwrap_or_default().min(ALIGN_LIMIT) +} + +fn join_columns(columns: &[String]) -> String { + columns + .iter() + .filter(|column| !column.is_empty()) + .map(String::as_str) + .collect::>() + .join(GAP) +} + +fn counted(count: usize, noun: &str) -> String { + if count == 1 { + format!("{count} {noun}") + } else { + format!("{count} {noun}s") + } +} + +/// The label serde already assigns to a report enumeration. Reading it here +/// keeps the human sentence and the JSON document in one vocabulary instead of +/// restating the compiler's terms in adopter tooling. +fn wire_label(value: &T) -> Result { + match serde_json::to_value(value)? { + serde_json::Value::String(label) => Ok(label), + _ => Err(serde_json::Error::custom( + "a report label did not serialize as one string", + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report(document: &str) -> ToolingReport { + serde_json::from_str(document).expect("the report fixture parses") + } + + fn rendered(document: &str) -> String { + render_human(&report(document)).expect("the report renders") + } + + const INITIALIZED: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "initialized", + "files": ["registry.yaml", "runtime.yaml"] + } + }"#; + + const INITIALIZATION_REFUSED: &str = r#"{ + "status": "refused", + "diagnostics": [ + { + "severity": "error", + "code": "project.destination_not_empty", + "location": ".", + "message": "initialization requires a new or empty project directory" + } + ], + "details": {"kind": "initialized", "files": []} + }"#; + + const INSPECTION: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "schema-inspection", + "fingerprint": "sha256:aaaa", + "objects": [ + { + "kind": "index", + "name": "source_records_key", + "tableName": "source_records", + "columns": [] + }, + { + "kind": "table", + "name": "source_records", + "tableName": "source_records", + "columns": [ + {"name": "record_identifier", "declaredType": "TEXT", "nullable": false, "primaryKey": true}, + {"name": "region_code", "declaredType": "TEXT", "nullable": true, "primaryKey": false} + ] + } + ], + "starter_file": "schema-starter.yaml", + "statistical_starter_file": "statistical-dataset-starter.yaml" + } + }"#; + + const CHECK_REFUSED: &str = r#"{ + "status": "refused", + "diagnostics": [ + { + "severity": "warning", + "code": "contract.review_pending", + "location": "resources[0]", + "message": "a suggested review entry is still unreviewed" + }, + { + "severity": "error", + "code": "runtime.issuer_missing", + "location": "runtime.yaml.authentication.issuer", + "message": "a Registry with protected operations requires one configured issuer" + }, + { + "severity": "error", + "code": "source.schema_observation_missing", + "location": "sources.registry", + "message": "production compilation requires the observed source schema" + } + ], + "details": { + "kind": "check", + "contract_revision": null, + "production": true, + "configuration_key_paths": null + } + }"#; + + const CHECK_PASSED: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "check", + "contract_revision": "sha256:bbbb", + "production": false, + "configuration_key_paths": { + "registry": ["apiVersion", "sources.*.path"], + "runtime": ["apiVersion"] + } + } + }"#; + + const GENERATED: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "generate", + "contract_revision": "sha256:cccc", + "artifacts": [ + {"id": "capability-inventory", "path": "artifacts/capabilities.json", "sha256": "sha256:dddd"}, + {"id": "identification-report", "path": "identification-report.md", "sha256": "sha256:eeee"} + ] + } + }"#; + + const FIXTURE_RUN_REFUSED: &str = r#"{ + "status": "refused", + "diagnostics": [ + { + "severity": "error", + "code": "fixture.status_mismatch", + "location": "steps[1]", + "message": "the fixture step returned another status" + } + ], + "details": { + "kind": "test", + "contract_revision": "sha256:ffff", + "report": { + "registryIdentifier": "urn:example:registry:records", + "selectedFixture": null, + "steps": [ + { + "id": "first-page", + "operationIdentifier": "record.list", + "expectedStatus": 200, + "actualStatus": 200, + "actualCode": null, + "passed": true + }, + { + "id": "refused-page", + "operationIdentifier": "record.list", + "expectedStatus": 200, + "actualStatus": 403, + "actualCode": "relay.forbidden", + "passed": false + } + ], + "diagnostics": [ + { + "code": "fixture.status_mismatch", + "location": "steps[1]", + "message": "the fixture step returned another status" + } + ] + } + } + }"#; + + const DIFF_CHANGED: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "diff", + "report": { + "previousRevision": "sha256:1111", + "currentRevision": "sha256:2222", + "changes": [ + { + "class": "pagination-expanded", + "impact": "widening", + "location": "resources[0].operations.list.pagination", + "description": "the maximum page size increased" + }, + { + "class": "filter-removed", + "impact": "breaking", + "location": "resources[0].operations.list.filters", + "description": "a request filter was removed" + } + ] + } + } + }"#; + + const DIFF_UNCHANGED: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "diff", + "report": { + "previousRevision": "sha256:1111", + "currentRevision": "sha256:1111", + "changes": [] + } + } + }"#; + + const PACKAGED: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "package", + "manifest": { + "packageVersion": "relay.registrystack.org/package/v1alpha3", + "packageRevision": "sha256:3333", + "contractRevision": "sha256:4444", + "sourceSchemaFingerprints": {"records": "sha256:5555"}, + "sourceSchemas": { + "records": { + "source": "records", + "fingerprint": "sha256:5555", + "views": [ + { + "name": "relay_records", + "columns": [ + {"name": "record_identifier", "declaredType": "TEXT", "nullable": true, "primaryKey": false} + ] + } + ] + } + }, + "artifacts": [ + { + "id": "capability-inventory", + "path": "generated/artifacts/capabilities.json", + "mediaType": "application/json", + "visibility": "public", + "operationIdentifier": null, + "accessBinding": null, + "sha256": "sha256:6666" + } + ], + "operationArtifactBindings": [ + { + "operationIdentifier": "record.list", + "accessProfileIdentifier": "public-view", + "vocabularyPath": "artifacts/record--list.vocabulary.jsonld", + "contextPath": "artifacts/record--list.context.jsonld", + "accessProfileSchemaPath": "artifacts/record--list.schema.json", + "accessProfileShaclPath": "artifacts/record--list.shacl.ttl", + "classificationPath": "artifacts/record--list.classifications.json", + "processingPath": "artifacts/record--list.processing.json" + } + ], + "files": [ + { + "path": "generated/artifacts/capabilities.json", + "size": 512, + "sha256": "sha256:6666", + "mediaType": "application/json", + "visibility": "public", + "generated": true + } + ] + } + } + }"#; + + const PACKAGE_REFUSED: &str = r#"{ + "status": "refused", + "diagnostics": [ + { + "severity": "error", + "code": "classification.unreviewed", + "location": "resources[0].properties.recordValue.classification", + "message": "production compilation requires reviewed classification" + } + ], + "details": {"kind": "package", "manifest": null} + }"#; + + /// Every fixture above is one report the shared library can return, so the + /// rendering tests never describe a shape the JSON contract does not have. + const EVERY_FIXTURE: [&str; 11] = [ + INITIALIZED, + INITIALIZATION_REFUSED, + INSPECTION, + CHECK_REFUSED, + CHECK_PASSED, + GENERATED, + FIXTURE_RUN_REFUSED, + DIFF_CHANGED, + DIFF_UNCHANGED, + PACKAGED, + PACKAGE_REFUSED, + ]; + + #[test] + fn every_rendering_is_plain_ascii_text_and_ends_with_one_newline() { + for document in EVERY_FIXTURE { + let output = rendered(document); + assert!(output.is_ascii(), "rendering left ASCII: {output}"); + assert!(!output.contains('\u{1b}'), "rendering used an escape code"); + assert!(output.ends_with('\n'), "rendering lacks a trailing newline"); + assert!( + !output.ends_with("\n\n"), + "rendering ends with a blank line" + ); + assert!(!output.starts_with('{'), "rendering opened a JSON document"); + for line in output.lines() { + assert_eq!(line.trim_end(), line, "line has trailing space: {line:?}"); + } + } + } + + #[test] + fn initialization_lists_every_written_file() { + assert_eq!( + rendered(INITIALIZED), + "Initialized an authoring project. 2 files written.\n registry.yaml\n runtime.yaml\n" + ); + } + + #[test] + fn initialization_refusal_states_the_refusal_and_counts_last() { + assert_eq!( + rendered(INITIALIZATION_REFUSED), + concat!( + "Project initialization refused.\n", + "\n", + " error project.destination_not_empty .\n", + " initialization requires a new or empty project directory\n", + "\n", + "1 error, 0 warnings.\n", + ) + ); + } + + #[test] + fn inspection_renders_objects_and_their_columns() { + assert_eq!( + rendered(INSPECTION), + concat!( + "Inspected the SQLite structure. 2 objects.\n", + " fingerprint sha256:aaaa\n", + " starter schema-starter.yaml\n", + " statistical starter statistical-dataset-starter.yaml\n", + "\n", + " index source_records_key on source_records\n", + " table source_records\n", + " record_identifier TEXT not null primary key\n", + " region_code TEXT nullable\n", + ) + ); + } + + #[test] + fn production_refusal_orders_errors_before_warnings_without_grouping() { + assert_eq!( + rendered(CHECK_REFUSED), + concat!( + "Production check refused.\n", + "\n", + " error runtime.issuer_missing runtime.yaml.authentication.issuer\n", + " a Registry with protected operations requires one configured issuer\n", + " error source.schema_observation_missing sources.registry\n", + " production compilation requires the observed source schema\n", + " warning contract.review_pending resources[0]\n", + " a suggested review entry is still unreviewed\n", + "\n", + "2 errors, 1 warning.\n", + ) + ); + } + + #[test] + fn a_passing_check_summarizes_the_configuration_key_paths() { + assert_eq!( + rendered(CHECK_PASSED), + concat!( + "Authoring check passed.\n", + " contract revision sha256:bbbb\n", + " registry key paths 2\n", + " runtime key paths 1\n", + ) + ); + } + + #[test] + fn generation_lists_every_artifact() { + assert_eq!( + rendered(GENERATED), + concat!( + "Generated 2 artifacts.\n", + " contract revision sha256:cccc\n", + "\n", + " capability-inventory artifacts/capabilities.json\n", + " identification-report identification-report.md\n", + ) + ); + } + + const GENERATED_WITH_A_LONG_IDENTIFIER: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "generate", + "contract_revision": null, + "artifacts": [ + {"id": "capability-inventory", "path": "artifacts/capabilities.json", "sha256": "sha256:dddd"}, + { + "id": "an-identifier-far-longer-than-the-column-alignment-limit-allows", + "path": "artifacts/long.json", + "sha256": "sha256:eeee" + } + ] + } + }"#; + + #[test] + fn one_long_identifier_does_not_widen_every_other_line() { + let output = rendered(GENERATED_WITH_A_LONG_IDENTIFIER); + let widest = output + .lines() + .map(|line| line.chars().count()) + .max() + .expect("the rendering has lines"); + + assert_eq!( + output, + concat!( + "Generated 2 artifacts.\n", + "\n", + " capability-inventory artifacts/capabilities.json\n", + " an-identifier-far-longer-than-the-column-alignment-limit-allows \ +artifacts/long.json\n", + ) + ); + // The short line is padded to the limit, not to the outlier's width. + assert!( + widest < 2 + 63 + 2 + "artifacts/capabilities.json".len(), + "a single long identifier widened the whole list: {widest}" + ); + } + + #[test] + fn a_fixture_run_renders_every_step_and_its_refusal() { + assert_eq!( + rendered(FIXTURE_RUN_REFUSED), + concat!( + "Fixture run refused.\n", + " contract revision sha256:ffff\n", + " registry urn:example:registry:records\n", + "\n", + " pass first-page record.list expected 200 actual 200\n", + " fail refused-page record.list expected 200 actual 403 relay.forbidden\n", + "\n", + " error fixture.status_mismatch steps[1]\n", + " the fixture step returned another status\n", + "\n", + "1 error, 0 warnings.\n", + ) + ); + } + + #[test] + fn a_diff_renders_every_change_and_counts_each_impact() { + assert_eq!( + rendered(DIFF_CHANGED), + concat!( + "2 contract changes classified.\n", + " previous revision sha256:1111\n", + " current revision sha256:2222\n", + "\n", + " widening pagination-expanded resources[0].operations.list.pagination\n", + " the maximum page size increased\n", + " breaking filter-removed resources[0].operations.list.filters\n", + " a request filter was removed\n", + "\n", + "1 breaking, 1 widening, 0 narrowing, 0 informational.\n", + ) + ); + } + + #[test] + fn an_unchanged_diff_states_that_and_stops() { + assert_eq!( + rendered(DIFF_UNCHANGED), + concat!( + "No contract changes.\n", + " previous revision sha256:1111\n", + " current revision sha256:1111\n", + ) + ); + } + + #[test] + fn a_sealed_package_renders_its_revisions_and_source_fingerprints() { + assert_eq!( + rendered(PACKAGED), + concat!( + "Sealed a deployment package. 1 artifact, 1 file.\n", + " package version relay.registrystack.org/package/v1alpha3\n", + " package revision sha256:3333\n", + " contract revision sha256:4444\n", + " artifact bindings 1\n", + "\n", + " source schema fingerprints\n", + " records sha256:5555\n", + ) + ); + } + + #[test] + fn a_refused_package_renders_the_refusal_without_a_manifest() { + assert_eq!( + rendered(PACKAGE_REFUSED), + concat!( + "Packaging refused.\n", + "\n", + " error classification.unreviewed resources[0].properties.recordValue.classification\n", + " production compilation requires reviewed classification\n", + "\n", + "1 error, 0 warnings.\n", + ) + ); + } + + #[test] + fn every_diagnostic_the_report_carries_is_rendered_once() { + let repeated = r#"{ + "status": "refused", + "diagnostics": [ + { + "severity": "error", + "code": "classification.unreviewed", + "location": "resources[0].sourceColumnClassifications", + "message": "production compilation requires reviewed classification" + }, + { + "severity": "error", + "code": "classification.unreviewed", + "location": "resources[0].sourceColumnClassifications", + "message": "production compilation requires reviewed classification" + } + ], + "details": { + "kind": "check", + "contract_revision": null, + "production": true, + "configuration_key_paths": null + } + }"#; + + let output = rendered(repeated); + + assert_eq!( + output + .lines() + .filter(|line| line.contains("classification.unreviewed")) + .count(), + 2 + ); + assert!(output.ends_with("2 errors, 0 warnings.\n")); + } + + // SQLite accepts a quoted identifier containing any byte a `TEXT` value can hold, including + // newlines, terminal escape sequences, and Unicode bidirectional overrides. Nothing upstream of + // this renderer restricts a schema name to a safe character set, so a hostile schema must not be + // able to forge a report line, emit a terminal escape sequence, or visually reorder the text + // around it. These fixtures use the same JSON shape as `INSPECTION`, with one schema-derived + // string replaced by a value a hostile schema could carry. + + const INSPECTION_HOSTILE_COLUMN_NAME: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "schema-inspection", + "fingerprint": "sha256:aaaa", + "objects": [ + { + "kind": "table", + "name": "source_records", + "tableName": "source_records", + "columns": [ + { + "name": "region_code\n\u001b[31mFORGED: 0 errors, 0 warnings.\u001b[0m", + "declaredType": "TEXT", + "nullable": true, + "primaryKey": false + } + ] + } + ], + "starter_file": null, + "statistical_starter_file": null + } + }"#; + + const INSPECTION_CONTROL_COLUMN_NAME: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "schema-inspection", + "fingerprint": "sha256:aaaa", + "objects": [ + { + "kind": "table", + "name": "source_records", + "tableName": "source_records", + "columns": [ + { + "name": "region_code_safe_control_value", + "declaredType": "TEXT", + "nullable": true, + "primaryKey": false + } + ] + } + ], + "starter_file": null, + "statistical_starter_file": null + } + }"#; + + #[test] + fn a_hostile_column_name_cannot_forge_a_report_line() { + let hostile = rendered(INSPECTION_HOSTILE_COLUMN_NAME); + let control = rendered(INSPECTION_CONTROL_COLUMN_NAME); + + assert!( + !hostile.contains('\u{1b}'), + "a raw escape byte reached the rendering: {hostile:?}" + ); + assert_eq!( + hostile.lines().count(), + control.lines().count(), + "the embedded newline changed the number of report lines: {hostile:?}" + ); + assert!( + !hostile.lines().any(|line| line.starts_with("FORGED")), + "the forged text opened its own report line: {hostile:?}" + ); + } + + const INSPECTION_HOSTILE_OBJECT_NAMES: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "schema-inspection", + "fingerprint": "sha256:aaaa", + "objects": [ + { + "kind": "index", + "name": "source_records_key\n\u001b[31mFORGED: 0 errors, 0 warnings.\u001b[0m", + "tableName": "source_records\n\u001b[32mFORGED-TABLE\u001b[0m", + "columns": [] + } + ], + "starter_file": null, + "statistical_starter_file": null + } + }"#; + + const INSPECTION_CONTROL_OBJECT_NAMES: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "schema-inspection", + "fingerprint": "sha256:aaaa", + "objects": [ + { + "kind": "index", + "name": "source_records_key_safe_control_value", + "tableName": "source_records_safe_control_value", + "columns": [] + } + ], + "starter_file": null, + "statistical_starter_file": null + } + }"#; + + #[test] + fn a_hostile_object_or_table_name_cannot_forge_a_report_line() { + let hostile = rendered(INSPECTION_HOSTILE_OBJECT_NAMES); + let control = rendered(INSPECTION_CONTROL_OBJECT_NAMES); + + assert!( + !hostile.contains('\u{1b}'), + "a raw escape byte reached the rendering: {hostile:?}" + ); + assert_eq!( + hostile.lines().count(), + control.lines().count(), + "an embedded newline changed the number of report lines: {hostile:?}" + ); + assert!( + !hostile + .lines() + .any(|line| line.starts_with("FORGED") || line.starts_with("FORGED-TABLE")), + "the forged text opened its own report line: {hostile:?}" + ); + } + + const INSPECTION_HOSTILE_DECLARED_TYPE: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "schema-inspection", + "fingerprint": "sha256:aaaa", + "objects": [ + { + "kind": "table", + "name": "source_records", + "tableName": "source_records", + "columns": [ + { + "name": "region_code", + "declaredType": "TEXT\n\u001b[31mFORGED: 0 errors, 0 warnings.\u001b[0m", + "nullable": true, + "primaryKey": false + } + ] + } + ], + "starter_file": null, + "statistical_starter_file": null + } + }"#; + + const INSPECTION_CONTROL_DECLARED_TYPE: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "schema-inspection", + "fingerprint": "sha256:aaaa", + "objects": [ + { + "kind": "table", + "name": "source_records", + "tableName": "source_records", + "columns": [ + { + "name": "region_code", + "declaredType": "TEXT_safe_control_value", + "nullable": true, + "primaryKey": false + } + ] + } + ], + "starter_file": null, + "statistical_starter_file": null + } + }"#; + + #[test] + fn a_hostile_declared_type_cannot_forge_a_report_line() { + let hostile = rendered(INSPECTION_HOSTILE_DECLARED_TYPE); + let control = rendered(INSPECTION_CONTROL_DECLARED_TYPE); + + assert!( + !hostile.contains('\u{1b}'), + "a raw escape byte reached the rendering: {hostile:?}" + ); + assert_eq!( + hostile.lines().count(), + control.lines().count(), + "the embedded newline changed the number of report lines: {hostile:?}" + ); + assert!( + !hostile.lines().any(|line| line.starts_with("FORGED")), + "the forged text opened its own report line: {hostile:?}" + ); + } + + // A raw string literal cannot hold U+202E directly: rustc denies an invisible + // text-direction codepoint appearing literally in source. `\u{202e}` keeps the + // codepoint out of the source text while still producing it in the parsed JSON. + const INSPECTION_BIDI_OVERRIDE_COLUMN_NAME: &str = "{ + \"status\": \"success\", + \"diagnostics\": [], + \"details\": { + \"kind\": \"schema-inspection\", + \"fingerprint\": \"sha256:aaaa\", + \"objects\": [ + { + \"kind\": \"table\", + \"name\": \"source_records\", + \"tableName\": \"source_records\", + \"columns\": [ + { + \"name\": \"region\u{202e}code_public\", + \"declaredType\": \"TEXT\", + \"nullable\": true, + \"primaryKey\": false + } + ] + } + ], + \"starter_file\": null, + \"statistical_starter_file\": null + } + }"; + + /// U+202E (RIGHT-TO-LEFT OVERRIDE) tells a terminal or editor to draw the characters after it in + /// reverse, so a column named `region\u{202e}code_public` can draw as something else entirely. + /// Left in the rendering, it would keep reordering everything printed after it on the same line. + #[test] + fn a_bidi_override_in_a_column_name_is_neutralized() { + let output = rendered(INSPECTION_BIDI_OVERRIDE_COLUMN_NAME); + assert!( + !output.contains('\u{202e}'), + "a raw bidirectional override character reached the rendering: {output:?}" + ); + } + + const INSPECTION_NON_ASCII_COLUMN_NAMES: &str = r#"{ + "status": "success", + "diagnostics": [], + "details": { + "kind": "schema-inspection", + "fingerprint": "sha256:aaaa", + "objects": [ + { + "kind": "table", + "name": "source_records", + "tableName": "source_records", + "columns": [ + {"name": "région_code", "declaredType": "TEXT", "nullable": true, "primaryKey": false}, + {"name": "注册", "declaredType": "TEXT", "nullable": true, "primaryKey": false} + ] + } + ], + "starter_file": null, + "statistical_starter_file": null + } + }"#; + + /// Legitimate schemas use non-English identifiers. Escaping is for bytes that are dangerous to a + /// terminal or a reader, not for ordinary printable Unicode outside ASCII, so a name like these + /// must reach the rendering unchanged and still readable. + #[test] + fn non_ascii_column_names_render_intact() { + let output = rendered(INSPECTION_NON_ASCII_COLUMN_NAMES); + assert!( + output.contains("région_code"), + "a non-ASCII Latin column name was altered: {output:?}" + ); + assert!( + output.contains("注册"), + "a non-ASCII CJK column name was altered: {output:?}" + ); + } + + // A diagnostic carries an authored key path in `location` and an authored source-column name + // inside `message`. Neither is restricted to a safe character set, and both are written into a + // report line, so the same forging a hostile schema name allows is reachable through an + // authoring document. These fixtures pair a hostile diagnostic with an identically shaped safe + // one, so each assertion compares against a real control rendering. + + const DIAGNOSTIC_HOSTILE_MESSAGE: &str = r#"{ + "status": "refused", + "diagnostics": [ + { + "severity": "error", + "code": "classification.column_incomplete", + "location": "resources[0].classificationDefaults", + "message": "an accounted source column has no complete classification for source column 'id\n\u001b[31mFORGED: 0 errors, 0 warnings.\u001b[0m'" + } + ], + "details": { + "kind": "check", + "contract_revision": null, + "production": true, + "configuration_key_paths": null + } + }"#; + + const DIAGNOSTIC_HOSTILE_LOCATION: &str = r#"{ + "status": "refused", + "diagnostics": [ + { + "severity": "error", + "code": "classification.column_incomplete", + "location": "resources[0].sourceColumnClassifications.id\n\u001b[31mFORGED: 0 errors, 0 warnings.\u001b[0m", + "message": "an accounted source column has no complete classification" + } + ], + "details": { + "kind": "check", + "contract_revision": null, + "production": true, + "configuration_key_paths": null + } + }"#; + + const DIAGNOSTIC_CONTROL: &str = r#"{ + "status": "refused", + "diagnostics": [ + { + "severity": "error", + "code": "classification.column_incomplete", + "location": "resources[0].classificationDefaults", + "message": "an accounted source column has no complete classification" + } + ], + "details": { + "kind": "check", + "contract_revision": null, + "production": true, + "configuration_key_paths": null + } + }"#; + + #[test] + fn a_hostile_diagnostic_message_cannot_forge_a_report_line() { + let hostile = rendered(DIAGNOSTIC_HOSTILE_MESSAGE); + let control = rendered(DIAGNOSTIC_CONTROL); + + assert!( + !hostile.contains('\u{1b}'), + "a raw escape byte reached the rendering: {hostile:?}" + ); + assert_eq!( + hostile.lines().count(), + control.lines().count(), + "the embedded newline changed the number of report lines: {hostile:?}" + ); + assert!( + !hostile + .lines() + .any(|line| line.trim_start().starts_with("FORGED")), + "the forged text opened its own report line: {hostile:?}" + ); + } + + #[test] + fn a_hostile_diagnostic_location_cannot_forge_a_report_line() { + let hostile = rendered(DIAGNOSTIC_HOSTILE_LOCATION); + let control = rendered(DIAGNOSTIC_CONTROL); + + assert!( + !hostile.contains('\u{1b}'), + "a raw escape byte reached the rendering: {hostile:?}" + ); + assert_eq!( + hostile.lines().count(), + control.lines().count(), + "the embedded newline changed the number of report lines: {hostile:?}" + ); + assert!( + !hostile + .lines() + .any(|line| line.trim_start().starts_with("FORGED")), + "the forged text opened its own report line: {hostile:?}" + ); + } +} diff --git a/crates/registry-relayctl/src/shared.rs b/crates/registry-relayctl/src/shared.rs index 8d6e6fe30..a7ba01d23 100644 --- a/crates/registry-relayctl/src/shared.rs +++ b/crates/registry-relayctl/src/shared.rs @@ -3,7 +3,17 @@ use registry_relay_v2::tooling::{ self, CheckOptions, DiffOptions, GenerateOptions, InitOptions, InspectOptions, - InspectionProfile, PackageOptions, TestOptions, ToolingError, ToolingReport, + InspectionProfile, PackageOptions, TestOptions, ToolingError, +}; + +/// The report shapes adopter presentation reads. Re-exporting them keeps every +/// mention of Relay semantics in this module, the rendering one included. +pub(crate) use registry_relay_v2::{ + diff::{ChangeImpact, ChangeImpactReport}, + fixtures::FixturePlanReport, + model::{Diagnostic, DiagnosticSeverity}, + package::PackageManifest, + tooling::{InspectedObject, ToolingDetails, ToolingReport, ToolingStatus}, }; use crate::{Command, InspectionProfileArg}; diff --git a/crates/registry-relayctl/tests/cli_contract.rs b/crates/registry-relayctl/tests/cli_contract.rs index 65febd098..16558e9ea 100644 --- a/crates/registry-relayctl/tests/cli_contract.rs +++ b/crates/registry-relayctl/tests/cli_contract.rs @@ -95,10 +95,14 @@ fn package_refuses_an_implicit_destination_without_echoing_project_contents() { fn adopter_commands_link_the_shared_library_and_never_spawn_relay() { let library = include_str!("../src/lib.rs"); let shared = include_str!("../src/shared.rs"); + let rendering = include_str!("../src/report.rs"); let binary = include_str!("../src/main.rs"); - let production = format!("{library}\n{shared}\n{binary}"); + let production = format!("{library}\n{shared}\n{rendering}\n{binary}"); assert!(shared.contains("registry_relay_v2::tooling")); + // Report presentation reads the shared report through the one seam, so the + // renderer names no Relay module of its own. + assert!(!rendering.contains("registry_relay_v2")); for forbidden in ["std::process::Command", "Command::new", "rusqlite"] { assert!( !production.contains(forbidden), diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index 3d303d3e8..349f26de9 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -16,13 +16,27 @@ This repo is an Astro and Starlight documentation site. ## Writing 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`, 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. +structure, frontmatter, page types, the banned-word list, the rules for pages +that ask the reader to run something, 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`, 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 claim inline with a `TODO[evidence]` MDX comment and propose a weaker claim level, rather than deleting the claim or asserting it. + +A procedure carries more than its commands: the reason for a step whose reason is +not visible in the command, what an irreversible step forecloses, what failure +looks like and the next move, and a `caution` or `danger` at every action that +loses data, exposes a secret, or cannot be undone. Show command output only when +you ran the command and read what came back; otherwise describe what happens in a +sentence. Do not ask a reader to paste guards, `exit 1`, or assertions that exist +for this project's own test harness. + +The docs gate runs the commands the tutorials document and deliberately does not +police prose. Wording, added reasons, and added recovery paths cannot break it, so +the writing review in the style guide is a judgement, not a word check: whether a +reader with only that page could finish the task and tell success from failure. diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 7ac63d9cb..5d674b13b 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -131,7 +131,10 @@ export default defineConfig({ ...buildNotaryRetirementRedirects(currentDocsetRedirect), ...buildRelayV2RetirementRedirects(currentDocsetRedirect), '/start/': internalRedirect('/'), - '/start/see-it-live/': internalRedirect('/start/quickstart/'), + '/start/see-it-live/': internalRedirect('/start/when-to-use/'), + // Retired: a second product chooser beside /start/when-to-use/, which + // absorbed its job. + '/start/quickstart/': internalRedirect('/start/when-to-use/'), '/explanation/trust-posture-and-security-guarantees/': internalRedirect('/security/'), '/reference/security-self-assessment/': internalRedirect('/security/self-assessment/'), '/reference/openssf-evidence/': internalRedirect('/security/openssf-evidence/'), @@ -146,11 +149,11 @@ export default defineConfig({ '/journeys/product-input-lifecycle/': internalRedirect('/generated-artifacts/'), // Retired first-call and source-review routes enter the supported local path. '/start/your-first-call/': internalRedirect('/tutorials/publish-governed-sqlite-registry/'), - '/start/test-current-source-revision/': internalRedirect('/start/quickstart/'), + '/start/test-current-source-revision/': internalRedirect('/start/when-to-use/'), // Retired lab tutorials land on the current chooser or Evidence Gateway // overview. The historical Solmara workflow used an obsolete Relay source // path and is no longer published as current guidance. - '/tutorials/first-run-with-registry-lab/': internalRedirect('/start/quickstart/'), + '/tutorials/first-run-with-registry-lab/': internalRedirect('/start/when-to-use/'), '/tutorials/first-run-with-solmara-lab/': internalRedirect('/start/evidence-quickstart/'), '/tutorials/review-a-dhis2-evidence-source/': internalRedirect('/tutorials/issue-immunization-evidence-from-dhis2/'), // Retired monorepo lab tutorials redirect to the current integration guidance. @@ -193,7 +196,7 @@ export default defineConfig({ '/projects/registry-relay/reference/': internalRedirect('/configure/relay/'), // Retired project routes redirect only when a current replacement exists. // Solmara Lab is an external adopter, not a Registry Stack product. - '/projects/registry-lab/demo-flow/': internalRedirect('/start/quickstart/'), + '/projects/registry-lab/demo-flow/': internalRedirect('/start/when-to-use/'), }, integrations: [ // Mermaid must come BEFORE starlight: its rehype plugin rewrites @@ -255,6 +258,8 @@ export default defineConfig({ }, }, customCss: ['./src/styles/custom.css'], + // Expressive Code settings live in ec.config.mjs, not here: the + // starlight-openapi plugin replaces this key wholesale. See that file. components: { Banner: './src/components/RegistryBanner.astro', Head: './src/components/RegistryHead.astro', @@ -274,20 +279,34 @@ export default defineConfig({ href: 'https://github.com/registrystack/registry-stack/tree/main/docs/site', }, ], - // Keep the first screen focused on adopter outcomes. Detailed product, - // generated-file, and contract material remains available under - // collapsed reference sections. + // Keep the first screen focused on adopter outcomes. Detailed product + // and contract material remains available under collapsed reference + // sections. Every top level is a task an adopter can name; the product + // that serves the task is named inside it. sidebar: [ { label: 'Start', items: [ { label: 'Overview', link: '/' }, - { label: 'When Registry Stack fits', slug: 'start/when-to-use' }, + { label: 'Which product fits your problem', slug: 'start/when-to-use' }, + // There is no 'Evaluate Registry Relay' beside this, and the + // asymmetry is deliberate. Relay answers its own evaluation + // question by running: the SQLite tutorial reaches a protected API + // in one sitting, so a reader deciding about Relay is better served + // by doing it than by reading about it. Evidence Gateway asks an + // adopter to commit to signing keys and a question model before + // anything runs, so its case has to be made before the first + // command rather than after it. { label: 'Evaluate Evidence Gateway', slug: 'start/evaluate-evidence' }, + // A reader on their first page meets the vocabulary before they + // meet a command, so the glossary sits here rather than in + // Reference, where it was reachable only after the terms had + // already gone by. + { label: 'Glossary', slug: 'reference/glossary' }, ], }, { - label: 'Answer with Evidence Gateway', + label: 'Answer a bounded question', items: [ { label: 'Overview', slug: 'start/evidence-quickstart' }, // The first hands-on tutorial stays in the open beside the @@ -306,14 +325,25 @@ export default defineConfig({ ], }, { - label: 'Connect a source', - collapsed: true, + // Open, because this is where an adopter leaves the mock source + // behind and points the deployment at their own institution. The + // source-product examples stay collapsed inside it: they show one + // way to do what the two pages above them describe generally. + label: 'Connect your own source', items: [ { label: 'Create a source from OpenAPI', slug: 'tutorials/connect-an-institution-source' }, { label: 'Connect a SQLite extract', slug: 'tutorials/connect-a-sqlite-extract' }, - { label: 'OpenCRVS: registered parent', slug: 'tutorials/verify-a-registered-parent-with-opencrvs' }, - { label: 'OpenCRVS: birth certificate SD-JWT VC', slug: 'tutorials/issue-a-birth-certificate-vc-from-opencrvs' }, - { label: 'DHIS2: immunization summary (under review)', slug: 'tutorials/issue-immunization-evidence-from-dhis2' }, + { label: 'Advanced source patterns', slug: 'explanation/integration-patterns' }, + { + label: 'Worked examples', + collapsed: true, + items: [ + { label: 'OpenCRVS: registered parent', slug: 'tutorials/verify-a-registered-parent-with-opencrvs' }, + { label: 'OpenCRVS: birth certificate SD-JWT VC', slug: 'tutorials/issue-a-birth-certificate-vc-from-opencrvs' }, + { label: 'DHIS2: immunization summary', slug: 'tutorials/issue-immunization-evidence-from-dhis2' }, + { label: 'FHIR R4: patient coverage SD-JWT VC', slug: 'tutorials/issue-fhir-evidence-as-vcs' }, + ], + }, ], }, { @@ -327,28 +357,6 @@ export default defineConfig({ { label: 'Deploy with Docker Compose', slug: 'tutorials/integrate-evidence-candidate-with-docker-compose' }, ], }, - { - label: 'Authenticate callers', - collapsed: true, - items: [ - { label: 'Add Mint to Evidence Gateway', slug: 'tutorials/issue-evidence-access-tokens-with-registry-mint' }, - { label: 'Configure Registry Mint', slug: 'configure/mint' }, - { label: 'Use Mint with QGIS', slug: 'configure/use-mint-with-qgis-and-standard-oauth-clients' }, - { label: 'Call Mint from application code', slug: 'configure/request-an-access-token' }, - ], - }, - // Two audiences that used to share one group: a relying party - // calling the HTTP contract, and a deployment delivering the same - // assertion to a wallet. Each reads only its own half. - { - label: 'Verify as a relying party', - collapsed: true, - items: [ - { label: 'Request from an application', slug: 'tutorials/request-evidence-from-an-application' }, - { label: 'Verify and retain an assertion', slug: 'tutorials/verify-an-assertion-as-a-consumer' }, - { label: 'Manage verifier trust', slug: 'tutorials/manage-evidence-verifier-trust' }, - ], - }, { label: 'Deliver to wallets', collapsed: true, @@ -358,16 +366,22 @@ export default defineConfig({ { label: 'Run OID4VCI interoperability checks', slug: 'tutorials/run-oid4vci-interoperability-checks' }, ], }, + // Reference material a reader needs while the deployment is in + // front of them, so it stays in this section rather than in + // Reference, where it answered questions nobody was asking yet. + { label: 'Configuration reference', slug: 'reference/evidence-configuration' }, + { label: 'Problems and error codes', slug: 'reference/evidence-problems' }, { - label: 'Operate Evidence Gateway', + label: 'HTTP API', collapsed: true, items: [ - { label: 'Rotate signing keys', slug: 'tutorials/rotate-evidence-signing-keys' }, - { label: 'Verify the audit chain', slug: 'operate/evidence-audit' }, + { label: 'Evidence Gateway (narrative)', slug: 'reference/apis/registry-evidence' }, + // Generated operation pages for each schema (theme-aware, searchable). + ...openAPISidebarGroups, ], }, // Product-scoped, so it sits with the product rather than in the - // cross-product Security section. + // cross-product security group under Operate and secure. { label: 'Security model', slug: 'security/evidence' }, ], }, @@ -388,42 +402,119 @@ export default defineConfig({ items: [ { label: 'Author a Relay project', slug: 'configure/relay' }, { label: 'Semantics and disclosure', slug: 'explanation/relay-semantics-and-disclosure' }, - { label: 'Advanced source patterns', slug: 'explanation/integration-patterns' }, + { label: 'Validate a project', slug: 'verify' }, ], }, { - label: 'Operate Relay', - collapsed: true, + // The caller's half of Relay, which the authoring and operating + // pages never address. Open rather than collapsed, because a + // consumer arrives without knowing Relay has a client at all, so + // the tutorial that shows one has to be visible from the section + // rather than behind a disclosure. + label: 'Call a Relay API', items: [ - { label: 'Prepare the operator handoff', slug: 'operate' }, - { label: 'Run a Relay deployment', slug: 'operate/relay' }, + { label: 'Query a Relay with Python', slug: 'tutorials/query-relay-client' }, + { label: 'Relay client APIs', slug: 'reference/relay-client-api' }, ], }, + { label: 'Run a Relay deployment', slug: 'operate/relay' }, + { label: 'relayctl workflows', slug: 'reference/relayctl' }, + { label: 'Operational posture (spec)', slug: 'spec/rs-op-posture' }, ], }, { - // Only what applies to more than one product. Anything that names a - // single product lives in that product's section. - label: 'Operate across products', + // Two audiences used to share one Evidence Gateway group: a relying + // party calling the HTTP contract, and a deployment delivering the + // same assertion to a wallet. A relying party runs neither runtime, + // so its path is a section of its own and wallet delivery stays with + // the deployment that does the delivering. + label: 'Consume and verify assertions', + items: [ + { label: 'Request from an application', slug: 'tutorials/request-evidence-from-an-application' }, + { label: 'Verify and retain an assertion', slug: 'tutorials/verify-an-assertion-as-a-consumer' }, + { label: 'Manage verifier trust', slug: 'tutorials/manage-evidence-verifier-trust' }, + ], + }, + { + // Registry Mint issues the access tokens a resource server verifies, + // so it is a step in both adoption paths and belongs to neither. + label: 'Authenticate callers', + collapsed: true, + items: [ + { label: 'Configure Registry Mint', slug: 'configure/mint' }, + { label: 'Add Mint to Evidence Gateway', slug: 'tutorials/issue-evidence-access-tokens-with-registry-mint' }, + { label: 'Call Mint from application code', slug: 'configure/request-an-access-token' }, + { label: 'Use Mint with QGIS', slug: 'configure/use-mint-with-qgis-and-standard-oauth-clients' }, + { label: 'Mint reference', slug: 'reference/mint' }, + ], + }, + { + // One index, one section. The concept, the tutorial, and the build + // page had been split across three unrelated parents. + label: 'Publish a Discovery index', collapsed: true, + items: [ + { label: 'Registry Discovery is an index', slug: 'explanation/discovery-as-an-index' }, + { label: 'Publish and consume an index', slug: 'tutorials/publish-and-consume-discovery-index' }, + { label: 'Build and run an index', slug: 'configure/discovery' }, + ], + }, + { + // What an operator does once a deployment is running, and the + // security material that operator is expected to have read. Pages + // that name one runtime are allowed here when the reader is the + // operator rather than the adopter who authored the project. + label: 'Operate and secure', items: [ { label: 'Overview', slug: 'operate/advanced' }, - { label: 'Publish and consume a Discovery index', slug: 'tutorials/publish-and-consume-discovery-index' }, - { label: 'Build and run a Registry Discovery index', slug: 'configure/discovery' }, - { label: 'Retention and persistent state', slug: 'operate/retention-and-persistent-state' }, - { label: 'Inspect and diagnose', slug: 'operate/advanced/inspect-and-diagnose' }, + { label: 'Prepare the operator handoff', slug: 'operate' }, + { label: 'Verify the Evidence audit chain', slug: 'operate/evidence-audit' }, + { label: 'Rotate Evidence signing keys', slug: 'tutorials/rotate-evidence-signing-keys' }, { label: 'Rotate credentials and trust', slug: 'operate/advanced/rotate-credentials-and-trust' }, + { label: 'Inspect and diagnose', slug: 'operate/advanced/inspect-and-diagnose' }, + { label: 'Retention and persistent state', slug: 'operate/retention-and-persistent-state' }, + { label: 'Generated files and ownership', slug: 'generated-artifacts' }, + { label: 'Harden a production deployment', slug: 'security/hardening-checklist' }, + { + label: 'Security and disclosure', + collapsed: true, + items: [ + { label: 'Overview', slug: 'security' }, + { label: 'Threat model', slug: 'explanation/threat-model' }, + { label: 'Known limitations', slug: 'explanation/known-limitations' }, + { label: 'Report a vulnerability', slug: 'security/report-a-vulnerability' }, + { label: 'Security support window', slug: 'security/support-window' }, + { label: 'Security self-assessment', slug: 'security/self-assessment' }, + { label: 'Release trust', slug: 'security/openssf-evidence' }, + ], + }, ], }, { - label: 'Security', + // Promoted out of Reference. A reader who wants the model behind the + // products is not looking up a contract, and burying these pages two + // levels inside Reference meant the decision records had no seat at + // all. + label: 'Understand the design', collapsed: true, items: [ - { label: 'Overview', slug: 'security' }, - { label: 'Report a vulnerability', slug: 'security/report-a-vulnerability' }, - { label: 'Security support window', slug: 'security/support-window' }, - { label: 'Security self-assessment', slug: 'security/self-assessment' }, - { label: 'Release trust', slug: 'security/openssf-evidence' }, + { label: 'Architecture', slug: 'explanation/architecture' }, + { label: 'Boundaries and map', slug: 'map/boundaries-and-map' }, + { label: 'Records stay home', slug: 'explanation/records-stay-home' }, + { 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: 'DPI safeguards', slug: 'explanation/dpi-safeguards-alignment' }, + { + // Newest first. The records have no index page of their own, so + // this group is the only navigation into them. + label: 'Decisions', + collapsed: true, + items: [ + { label: 'Relay V1 and registryctl retirement', slug: 'decisions/relay-v1-and-registryctl-retirement-2026-08-11' }, + { label: 'Registry Notary retirement', slug: 'decisions/notary-retirement-2026-08-03' }, + ], + }, ], }, { @@ -431,27 +522,37 @@ export default defineConfig({ collapsed: true, items: [ { label: 'Overview', slug: 'reference' }, - { label: 'Validate a project', slug: 'verify' }, - { label: 'Generated files and ownership', slug: 'generated-artifacts' }, - { label: 'Evidence Gateway configuration', slug: 'reference/evidence-configuration' }, - ...cliReferenceSidebar(), - { label: 'relayctl workflows', slug: 'reference/relayctl' }, + { label: 'Errors and status codes', slug: 'reference/errors' }, + { label: 'Environment variables', slug: 'reference/environment-variables' }, + { label: 'API overview', slug: 'reference/apis' }, { label: 'evidencectl workflows', slug: 'reference/evidencectl' }, - { label: 'Relay client APIs', slug: 'reference/relay-client-api' }, + ...cliReferenceSidebar(), { - label: 'API reference', + label: 'Compatibility and support', collapsed: true, items: [ - { label: 'Overview', slug: 'reference/apis' }, - { label: 'Evidence Gateway (narrative)', slug: 'reference/apis/registry-evidence' }, - // Generated operation pages for each schema (theme-aware, searchable). - ...openAPISidebarGroups, + { label: 'Contracts', slug: 'reference/contracts' }, + { label: 'API stability and versioning', slug: 'reference/api-stability' }, + { label: 'Deprecation policy', slug: 'reference/deprecation-policy' }, + { label: 'Standards', slug: 'reference/standards' }, + { label: 'ITB and SEMIC evidence', slug: 'reference/itb-semic-evidence' }, + ], + }, + { + label: 'Specifications', + collapsed: true, + items: [ + { label: 'Register', slug: 'spec' }, + { label: 'RS-DOC · Documentation framework', slug: 'spec/rs-doc' }, + { label: 'RS-TERMS · Terms', slug: 'spec/rs-terms' }, + { label: 'RS-ARC-G · Architecture', slug: 'spec/rs-arc-g' }, + { label: 'RS-PR-EVIDENCE · Evidence Gateway protocol', slug: 'spec/rs-pr-evidence' }, + { label: 'RS-PR-RELAYCTL · relayctl contract', slug: 'spec/rs-pr-relayctl' }, + { label: 'RS-PR-RELAY · Relay protocol', slug: 'spec/rs-pr-relay' }, + { label: 'RS-SEC-G · Security model', slug: 'spec/rs-sec-g' }, + { label: 'RS-DM-MANIFEST · Portable metadata model', slug: 'spec/rs-dm-manifest' }, ], }, - { label: 'Errors and status codes', slug: 'reference/errors' }, - { label: 'Evidence Gateway problems', slug: 'reference/evidence-problems' }, - { label: 'Registry Mint', slug: 'reference/mint' }, - { label: 'Environment variables', slug: 'reference/environment-variables' }, { label: 'Product documentation', collapsed: true, @@ -482,43 +583,8 @@ export default defineConfig({ : []), ], }, - { label: 'Contracts', slug: 'reference/contracts' }, - { label: 'API stability and versioning', slug: 'reference/api-stability' }, - { label: 'Deprecation policy', slug: 'reference/deprecation-policy' }, - { label: 'Standards', slug: 'reference/standards' }, - { label: 'ITB and SEMIC evidence', slug: 'reference/itb-semic-evidence' }, - { label: 'Glossary', slug: 'reference/glossary' }, - { - label: 'Concepts', - collapsed: true, - items: [ - { label: 'Architecture', slug: 'explanation/architecture' }, - { label: 'Boundaries and map', slug: 'map/boundaries-and-map' }, - { label: 'Records stay home', slug: 'explanation/records-stay-home' }, - { label: 'Registry Discovery is an index', slug: 'explanation/discovery-as-an-index' }, - { 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' }, - ], - }, - { - label: 'Specifications', - collapsed: true, - items: [ - { label: 'Register', slug: 'spec' }, - { label: 'RS-DOC · Documentation framework', slug: 'spec/rs-doc' }, - { label: 'RS-TERMS · Terms', slug: 'spec/rs-terms' }, - { label: 'RS-ARC-G · Architecture', slug: 'spec/rs-arc-g' }, - { label: 'RS-PR-EVIDENCE · Evidence Gateway protocol', slug: 'spec/rs-pr-evidence' }, - { label: 'RS-PR-RELAYCTL · relayctl contract', slug: 'spec/rs-pr-relayctl' }, - { label: 'RS-PR-RELAY · Relay protocol', slug: 'spec/rs-pr-relay' }, - { label: 'RS-SEC-G · Security model', slug: 'spec/rs-sec-g' }, - { label: 'RS-DM-MANIFEST · Portable metadata model', slug: 'spec/rs-dm-manifest' }, - ], - }, { label: 'Changelog', slug: 'changelog' }, + { label: 'Accessibility', slug: 'accessibility' }, ], }, ], diff --git a/docs/site/docs/style-guide.md b/docs/site/docs/style-guide.md index b7ef2b275..434ad31b6 100644 --- a/docs/site/docs/style-guide.md +++ b/docs/site/docs/style-guide.md @@ -1,7 +1,7 @@ # Registry Docs writing style guide **Status:** current -**Last reviewed:** 2026-05-23 +**Last reviewed:** 2026-08-19 **Applies to:** every page under `src/content/docs/` and every contributor or agent that writes them. This guide tells you how to write for Registry Docs. It is short on purpose. When in doubt, prefer clarity over cleverness, evidence over assertion, and the reader's task over the writer's voice. Borrowed from GitLab's documentation style guide, then trimmed and adapted to a 20-page institutional docs site. @@ -11,24 +11,27 @@ If a rule here conflicts with `design-registry-docs.md`, follow the design doc f ## Principles 1. **Documentation is the source of truth** for user-visible behavior. If the docs and the code disagree, one of them is wrong, and the docs page must say which. -2. **Evidence before claim.** Every factual statement points to code, fixtures, tests, OpenAPI, or an upstream standard. If you cannot point, mark the claim with a TODO and demote it to a weaker claim level. +2. **Evidence before claim.** Every factual statement points to code, fixtures, tests, OpenAPI, or an upstream standard. If you cannot point, mark the claim with a TODO and demote it to a weaker claim level. Pointing binds the writer; it does not oblige the reader to read the pointer. How much of the pointer belongs in the reader's sentence is settled in "Code, commands, paths". 3. **Reader first.** State the page's goal in the first paragraph. Put the next action at the end. Everything in between earns its place. 4. **Scannable beats narrative.** Short sections, descriptive headings, parallel lists, generated tables. A user landing from search should orient in 10 seconds. -5. **Concise.** A clear sentence beats a clear paragraph. A clear paragraph beats a clear section. +5. **Concise.** A clear sentence beats a clear paragraph. A clear paragraph beats a clear section. Concision cuts words, not the reason for a step, the consequence of a step, or the recovery from a step. +6. **Scanning is for finding. Procedures are for doing.** Principle 4 governs reference pages, landings, and anything a reader reaches from search. A reader part-way through a procedure with a terminal open is not scanning: that reader needs why the step exists, what it forecloses, and what to do when it fails, even when those cost a sentence each. Trimming a procedure until it scans well is how a page stops working. ## Voice and tone -- Use second person (`you`) for actions the reader takes. +- Use second person (`you`) for actions the reader takes, and keep using it to the end. A procedure that addresses the reader in the prerequisites and then goes impersonal for nine steps has stopped talking to anyone. - Use the project name (`Registry Relay`, not `we`) for system behavior. - Use active voice. Exception: when the actor is unimportant or obvious from context. -- Institutional, calm, technical. More operating manual than marketing copy. -- Do not address the reader's emotions. No "don't worry", "easy", "no problem". +- Institutional, calm, technical. More operating manual than marketing copy, and an operating manual is written to the operator. +- Do not manage the reader's emotions. No "don't worry", "easy", "no problem". Calm is not distance: that a step is destructive, that a mistake here cannot be undone, or that a command runs for ten minutes is information the reader needs, and withholding it is not restraint. +- Say what a thing does before what it does not. A negation closes a door the reader would otherwise walk through, so use one where a door is open. A run of sentences that all open with a negation tells the reader everything except what to do. - Do not promise future features. If a capability is unbuilt, link to the issue or say `not yet supported`. ## Page structure - H1 is the page topic, not the brand name. Title is set in frontmatter; do not write `#` H1 in MDX. - One lead paragraph directly under H1. No second lead. +- The lead names who the page is for wherever the page has a narrower reader than someone using this product: an adopter deploying it, an operator on call, a contributor changing it. A tutorial that opens without naming its reader is usually written for whoever wrote it. Reference and specification pages are exempt, because their reader is whoever holds the contract. - Increment heading levels by one. Do not skip from H2 to H4. - Max depth is H4. If a page wants H5, split the page. - Sentence case for all headings. `Architecture overview`, not `Architecture Overview`. @@ -62,9 +65,9 @@ standards_referenced: Each page belongs to exactly one `doc_type`. The pattern is enforced by the type. -**Tutorial.** Goal, prerequisites, estimated time, ordered steps, expected output, cleanup, next page. The reader can finish the tutorial in one sitting. +**Tutorial.** Goal, prerequisites, estimated time, ordered steps, how the reader knows each step worked, what to do when it did not, cleanup, next page. The reader can finish the tutorial in one sitting. "How the reader knows it worked" is a statement about observable state, not a mandatory transcript block: a line the command prints, a file that now exists, a status code, a key the reader can list. Show a transcript only under the rules in "Code, commands, paths". -**How-to.** When to use it, prerequisites, ordered steps, verification, troubleshooting. Scoped to one task. +**How-to.** When to use it, prerequisites, ordered steps, verification, troubleshooting. Scoped to one task. Verification, output, and recovery follow the same rules as a tutorial. **Explanation.** Context, model, boundaries, tradeoffs, related docs. No steps. No commands. The reader leaves with a mental model, not a finished artifact. @@ -109,8 +112,9 @@ Preferred terms. - Ordered list for steps that must run in sequence. Unordered list for items with no order. - All items start with a capital letter. -- Parallel structure. All items are noun phrases, or all are imperative verbs. Do not mix. -- No period if every item is a fragment. Period on every item if any item is a complete sentence. +- Parallel structure for a list of like things: options, fields, products, sources. All items are noun phrases, or all are imperative verbs. Do not mix. +- Steps in a procedure are not a list of like things. Write them as sentences and let them differ in shape. Forcing every step into one frame is how a page ends up repeating an opener nobody chose. +- No period if every item is a fragment. Period on every item if any item is a complete sentence. The fragment preference does not apply to procedure steps. - Use the Oxford comma in prose: `Manifest, Relay, and Evidence Gateway`. - Do not use bold inside list items for keywords. Reserve bold for UI labels. @@ -122,6 +126,21 @@ Preferred terms. - Use `` for values the reader replaces, in code blocks too: `curl https:///evidence/...`. - Do not paste real secrets, tokens, or production hostnames. Use `example.com` and the fake-token convention. - For keyboard shortcuts, use backticks: `Ctrl+C`. Inline HTML, including ``, fails the markdownlint gate (MD033). +- Show output only if you ran the command and read what came back. An unobserved transcript is a claim without evidence, and Principle 2 applies to it exactly as it applies to prose. If you cannot run the command, write a sentence for what happens instead: "the command prints the key ID and exits 0". +- Never invent a banner, a log line, a progress message, or a version string. If nobody has seen the software print it, it is not output. +- When real output is long, quote the lines the reader checks against and say plainly that the rest is omitted. When it varies per reader, replace the varying parts with `` and name what varies: timestamps, identifiers, host names, absolute paths. +- Repo paths such as `crates/registry-evidence/` and `products/evidence/` address a contributor with the repository checked out. An adopter has a terminal and a released binary. Keep repo paths out of reader-facing prose in tutorials, how-tos, and start pages: put them in an author-facing MDX comment, in a page whose reader is a contributor, or in a pinned link so a reader without a clone can still open the file. +- Paths the reader creates, edits, or passes on their own machine are not repo paths. Write those in full and say where they come from. + +## Procedures + +This applies to `tutorial` and `how-to` pages, and to any page that asks the reader to run something. + +- Give the reason for a step wherever the reason is not visible in the command itself. One clause carries it, and `because` is on the preferred side of the word list for this purpose. A reader who knows why a step exists can adapt it and recover from it. A reader who does not can only start over. +- A step that cannot be undone, or that forecloses an option the reader may want later, states what it forecloses in the same block as the command, not in a later section. Name the consequence in the reader's terms: "a key created with these settings can never leave this cluster, so losing the cluster loses the signing identity". +- Say what failure looks like wherever failure is plausible: what the reader sees, and the next move. A procedure that documents only the success path is half written, and the half it omits is the half the reader reads under pressure. +- Do not make the reader paste the project's own scaffolding. Guards, `exit 1`, assertions, and one-command-per-fence ceremony exist so a harness can extract and run a page; the reader is typing into their own shell, where `exit 1` closes it. Give the command the reader would type, and let the harness hold the scaffolding. +- Never quote scaffolding back as output. A guard's own `printf` is not what the software printed. ## Links @@ -129,7 +148,8 @@ Preferred terms. - External links: full URL. - Link text describes the target page. Do not write `click [here](...)` or `see [this page](...)`. - 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. +- A link earns its place when the reader would otherwise have to search for the target. A paragraph so dense with links that it cannot be read aloud is a list that has not admitted it yet. +- Link into another page's section when the reader wants that section and not the page: a procedure they were sent to perform, a definition they were sent to check. Link to the page itself when they need its context to make sense of the part. `check-built-links` resolves every fragment against the built page, so a renamed heading fails the build rather than dropping the reader silently at the top. - Link to upstream standards bodies first, then to mirrors or summaries. - 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. @@ -143,10 +163,11 @@ Preferred terms. ## Admonitions -- Use admonitions sparingly. A page with three admonitions usually has structural problems. - Allowed: `note`, `tip`, `caution`, `danger`. -- Never stack two admonitions in a row. -- `note` is for context the reader can skip without harm. `caution` and `danger` are for actions that lose data or expose secrets. +- Scarcity applies to `note` and `tip`. A page with three of those usually has structural problems: that context belongs in the prose. +- `caution` and `danger` are required, not rationed. Use one wherever an action loses data, exposes a secret, or cannot be undone, as many times as the page does those things. On a page about key custody or production cutover, carrying none is under-marking, not discipline. +- `note` is for context the reader can skip without harm. `caution` is for an action the reader can recover from with effort. `danger` is for one the reader cannot recover from at all. +- Never stack two admonitions in a row. Where two consecutive steps each need a warning, attach each warning to its own step rather than merging them into one paragraph that covers neither precisely. - Do not put an admonition immediately under H1. The lead paragraph carries the framing. ## Images and diagrams @@ -206,7 +227,6 @@ This applies to every page that touches a standard or a contract. - No real user data, real production hostnames, or real tokens, even in `example` blocks. - 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. ## Rules from GitLab we adopt verbatim @@ -217,7 +237,6 @@ This applies to every page that touches a standard or a contract. - New sentence, new line. - Spell out acronyms on first use. - Banned word list (see above). -- Admonitions are rare and never consecutive. - No emojis in the rendered output. - Generated content names its source and its regeneration command. @@ -227,6 +246,7 @@ This applies to every page that touches a standard or a contract. - **Issue links.** GitLab writes `[issue 12345](url)`. We write `[GH#123](url)` for GitHub and link to the actual issue title in text. - **Screenshot rules.** GitLab requires PNG, 1000×500, ≤100 KB, with red `#EE2604` callout arrows. We use SVG for diagrams and use screenshots rarely. - **Tabs and collapsible panels.** GitLab uses Hugo shortcodes. We do not use tabs in v0. If a page needs tabs, it is probably two pages. +- **Admonition scarcity.** GitLab keeps admonitions rare across all four types. Registry Docs keeps `note` and `tip` rare and requires `caution` or `danger` at every action that loses data, exposes a secret, or cannot be undone. Consecutive admonitions stay disallowed. See the Admonitions section. ## Rules from GitLab we skip @@ -246,6 +266,7 @@ This applies to every page that touches a standard or a contract. but remains disabled until frontmatter and technical terms are fully covered. Vale suggestions and warnings run in CI so style drift is visible before v0 ships. - **Link check** runs in CI. +- **Tutorial gates** run the commands a tutorial documents, in a clean container, and fail when a documented command stops working. They prove the procedure, not the prose: they do not parse sentences, count sections, or require a page to keep a particular wording. Rewording a step, adding its reason, or adding a recovery path cannot break them, so edit wording freely and let the gate check the commands. - **Astro build** and **Redocly lint** must pass. - **Standards register validation** asserts that every `current` standards entry has an `official_url`, a `claim_level`, a `used_by` list, and at least one `evidence_docs` link. @@ -254,6 +275,11 @@ This applies to every page that touches a standard or a contract. Two reviews per change: 1. **Technical correctness review.** Does the page agree with the source repo at the cited commit? Are claim levels defensible? Are generated tables in sync with their data files? -2. **Writing review.** Does the page open with a clear lead? Are headings descriptive? Is the page scannable? Are banned words gone? +2. **Writing review.** Read the page as the reader it was written for, with nothing else open, then answer: + 1. Could that reader finish the task with only what this page gives them, and tell success from failure when they get there? If they would have to guess at either, the page is not finished. + 2. Does every step whose reason is not obvious give its reason, and does every step that forecloses something say what it forecloses? + 3. Is every transcript on the page one that somebody ran and read? + 4. Does the page read as written to a person, or assembled against a checklist? Second person through the whole procedure, cause where there is cause, a plain sentence where a fragment would hide the point. + 5. Do the mechanics hold: clear lead, descriptive headings, banned words gone, scannable where scanning is what the reader is doing? A page is `current` only after both reviews pass and `last_reviewed` is bumped. diff --git a/docs/site/ec.config.mjs b/docs/site/ec.config.mjs new file mode 100644 index 000000000..9dad31326 --- /dev/null +++ b/docs/site/ec.config.mjs @@ -0,0 +1,20 @@ +import { defineEcConfig } from '@astrojs/starlight/expressive-code'; + +// Expressive Code is configured here rather than through Starlight's +// `expressiveCode` option because the `starlight-openapi` plugin replaces that +// option wholesale: its `config:setup` hook builds a fresh object, reads +// `expressiveCode` off that empty object instead of off the user config, and +// hands `{ removeUnusedThemes: false }` to `updateConfig`, which shallow-merges +// it over the real settings. A config file is merged separately by +// astro-expressive-code and survives. +export default defineEcConfig({ + shiki: { + // Shiki ships no Rhai grammar, so every Rhai block rendered as flat + // unhighlighted text beside fully coloured YAML and shell blocks on the + // same page, which reads as a broken code block. Rhai borrows Rust's + // surface syntax (`fn`, `let`, `//`, the same string and number literals), + // so the Rust grammar colours it correctly; only the `#{ }` map literal + // falls back to plain text. + langAlias: { rhai: 'rust' }, + }, +}); diff --git a/docs/site/scripts/check-built-accessibility.mjs b/docs/site/scripts/check-built-accessibility.mjs index 440320002..3c59e625f 100644 --- a/docs/site/scripts/check-built-accessibility.mjs +++ b/docs/site/scripts/check-built-accessibility.mjs @@ -16,7 +16,7 @@ const criticalPaths = [ 'generated-artifacts/index.html', 'operate/index.html', ]; -const optionalCriticalPaths = ['start/quickstart/index.html']; +const optionalCriticalPaths = []; async function exists(path) { try { diff --git a/docs/site/scripts/check-built-accessibility.test.mjs b/docs/site/scripts/check-built-accessibility.test.mjs index 6e29204d9..528216224 100644 --- a/docs/site/scripts/check-built-accessibility.test.mjs +++ b/docs/site/scripts/check-built-accessibility.test.mjs @@ -11,7 +11,6 @@ const checker = resolve(here, 'check-built-accessibility.mjs'); const criticalPaths = [ 'index.html', 'start/when-to-use/index.html', - 'start/quickstart/index.html', 'tutorials/publish-governed-sqlite-registry/index.html', 'verify/index.html', 'generated-artifacts/index.html', diff --git a/docs/site/scripts/check-discovery-tutorial.sh b/docs/site/scripts/check-discovery-tutorial.sh old mode 100644 new mode 100755 index 0916902c0..fcc0a7d79 --- a/docs/site/scripts/check-discovery-tutorial.sh +++ b/docs/site/scripts/check-discovery-tutorial.sh @@ -1,11 +1,31 @@ #!/usr/bin/env bash +# +# Run the Discovery adopter tutorial's journey and check what it did. +# +# What this gate is for: proving the adopter journey the page documents still +# runs end to end, and that its transcript still shows the behaviour a +# successful exit does not already prove. Content-addressed revisions that are +# still reproducible, a consumer that still resolves and selects, handoffs that +# still verify against adopter-owned trust. +# +# What this gate is NOT for: policing what the page says. It pins no fence +# count and no page wording. Prose, roles, headings, output blocks and the +# order they appear in are free to change without touching this file. The one +# thing the page owes this gate is naming the same command the gate runs, +# because unlike the Evidence tutorial gate this one does not replay the page's +# own fences: it runs the product's adopter runner directly, and if the page +# stopped pointing at that runner the two would drift apart in silence. +# +# If you find yourself adding an array of strings the page must contain, stop. +# That is the pinning this file deliberately does not do. set -euo pipefail site_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) repository=$(cd "$site_root/../.." && pwd) -tutorial="$site_root/src/content/docs/tutorials/publish-and-consume-discovery-index.mdx" +tutorial="${DISCOVERY_TUTORIAL_PAGE:-$site_root/src/content/docs/tutorials/publish-and-consume-discovery-index.mdx}" product_runner="$repository/products/discovery/scripts/test-adopter-tutorial.sh" -expected_shell_fences=5 +# The command the page hands the reader, which is the command this gate runs. +documented_command='bash products/discovery/scripts/test-adopter-tutorial.sh' dry_run=0 case "${1:-}" in @@ -24,40 +44,18 @@ for path in "$tutorial" "$product_runner"; do fi done -shell_fences=$(awk '/^```sh[[:space:]]*$/ { count += 1 } END { print count + 0 }' "$tutorial") -if ((shell_fences != expected_shell_fences)); then - printf 'Discovery tutorial drift: expected %d shell fences, found %d\n' \ - "$expected_shell_fences" "$shell_fences" >&2 +if ! grep -Fq -- "$documented_command" "$tutorial"; then + printf 'Discovery tutorial drift: the page no longer documents the command this gate runs\n' >&2 + printf 'This gate runs the product adopter runner directly rather than replaying the page,\n' >&2 + printf 'so the page must still hand the reader: %s\n' "$documented_command" >&2 exit 1 fi -required_literals=( - 'persona:' - ' - assertion provider' - ' - data publisher' - ' - operator' - ' - consumer or verifier' - 'products/discovery/tutorial/project/origins.yaml' - 'products/discovery/tutorial/project/mappings/adult-status.yaml' - 'bash products/discovery/scripts/test-adopter-tutorial.sh' - '[operator] offline check: valid origins=2 mappings=1' - '[operator] readiness: {"status":"ready"}' - '[consumer] resolved evidenceType=urn:example:evidence-type:adult-status alternatives=1' - '[handoff] adopter-owned Evidence trust accepted; native assertion verified' - '[handoff] adopter-owned Relay trust accepted; native list response verified' - '[cleanup] local services stopped; temporary project removed' - 'Tier-C evidence:' -) -for literal in "${required_literals[@]}"; do - if ! grep -Fq -- "$literal" "$tutorial"; then - printf 'Discovery tutorial drift: missing literal: %s\n' "$literal" >&2 - exit 1 - fi -done - -printf 'Discovery tutorial dry-run: %d shell fences and required role/output literals present\n' \ +shell_fences=$(awk '/^```sh[[:space:]]*$/ { count += 1 } END { print count + 0 }' "$tutorial") +printf 'Discovery tutorial dry-run: %d shell fences, running the documented adopter runner\n' \ "$shell_fences" if ((dry_run)); then + printf '%s\n' 'Discovery tutorial reader gate: dry run only' exit 0 fi @@ -72,19 +70,24 @@ if ! (cd "$repository" && bash "$product_runner") | tee "$transcript"; then exit 1 fi +# Behaviour the runner's own exit status does not prove. The published +# documents are content addressed, so their digests and the revisions built +# from them are the assertion that the journey is still reproducible, and the +# consumer and handoff lines are the assertion that something was actually +# resolved, selected and verified rather than skipped. One test decides +# membership here: would this regress silently, without the runner exiting +# non-zero? Do not add anything the page merely says. expected_output=( '[provider] evidence.jsonld sha256=fc96f3a8cb0d82239425ea5712dceca975a5899e5528616648174da661fae905' '[provider] relay.jsonld sha256=5a34fa469803b7c28b3d5e7134a42398e326a2f173aacae9090d29787bc8f4d7' '[operator] offline check: valid origins=2 mappings=1' '[operator] explicit build: built catalogRevision=sha256:b4b7195f36691c245bf49a88a248049ed899c0c41dbf1a87a386571c0dbfba0f mappingRevision=sha256:332004ca3920c498539180946e8f2637e9998ba7e49cd98f31e19d6f818857ac' - '[operator] readiness: {"status":"ready"}' '[consumer] resolved evidenceType=urn:example:evidence-type:adult-status alternatives=1' '[consumer] selected evidence recordId=urn:registrystack:discovery:record:sha256:676659c10ce5cc9d353f4fd2816673c7947e612151efbbe1cc4d42372d9be9d5' '[consumer] selected relay recordId=urn:registrystack:discovery:record:sha256:aa220c11f493c266bc22adf5dc7ca82fb7a83842e6e887dc0e8e5680f4f84244' '[handoff] adopter-owned Evidence trust accepted; native assertion verified' '[handoff] adopter-owned Relay trust accepted; native list response verified' '[cleanup] local services stopped; temporary project removed' - 'Registry Discovery adopter tutorial: PASS' ) for expected in "${expected_output[@]}"; do if ! grep -Fq -- "$expected" "$transcript"; then diff --git a/docs/site/scripts/check-discovery-tutorial.test.mjs b/docs/site/scripts/check-discovery-tutorial.test.mjs index b8bef6b12..ee372e980 100644 --- a/docs/site/scripts/check-discovery-tutorial.test.mjs +++ b/docs/site/scripts/check-discovery-tutorial.test.mjs @@ -1,19 +1,61 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); +const siteRoot = new URL('..', import.meta.url); +const gatePath = fileURLToPath(new URL('check-discovery-tutorial.sh', import.meta.url)); +const pagePath = fileURLToPath( + new URL('../src/content/docs/tutorials/publish-and-consume-discovery-index.mdx', import.meta.url), +); -test('Discovery tutorial dry-run binds the documented reader journey', async () => { - const { stdout } = await execFileAsync( - 'bash', - ['scripts/check-discovery-tutorial.sh', '--dry-run'], - { cwd: new URL('..', import.meta.url), encoding: 'utf8' }, - ); +async function dryRun(env = {}) { + return execFileAsync('bash', ['scripts/check-discovery-tutorial.sh', '--dry-run'], { + cwd: siteRoot, + encoding: 'utf8', + env: { ...process.env, ...env }, + }); +} + +test('Discovery tutorial dry-run reports the reader journey it found', async () => { + const { stdout } = await dryRun(); + + // Registration only. Pinning the number here would rebuild the tripwire this + // gate deliberately dropped, one file further out. + assert.match(stdout, /Discovery tutorial dry-run: \d+ shell fences/u); + assert.match(stdout, /Discovery tutorial reader gate: dry run only/u); +}); + +test('a page that stops documenting the command this gate runs fails by name', async () => { + const page = await readFile(pagePath, 'utf8'); + const command = 'bash products/discovery/scripts/test-adopter-tutorial.sh'; + assert.ok(page.includes(command), 'fixture assumption: the page documents the runner command'); - assert.match( - stdout, - /Discovery tutorial dry-run: 5 shell fences and required role\/output literals present/u, + const dir = await mkdtemp(join(tmpdir(), 'discovery-tutorial-')); + const edited = join(dir, 'page.mdx'); + await writeFile(edited, page.replace(command, 'bash products/discovery/scripts/renamed.sh')); + + await assert.rejects( + () => dryRun({ DISCOVERY_TUTORIAL_PAGE: edited }), + (error) => { + assert.match(error.stderr, /the page no longer documents the command this gate runs/u); + assert.match(error.stderr, /bash products\/discovery\/scripts\/test-adopter-tutorial\.sh/u); + return true; + }, ); }); + +test('the gate pins neither a fence count nor page strings', async () => { + const source = await readFile(gatePath, 'utf8'); + + // The two mechanisms this gate dropped, and the reason it dropped them: they + // made the page answerable to the gate instead of to its reader. Keep the + // transcript assertions below them; do not grow a page-content array back. + assert.doesNotMatch(source, /expected_shell_fences/u); + assert.doesNotMatch(source, /required_literals/u); +}); diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index 32ceaf9f4..0a84679b9 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -2,6 +2,20 @@ # # Execute the current Evidence tutorials from a fresh reader directory. # +# What this gate is for: proving that the commands the tutorials document still +# run, and that a short list of behaviours a successful exit does not already +# prove still holds. A refusal that still refuses, tampering that is still +# caught, an audit entry that still records the disclosure it should. +# +# What this gate is NOT for: policing what a page says. It pins no fence count, +# no command string and no documented output. Prose, the text around a +# heading, output blocks and command wording are free to change without touching +# this file, and a writer may add or remove a command block under a heading the +# journey already runs with no change here at all. If you find yourself adding +# an array of strings a page must contain, stop: that is the pinning this file +# deliberately does not do, and it is what made these tutorials unreadable for +# a human reader once already. +# # This gate builds the Evidence toolset from the checked-out source unless # EVIDENCE_BIN, EVIDENCECTL_BIN, EVIDENCE_OID4VCI_BIN and MINT_BIN select exact # candidate or released bytes, then replays each registered tutorial's own @@ -10,37 +24,61 @@ # # Usage: # scripts/check-evidence-tutorials.sh replay every tutorial -# scripts/check-evidence-tutorials.sh --dry-run drift-check only +# scripts/check-evidence-tutorials.sh --dry-run resolve the journeys only # scripts/check-evidence-tutorials.sh --only one tutorial and its prerequisites # # Registering a tutorial means adding its slug to EVIDENCE_TUTORIALS and a -# branch to load_spec. Each spec pins: -# SPEC_FENCES how many sh fences the tutorial holds; bump it when you -# intentionally add or remove a documented command block -# SPEC_STEPS the reader journey, in order, which need not follow fence -# order: a tutorial that leaves one terminal in an earlier -# directory is replayed by running its fence first -# run:N or run:N-M execute those sh fences -# run-fails:N execute one sh fence the tutorial -# documents as refused, and require it -# to exit non-zero -# edit:H|lang|occ|H2|lang2|occ2|target -# apply a documented before/after fence -# pair to an existing file -# save:H|lang|occ|target -# write a documented non-shell fence to -# the file the reader is told to create -# background:N run a one-line sh fence the tutorial -# leaves running in a second terminal -# stop-background stop the most recently started -# background fence where the page says -# to press Ctrl+C -# wait-http:URL block until that URL answers -# python-client install the Python client from this -# checkout, standing in for the -# documented clone and build -# SPEC_LITERALS commands and outputs the tutorial must keep documenting -# SPEC_OUTPUTS lines the replay transcript must contain +# branch to load_spec. Each spec holds two things: +# +# SPEC_STEPS the reader journey, in order. It need not follow document +# order: a tutorial that leaves one terminal in an earlier +# directory is replayed by running its later fence first. +# Fences are addressed by the heading they sit under, never by +# position, so inserting a command block cannot silently move a +# step onto the wrong command. +# run: execute every sh fence under +# that heading, in document order +# run:| execute the nth sh fence under +# that heading +# run-fails:| execute one sh fence the page +# documents as refused, and +# require a non-zero exit +# background:| run a one-line sh fence the page +# leaves running in a second +# terminal +# stop-background stop the most recently started +# background fence, where the page +# says to press Ctrl+C +# save:H|lang|occ|target write a documented non-shell +# fence to the file the reader is +# told to create +# edit:H|lang|occ|H2|lang2|occ2|target +# apply a documented before/after +# fence pair to an existing file +# wait-http:URL block until that URL answers +# python-client install the Python client from +# this checkout, standing in for +# the documented clone and build +# fhir-mock start the sanitized local FHIR +# mock this gate carries +# track-pid:PATH adopt a PID a fence wrote, so +# cleanup reaches it +# The | suffix is optional wherever a heading holds a single +# sh fence. Skipping is implicit: a fence under no listed +# heading is simply not run, and the summary names it so a +# reviewer can see the unverified surface. +# +# SPEC_ASSERTS behaviours the replay transcript must still show. One test +# decides membership: would this regress silently, without any +# command exiting non-zero? Startup chatter, "created", "ready" +# and "prepared" lines fail that test, because the next command +# would have failed without them. Do not grow this back into a +# transcript pin. +# +# Renaming a heading breaks the steps that name it, by name, in --dry-run. +# That is the trade, and it is a good one: a renamed heading is a structural +# edit to the journey, it fails loudly rather than replaying the wrong command, +# and it is exactly when the journey is worth walking again. # # Configuration: # EVIDENCE_BIN / EVIDENCECTL_BIN / run these exact binaries instead of @@ -153,367 +191,255 @@ check_tutorial_coverage() { check_tutorial_coverage load_spec() { - SPEC_FENCES=0 SPEC_STEPS=() - SPEC_LITERALS=() - SPEC_OUTPUTS=() + SPEC_ASSERTS=() case "$1" in first-evidence-assertion) - SPEC_FENCES=21 SPEC_STEPS=( - "run:2" + "run:Preview a synthetic source|1" "save:Preview a synthetic source|yaml|1|tutorial-source.openapi.yaml" - "background:3" + "background:Preview a synthetic source|2" "wait-http:http://127.0.0.1:4010/people/person-123" - "run:4" + "run:Preview a synthetic source|3" "stop-background" - "run:5-6" + "run:Create the Evidence Gateway project" + "run:Keep exact cases for the tutorial|1" "save:Create the Evidence Gateway project|yaml|1|questions/adult-status.yaml" "save:Create the Evidence Gateway project|rhai|1|derivations/adult-status.rhai" "save:Keep exact cases for the tutorial|yaml|1|mocks/source.yaml" "save:Keep exact cases for the tutorial|json|1|mocks/cases/person-123.json" "save:Keep exact cases for the tutorial|json|2|mocks/cases/person-456.json" "save:Keep exact cases for the tutorial|json|3|mocks/cases/person-789.json" - "run:7" - "background:8" + "run:Keep exact cases for the tutorial|2" + "background:Keep exact cases for the tutorial|3" "wait-http:http://127.0.0.1:4010/people/person-123" - "run:9-20" - ) - SPEC_LITERALS=( - "releases/latest/download/evidencectl-install.sh | bash" - "evidencectl source mock serve --openapi tutorial-source.openapi.yaml" - "evidencectl source mock check --config mocks/source.yaml" - "evidencectl source mock serve --config mocks/source.yaml" - "evidencectl new adult-status" - "evidencectl request prepare adult-status" - "--config .evidence/requests/first-assertion/authorization.curl" - "evidencectl verify assertion.jws.json" - "--format sd-jwt-vc" - "--config .evidence/requests/first-vc/authorization.curl" - "evidencectl verify assertion.sd-jwt" - "evidencectl audit show --last-operation" - "evidencectl dev clean" + "run:Keep exact cases for the tutorial|4" + "run:Request an assertion" + "run:Verify before reading" + "run:Try the SD-JWT VC serialization" + "run:Stop the local services" + "run:Inspect the audit entry" + "run:Clean up" ) - SPEC_OUTPUTS=( - "Source mock ready: mode=ephemeral origin=http://127.0.0.1:4010" - "Mock plan valid: operations=1 cases=3" - "Source mock ready: mode=materialized origin=http://127.0.0.1:4010" - "Created an editable OpenAPI authoring project in adult-status" - "Evidence ready at http://127.0.0.1:8080" - "Prepared request: .evidence/requests/first-assertion/request.json" - "Prepared request: .evidence/requests/first-vc/request.json" + # The assertion was verified, the audit recorded who asked and why, and + # exactly one field was released. Nothing else here regresses in + # silence: a mock that did not start or a project that was not created + # ends the journey at the next command. + SPEC_ASSERTS=( "VERIFIED" - "Local Evidence stopped" "ACCESS AUTHORIZED adult-status age-check requester=" "DISCLOSURE RELEASED is_adult" - "Removed stopped local Evidence state" ) ;; request-evidence-as-sd-jwt-vc) - SPEC_FENCES=16 SPEC_STEPS=( - "background:1" + "background:Restart the source mock|1" "wait-http:http://127.0.0.1:4010/people/person-123" - "run:2-10" + "run:Restart the source mock|2" + "run:Request a scalar credential" + "run:Inspect the compact structure after verification" + "run:Inspect issuer discovery" + "run:Prove tampering is refused|1" + "run-fails:Prove tampering is refused|2" "save:Model independently disclosed fields|yaml|1|schemas/adult-assessment.yaml" "save:Model independently disclosed fields|yaml|2|questions/adult-assessment.yaml" "save:Model independently disclosed fields|rhai|1|derivations/adult-assessment.rhai" - "run:11-16" + "run:Model independently disclosed fields" + "run:Clean up" ) - SPEC_LITERALS=( - "responseFormats: [signed-jws, sd-jwt-vc]" - "--format sd-jwt-vc" - "--header 'Accept: application/dc+sd-jwt'" - "/.well-known/jwt-vc-issuer" - "scalar-tampered.sd-jwt" - "type: reviewed-structured-value" - "sdJwtVc:" - "evidencectl verify structured.sd-jwt" - ) - SPEC_OUTPUTS=( - "Evidence ready at http://127.0.0.1:8080" - "Prepared request: .evidence/requests/scalar-vc/request.json" + # The disclosure names are what a holder actually hands over, and the + # fences that print them exit zero whatever the credential carries, so a + # credential that started disclosing more would pass unnoticed. + SPEC_ASSERTS=( "disclosure: urn:registrystack:evidence:local:concept:adult-status:is_adult" - "Tampered credential refused" - "Prepared request: .evidence/requests/structured-vc/request.json" + "evidencectl: Evidence response verification failed" "disclosure: criterion" "disclosure: isAdult" "ACCESS AUTHORIZED adult-assessment age-assessment-review requester=" "DISCLOSURE RELEASED adult_assessment" - "Removed stopped local Evidence state" ) ;; run-oid4vci-interoperability-checks) - SPEC_FENCES=4 SPEC_STEPS=( - "run:1" + "run:Copy the complete configuration" "save:Copy the complete configuration|yaml|1|.tutorial/oid4vci-adopter/oid4vci.yaml" - "run:2" - "run:4" - ) - SPEC_LITERALS=( - 'actual `evidence-oid4vci` binary' - 'against the copied file, runs `inspect`' - 'probes `/health` and `/ready`' - "EVIDENCE_OID4VCI_ADOPTER_ROOT=\"\$PWD/.tutorial/oid4vci-adopter\"" - "products/evidence/fixtures/interoperability/inji-oid4vci/profile.json" - "products/evidence/scripts/compat/inji-oid4vci.sh" - "PASS: sanitized Inji OID4VCI profile and Registry-side interoperability tests" - "EVIDENCE_INJI_OID4VCI=1 products/evidence/scripts/compat/inji-oid4vci-upstream.sh" - "PASS: pinned Inji OID4VCI source and client tests" - "combined upstream runner is macOS-only" - "Pinned Inji OID4VCI checking needs Java 17; the installed runtime is not Java 17." - "Pinned Inji OID4VCI checking needs ANDROID_HOME or ANDROID_SDK_ROOT to name an installed Android SDK." - "Pinned Inji OID4VCI checking needs Android SDK platform 34 and Build Tools 33.0.1." - "Pinned Inji OID4VCI checking needs full Xcode, not Command Line Tools alone." - "Pinned Inji OID4VCI checking could not inspect installed iOS simulators." - "Pinned Inji OID4VCI checking needs an available iPhone 15 simulator." - "2fa12c3285b6523db340c3dd2333454b750b40a4" - "f1d7ee2b14e996e18bfc7c40fbf89ec31b768951" - "dbe60eef9a8c7b71ba58ee81cc7d0e5a92af7c7c" + "run:Replay the sanitized profile" + "run:Clean up" ) - SPEC_OUTPUTS=( - "CONFIG COPIED: complete configuration has no untracked inputs" - "CONFIG CHECKED: complete delivery configuration is valid" - "METADATA INSPECTED: derived holder-bound batch ceiling is 4" - "SERVICE READY: health and readiness are available on the delivery listener" - "METRICS PRIVATE: metrics exist only on the separate loopback listener" + # The sanitized runner prints one line per phase and exits non-zero on + # any of them, so the phases hold themselves up. What they cannot hold + # up is having run at all: a filter that selects no test leaves the + # runner exiting zero with nothing done. One end-to-end line proves the + # wallet flow ran; the rest would be a transcript pin. + SPEC_ASSERTS=( "PRESENTATION VERIFIED: public wallet flow returned holder-bound Evidence" - "CLEANUP COMPLETE: generated private material was removed" - "PASS: sanitized Inji OID4VCI profile and Registry-side interoperability tests" ) ;; return-a-governed-value) - SPEC_FENCES=10 SPEC_STEPS=( - "background:1" + "background:Restart the source mock" "wait-http:http://127.0.0.1:4010/people/person-123" - "run:2" + "run:Add the age-bracket question" "save:Add the age-bracket question|yaml|1|questions/age-bracket.yaml" "save:Add the age-bracket question|rhai|1|derivations/age-bracket.rhai" - "run:3-10" + "run:Start the updated project" + "run:Request and verify the bracket" + "run:Inspect the audit and clean up" ) - SPEC_LITERALS=( - "type: controlled-category" - "values: [under-18, 18-to-24, 25-to-64, 65-or-older]" - "evidencectl request prepare age-bracket" - "--config .evidence/requests/age-bracket/authorization.curl" - "evidencectl verify age-bracket.jws.json" - "evidencectl audit show --last-operation" - "evidencectl dev clean" - ) - SPEC_OUTPUTS=( - "Evidence ready at http://127.0.0.1:8080" - "Prepared request: .evidence/requests/age-bracket/request.json" + SPEC_ASSERTS=( "VERIFIED" - "Local Evidence stopped" "ACCESS AUTHORIZED age-bracket service-path-selection requester=" "DISCLOSURE RELEASED age_bracket" - "Removed stopped local Evidence state" ) ;; control-who-can-request-evidence) - SPEC_FENCES=20 SPEC_STEPS=( - "background:1" + "background:Restart the source mock|1" "wait-http:http://127.0.0.1:4010/people/person-123" - "run:2-20" - ) - SPEC_LITERALS=( - "evidencectl access policy add age-checks --question adult-status" - "evidencectl access client add service-router" - "--config .evidence/requests/age-checker-refused/authorization.curl" - "--data-binary @.evidence/requests/age-checker-refused/request.json" - "evidencectl access client revoke age-checker" - "unexpected request preparation success" - "ACCESS REFUSED requester= reason=not_authorized" + "run:Restart the source mock|2" + "run:Define two access policies" + "run:Register the first local application" + "run:Start the protected service" + "run:Make an allowed request" + "run:Add an application without restarting" + "run:Use the application assigned the policy" + "run:Try a question the application was not granted" + "run:Revoke an application|1" + "run-fails:Revoke an application|2" + "run:Inspect the final audit operation" + "run:Clean up" ) - SPEC_OUTPUTS=( - "Evidence ready at http://127.0.0.1:8080" - "Added access policy age-checks for adult-status." - "Added client service-router with policy service-routing." - "Prepared request: .evidence/requests/age-checker-refused/request.json" - "Prepared request: .evidence/requests/service-router-allowed/request.json" + # This tutorial teaches refusal, so the refusals are what must hold. + # The unauthorized request's curl carries no --fail-with-body, so it + # exits zero on a 403 and a boundary that started answering 200 would + # leave the journey green. The revocation step requires a non-zero + # exit; the message is what proves it was refused because the + # client was revoked rather than for some unrelated reason. + SPEC_ASSERTS=( + "VERIFIED" "HTTP 403" '"code": "evidence.denied"' - "HTTP 200" - "VERIFIED" "evidencectl: unknown or revoked active client age-checker" - "Local Evidence stopped" "ACCESS REFUSED requester=" "reason=not_authorized" - "Removed stopped local Evidence state" ) ;; assert-a-role-bound-relationship) - SPEC_FENCES=9 SPEC_STEPS=( - "run:1" + "run:Start a relationship registry|1" "save:Start a relationship registry|python|1|registry.py" - "background:2" + "background:Start a relationship registry|2" "wait-http:http://127.0.0.1:8002/openapi.json" - "run:3" + "run:Create the Evidence Gateway project" "save:Create the Evidence Gateway project|yaml|1|questions/parent-relationship.yaml" "save:Create the Evidence Gateway project|rhai|1|derivations/parent-relationship.rhai" - "run:4-9" + "run:Start the project" + "run:Bind both subjects to the request" + "run:Inspect the audit and clean up" ) - SPEC_LITERALS=( - "subjects:" - "--subject child:child_id=child-123" - "--subject candidate-parent:candidate_id=parent-456" - "--config .evidence/requests/parent-relationship/authorization.curl" - "evidencectl verify parent-relationship.jws.json" - "evidencectl dev clean" - ) - SPEC_OUTPUTS=( - "Created an editable OpenAPI authoring project in parent-relationship" - "Evidence ready at http://127.0.0.1:8080" - "Prepared request: .evidence/requests/parent-relationship/request.json" + SPEC_ASSERTS=( "VERIFIED" - "Local Evidence stopped" "ACCESS AUTHORIZED parent-relationship relationship-check requester=" "DISCLOSURE RELEASED relationship_confirmed" - "Removed stopped local Evidence state" ) ;; refuse-unsafe-evidence-requests) - SPEC_FENCES=11 SPEC_STEPS=( - "background:1" + "background:Restart the local boundary|1" "wait-http:http://127.0.0.1:4010/people/person-123" - "run:2-11" - ) - SPEC_LITERALS=( - 'request["purpose"] = "age-check"' - "--data-binary @unauthorized-request.json" - "--write-out 'HTTP %{http_code}\\n'" - "--data-binary @.evidence/requests/refusal-check/request.json" - "evidencectl verify tampered-response.jws.json" - "test ! -e tampered-response.verified.json" + "run:Restart the local boundary|2" + "run:Prepare one authorized request" + "run:Change the purpose after preparation" + "run:Obtain and verify the authorized response" + "run:Change the signed response|1" + "run-fails:Change the signed response|2" + "run:Clean up" ) - SPEC_OUTPUTS=( - "Evidence ready at http://127.0.0.1:8080" - "Prepared request: .evidence/requests/refusal-check/request.json" + # The whole page is these three outcomes: the altered request was + # refused, the untouched one verified, and the altered response was + # caught. The refusal curl exits zero on a 403, so only the printed + # status separates a boundary that refused from one that answered. + SPEC_ASSERTS=( "HTTP 403" "VERIFIED" - "TAMPER REFUSED" - "Local Evidence stopped" + "evidencectl: Evidence response verification failed" ) ;; verify-an-assertion-as-a-consumer) - SPEC_FENCES=3 - SPEC_STEPS=("run:1-3") - SPEC_LITERALS=( - 'context["trustedJwks"]' - 'context["verificationPolicy"]' - ".evidence/requests/first-assertion/verification.json" - "--jws assertion.jws.json" - "--jwks trusted-issuer-keys.json" - "--policy verification-policy.json" - '--at "$verified_at"' + SPEC_STEPS=( + "run:Start with three separate inputs" + "run:Re-verify the recorded decision" ) - SPEC_OUTPUTS=( - "authentic: yes" - "currently-valid: yes" + # `evidence verify` exits non-zero on both `authentic: no` and + # `currently-valid: no`, so the verdict holds itself up. The disclosed + # value does not: verification succeeds whatever the assertion says, and + # a consumer reading the wrong answer is the failure that matters. + SPEC_ASSERTS=( '"value": true' ) ;; request-evidence-from-an-application) - SPEC_FENCES=16 SPEC_STEPS=( # The registry runs in the terminal the reader never moved out of - # the first tutorial's directory, so it starts before fence 1's - # `cd` rather than where the page prints it. - "background:7" + # the first tutorial's directory, so it starts before the `cd` the + # page opens with rather than where the page prints it. + "background:Start the local services|1" "wait-http:http://127.0.0.1:4010/people/person-123" - "run:1-4" - # Stands in for fences 5 and 6, the documented clone and build. + "run:Give the application its own identity" + "run:Pin the keys your application trusts" + # Stands in for the two fences under "Build the Python client", the + # documented clone and build of the released client. "python-client" - "run:8" - "run-fails:9" - "run:10-11" + "run:Start the local services|2" + "run-fails:Start the local services|3" + "run:Read the definitions once" + "run:Pin the procedure" "save:Write the relying procedure|python|1|age_check.py" - "run:12-14" - "run-fails:15" - "run:16" + "run:Run it" + "run-fails:Refuse before reading" + "run:Stop the local services" ) - SPEC_LITERALS=( - "evidencectl access policy add app-age-checks --question adult-status" - "evidencectl access client add age-check-app" - "--generate-local-key" - "evidencectl jwks --out trusted-issuer-keys.json secrets/signing-p256-public.jwk.json" - 'git clone --depth 1 --branch "v$installed"' - "-p registry-evidence-client-py --lib" - "--features registry-evidence-client-py/extension-module" - 'cp "../registry-stack/target/debug/$built" python-module/registry_evidence_client.so' - '"private_key_jwt"' - 'Path(".evidence/clients/age-check-app/private.jwk").read_text()' - "client.request_and_verify(client.prepare(spec))" - "subject_expectations=expectations_for(person_id)" - ) - SPEC_OUTPUTS=( - "Source mock ready: mode=materialized origin=http://127.0.0.1:4010" - "Added access policy app-age-checks for adult-status." - "Added client age-check-app with policy app-age-checks." - "evidenceAudience: urn:registrystack:evidence:local:client:age-check-app" - "wrote trusted-issuer-keys.json" - "Evidence ready at http://127.0.0.1:8080" - "Mint ready at http://127.0.0.1:8081" + # What the relying application actually did. Both refusals already + # exit non-zero, so what is held here is the reason: an unnamed caller + # refused for want of a registered client, and an unverifiable response + # refused before anything was read. The two answers prove the right + # subject was resolved rather than a constant returned, the pinning line + # proves the subject binding is still recorded, and the assurance + # profile is the trust level a relying party reads off the deployment. + SPEC_ASSERTS=( "evidencectl: the active project requires a registered client selected with --client" '"assuranceProfile": "local"' - '"response_format": "signed-jws"' "person-123 is_adult=True" "person-456 is_adult=False" "pinned binding recorded in subject-bindings.json" "unverifiable response, nothing read (policy)" - "Local Evidence stopped" - "Removed stopped local Evidence state" ) ;; issue-fhir-evidence-as-vcs) - SPEC_FENCES=10 SPEC_STEPS=( - "run:1" + "run:Select live synthetic records|1" "save:Select live synthetic records|python|1|discover-fhir-records.py" "fhir-mock" - "run:2" + "run:Select live synthetic records|2" "save:Run a live FHIR read-through adapter|python|1|fhir-read-through.py" - "run:3" + "run:Run a live FHIR read-through adapter" "track-pid:fhir-read-through.pid" "save:Describe the exact FHIR reads|yaml|1|fhir-smart-r4.openapi.yaml" - "run:4" + "run:Describe the exact FHIR reads" "save:Author the patient coverage question|yaml|1|questions/fhir-coverage-status.yaml" "save:Author the patient coverage question|rhai|1|derivations/fhir-coverage-status.rhai" "save:Author the healthcare-establishment question|yaml|1|questions/fhir-healthcare-establishment.yaml" "save:Author the healthcare-establishment question|rhai|1|derivations/fhir-healthcare-establishment.rhai" - "run:5-10" - ) - SPEC_LITERALS=( - 'FHIR_TUTORIAL_TEST_BASE_URL' - 'build_opener(ProxyHandler({}), NoRedirect)' - 'headers={"Accept": "application/fhir+json"}' - 'source: true' - '--subjects-file ../fhir-coverage-subjects.json' - '--subjects-file ../fhir-organization-subjects.json' - "--header 'Accept: application/dc+sd-jwt'" - 'evidencectl audit show --last-operation' - 'evidencectl dev clean' + "run:Start the project" + "run:Request the patient coverage credential" + "run:Request the healthcare-establishment credential" + "run:Inspect the audit and clean up" ) - SPEC_OUTPUTS=( - "Coverage selector file: ready" - "Organization selector file: ready" - "Created an editable OpenAPI authoring project in fhir-record-evidence" - "Evidence ready at http://127.0.0.1:8080" - "Mint ready at http://127.0.0.1:8081" - "Prepared request: .evidence/requests/fhir-coverage-vc/request.json" - "Prepared request: .evidence/requests/fhir-healthcare-establishment-vc/request.json" - "HTTP 200" + SPEC_ASSERTS=( "VERIFIED" - "Local Evidence stopped" "ACCESS AUTHORIZED fhir-healthcare-establishment healthcare-establishment-verification requester=" "DISCLOSURE RELEASED healthcare_provider_record_active" - "Removed stopped local Evidence state" ) ;; *) @@ -644,8 +570,9 @@ prepare_toolset() { # gate: it needs the network, and it would prove a released client rather than # the one in this checkout. Building the same crate from here instead is what # makes a client regression fail this gate on the commit that introduces it. -# The documented commands stay pinned as SPEC_LITERALS, so an edit to them -# still has to be deliberate. +# The two documented fences it stands in for are reported as unexecuted, so +# their release tag and build flags stay a reviewer's call rather than this +# gate's. # The module is built for the stable ABI, so one built outside this script # imports under any CPython the replay userland carries, exactly as # EVIDENCE_CLIENT_PY_LIB's siblings let CI mount prebuilt binaries. @@ -686,21 +613,72 @@ prepare_python_client() { # Journey assembly # --------------------------------------------------------------------------- -# Emit the sh fences named by a run: step, in order. -emit_run_step() { - local slug="$1" range="$2" fence_dir="$3" - local first="${range%%-*}" - local last="${range##*-}" - local i fence - for ((i = first; i <= last; i++)); do - fence="$(printf '%s/fence-%02d.sh' "$fence_dir" "$i")" - if [[ ! -f "$fence" ]]; then - printf 'tutorial spec error in %s: run step names sh fence %d, which does not exist\n' \ - "$slug" "$i" >&2 +# Resolve a heading address to the sh fence numbers it names, in document +# order, space separated. +# +# An address is a heading, optionally followed by | to name one +# fence under it. Addressing by heading rather than by position is what lets a +# writer add or remove a command block without touching a spec, and it is what +# stops an inserted block from silently moving a later step onto the wrong +# command. +resolve_fences() { + local slug="$1" address="$2" fence_dir="$3" + local heading="$address" occurrence="" + if [[ "$address" == *'|'* ]]; then + heading="${address%%|*}" + occurrence="${address##*|}" + if [[ ! "$occurrence" =~ ^[1-9][0-9]*$ ]]; then + printf 'tutorial spec error in %s: fence occurrence must be a positive integer: %s\n' \ + "$slug" "$address" >&2 exit 2 fi - printf '\nprintf "==> %s fence %02d\\n"\n' "$slug" "$i" - cat "$fence" + fi + local matched + matched="$(awk -F '\t' -v want="$heading" -v want_occurrence="$occurrence" ' + $3 != want { next } + want_occurrence != "" && $2 != want_occurrence + 0 { next } + { printf "%s ", $1 } + ' "$fence_dir/index.tsv")" + matched="${matched% }" + if [[ -z "$matched" ]]; then + printf 'tutorial drift in %s: no sh fence answers to "%s"\n' "$slug" "$address" >&2 + printf 'A step names a heading the page no longer carries, or an occurrence under it that no longer exists.\n' >&2 + printf 'Renaming a heading is a structural edit to the journey; walk it again, then name the new heading in %s.\n' \ + "${BASH_SOURCE[0]}" >&2 + printf 'The page currently holds these sh fences:\n' >&2 + awk -F '\t' '{ printf " fence %s, occurrence %s under \"%s\"\n", $1, $2, $3 }' \ + "$fence_dir/index.tsv" >&2 + exit 1 + fi + printf '%s\n' "$matched" +} + +# Resolve a heading address that must name exactly one sh fence. +resolve_one_fence() { + local slug="$1" address="$2" fence_dir="$3" step_kind="$4" + local matched + matched="$(resolve_fences "$slug" "$address" "$fence_dir")" || exit $? + local -a numbers + read -r -a numbers <<<"$matched" + if ((${#numbers[@]} != 1)); then + printf 'tutorial spec error in %s: a %s step runs one fence, but "%s" names %d; add |\n' \ + "$slug" "$step_kind" "$address" "${#numbers[@]}" >&2 + exit 2 + fi + printf '%s\n' "${numbers[0]}" +} + +# Emit the sh fences named by a run: step, in document order. +emit_run_step() { + local slug="$1" address="$2" fence_dir="$3" + local matched + matched="$(resolve_fences "$slug" "$address" "$fence_dir")" || exit $? + local -a numbers + read -r -a numbers <<<"$matched" + local number + for number in "${numbers[@]}"; do + printf '\nprintf "==> %s fence %s\\n"\n' "$slug" "$number" + cat "$fence_dir/fence-$number.sh" done } @@ -717,21 +695,16 @@ emit_run_step() { # sees nor what the page documents. `set +e` around the run keeps the failure # from ending the journey, and reinstates errexit for the steps after it. emit_run_fails_step() { - local slug="$1" number="$2" fence_dir="$3" - local fence - fence="$(printf '%s/fence-%02d.sh' "$fence_dir" "$number")" - if [[ ! -f "$fence" ]]; then - printf 'tutorial spec error in %s: run-fails step names sh fence %s, which does not exist\n' \ - "$slug" "$number" >&2 - exit 2 - fi - printf '\nprintf "==> %s fence %02d (documented refusal)\\n"\n' "$slug" "$number" + local slug="$1" address="$2" fence_dir="$3" + local number + number="$(resolve_one_fence "$slug" "$address" "$fence_dir" run-fails)" || exit $? + printf '\nprintf "==> %s fence %s (documented refusal)\\n"\n' "$slug" "$number" printf 'set +e\n' printf '( set -e\n' - cat "$fence" + cat "$fence_dir/fence-$number.sh" printf ')\nrefusal_status=$?\nset -e\n' printf 'if ((refusal_status == 0))\nthen\n' - printf ' printf "tutorial drift in %s: fence %02d succeeded, but the page documents a refusal\\n" >&2\n' \ + printf ' printf "tutorial drift in %s: fence %s succeeded, but the page documents a refusal\\n" >&2\n' \ "$slug" "$number" printf ' exit 1\n' printf 'fi\n' @@ -800,17 +773,18 @@ emit_save_step() { # second terminal. CI runs that exact one-line command in the background and # retains its PID for cleanup. emit_background_step() { - local slug="$1" number="$2" fence_dir="$3" - local fence - fence="$(printf '%s/fence-%02d.sh' "$fence_dir" "$number")" - if [[ ! -f "$fence" ]] || [[ "$(wc -l <"$fence")" -ne 1 ]]; then - printf 'tutorial spec error in %s: background step needs one sh line at fence %s\n' \ - "$slug" "$number" >&2 + local slug="$1" address="$2" fence_dir="$3" + local number + number="$(resolve_one_fence "$slug" "$address" "$fence_dir" background)" || exit $? + local fence="$fence_dir/fence-$number.sh" + if [[ "$(wc -l <"$fence")" -ne 1 ]]; then + printf 'tutorial spec error in %s: a background step needs one sh line, but fence %s under "%s" holds more\n' \ + "$slug" "$number" "$address" >&2 exit 2 fi local command IFS= read -r command <"$fence" - printf '\nprintf "==> %s fence %02d (background)\\n"\n' "$slug" "$number" + printf '\nprintf "==> %s fence %s (background)\\n"\n' "$slug" "$number" printf '%s &\n' "$command" printf 'BACKGROUND_PIDS+=("$!")\n' } @@ -894,21 +868,70 @@ emit_journey() { done } -# How many sh fences a spec executes, for the summary line. -executed_fence_count() { - local step range first last total=0 +# Resolve every fence-addressing step into EXECUTED_FENCES, in step order. +# +# This runs before the replay and in --dry-run, so a heading a spec names but +# the page no longer carries fails by name in seconds, without a toolchain. +resolve_journey_fences() { + local slug="$1" fence_dir="$2" + EXECUTED_FENCES=() + local step matched number + local -a numbers for step in ${SPEC_STEPS[@]+"${SPEC_STEPS[@]}"}; do case "$step" in - run:*) - range="${step#run:}" - first="${range%%-*}" - last="${range##*-}" - total=$((total + last - first + 1)) + run:*) matched="$(resolve_fences "$slug" "${step#run:}" "$fence_dir")" || exit $? ;; + run-fails:*) + matched="$(resolve_one_fence "$slug" "${step#run-fails:}" "$fence_dir" run-fails)" || exit $? + ;; + background:*) + matched="$(resolve_one_fence "$slug" "${step#background:}" "$fence_dir" background)" || exit $? ;; - run-fails:* | background:*) total=$((total + 1)) ;; + *) continue ;; esac + read -r -a numbers <<<"$matched" + for number in "${numbers[@]}"; do + if ! in_list "$number" ${EXECUTED_FENCES[@]+"${EXECUTED_FENCES[@]}"}; then + EXECUTED_FENCES+=("$number") + fi + done + done +} + +# Name the sh fences the journey never runs. +# +# This is information for a reviewer, not a rule: an install one-liner or a +# recovery block a reader only reaches on a bad day is documented and +# unverified, and saying so is more use than pinning its text would be. +report_unexecuted_fences() { + local slug="$1" fence_dir="$2" + local number occurrence heading first_line + while IFS=$'\t' read -r number occurrence heading; do + if in_list "$number" ${EXECUTED_FENCES[@]+"${EXECUTED_FENCES[@]}"}; then + continue + fi + first_line="" + IFS= read -r first_line <"$fence_dir/fence-$number.sh" || true + printf ' not executed: fence %s under "%s": %s\n' "$number" "$heading" "$first_line" + done <"$fence_dir/index.tsv" +} + +# Hold the behaviours a successful exit does not already prove. +# +# Read the SPEC_ASSERTS note in the header before adding an entry here. This +# holds outcomes, never the transcript: a page is free to reword everything +# around the line, and the line itself is only here because losing it would +# leave the journey green. +assert_transcript() { + local slug="$1" run_log="$2" + local expected + for expected in ${SPEC_ASSERTS[@]+"${SPEC_ASSERTS[@]}"}; do + if ! grep -F -q -- "$expected" "$run_log"; then + printf 'tutorial behaviour drift in %s: the replay ran, but its transcript never showed "%s"\n' \ + "$slug" "$expected" >&2 + printf 'Every command exited zero, so this is the kind of regression only this assertion catches.\n' >&2 + exit 1 + fi done - printf '%d' "$total" } # The sanitized OID4VCI runner may fall back to Cargo when CI has not supplied @@ -951,34 +974,41 @@ for slug in "${EVIDENCE_TUTORIALS[@]}"; do exit 1 fi - # Extract every sh fence, in order, into numbered files. + # Extract every sh fence, in order, into numbered files, and index each one + # by the heading it sits under and its occurrence there. Heading + # attribution matches the fence helper the save and edit steps use, so one + # address means the same thing everywhere in a spec: a level-2 heading opens + # a section, and occurrences are counted per heading. fence_dir="$WORK_ROOT/fences/$slug" mkdir -p "$fence_dir" - fence_count="$(awk -v outdir="$fence_dir" ' - /^```sh$/ { infence = 1; count += 1; next } - infence && /^```$/ { infence = 0; next } - infence { print > (outdir "/fence-" sprintf("%02d", count) ".sh") } + : >"$fence_dir/index.tsv" + fence_count="$(awk -v outdir="$fence_dir" -v index_file="$fence_dir/index.tsv" ' + in_fence == 0 && /^##[ \t]+/ { + heading = $0 + sub(/^##[ \t]+/, "", heading) + sub(/[ \t]+$/, "", heading) + next + } + in_fence == 0 && /^```[A-Za-z0-9_-]+$/ { + in_fence = 1 + capture = ($0 == "```sh") + if (capture) { + count += 1 + occurrence[heading] += 1 + printf "%02d\t%d\t%s\n", count, occurrence[heading], heading > index_file + } + next + } + in_fence && /^```$/ { in_fence = 0; capture = 0; next } + in_fence && capture { print > (outdir "/fence-" sprintf("%02d", count) ".sh") } END { print count + 0 } ' "$tutorial_file")" - if [[ "$fence_count" -ne "$SPEC_FENCES" ]]; then - printf 'tutorial drift in %s: %s sh fences found, expected %s\n' \ - "$slug" "$fence_count" "$SPEC_FENCES" >&2 - printf 'Update SPEC_FENCES and SPEC_STEPS in %s when the change is intentional.\n' \ - "${BASH_SOURCE[0]}" >&2 - exit 1 - fi - - for literal in ${SPEC_LITERALS[@]+"${SPEC_LITERALS[@]}"}; do - if ! grep -F -q -- "$literal" "$tutorial_file"; then - printf 'tutorial drift in %s: required literal missing: %s\n' \ - "$slug" "$literal" >&2 - exit 1 - fi - done + resolve_journey_fences "$slug" "$fence_dir" - printf '%s: %s sh fences, %s executed, %s required literals present\n' \ - "$slug" "$fence_count" "$(executed_fence_count)" "${#SPEC_LITERALS[@]}" + printf '%s: %s sh fences, %s executed\n' \ + "$slug" "$fence_count" "${#EXECUTED_FENCES[@]}" + report_unexecuted_fences "$slug" "$fence_dir" if ((DRY_RUN)); then continue @@ -1049,13 +1079,7 @@ for slug in "${EVIDENCE_TUTORIALS[@]}"; do exit 1 fi - for expected in ${SPEC_OUTPUTS[@]+"${SPEC_OUTPUTS[@]}"}; do - if ! grep -F -q -- "$expected" "$run_log"; then - printf 'tutorial output drift in %s: expected "%s" in the transcript\n' \ - "$slug" "$expected" >&2 - exit 1 - fi - done + assert_transcript "$slug" "$run_log" done if ((${#EVIDENCE_TUTORIALS[@]} == 1)); then diff --git a/docs/site/scripts/check-evidence-tutorials.test.mjs b/docs/site/scripts/check-evidence-tutorials.test.mjs index 50247000d..23da49346 100644 --- a/docs/site/scripts/check-evidence-tutorials.test.mjs +++ b/docs/site/scripts/check-evidence-tutorials.test.mjs @@ -36,30 +36,39 @@ async function runShell(script) { } } -test('the dry-run gate registers the shared Evidence start tutorials', async () => { +// Counts are reported, never required: a writer who adds or removes a command +// block under an existing heading changes these numbers and neither the gate +// nor this test may object. Only the registration is asserted. +test('the dry-run gate resolves every registered Evidence tutorial', async () => { const { code, output } = await runGate(); assert.equal(code, 0, output); - assert.match(output, /first-evidence-assertion: 21 sh fences, 19 executed/u); - assert.match(output, /request-evidence-as-sd-jwt-vc: 16 sh fences, 16 executed/u); - assert.match( - output, - /run-oid4vci-interoperability-checks: 4 sh fences, 3 executed/u, - ); - // Two of its sixteen are the documented clone and build of the client, which - // the replay substitutes with a build of this checkout. - assert.match( - output, - /request-evidence-from-an-application: 16 sh fences, 14 executed/u, - ); - assert.match(output, /return-a-governed-value: 10 sh fences, 10 executed/u); - assert.match(output, /assert-a-role-bound-relationship: 9 sh fences, 9 executed/u); - assert.match(output, /refuse-unsafe-evidence-requests: 11 sh fences, 11 executed/u); - assert.match(output, /verify-an-assertion-as-a-consumer: 3 sh fences, 3 executed/u); - assert.match(output, /control-who-can-request-evidence: 20 sh fences, 20 executed/u); - assert.match(output, /issue-fhir-evidence-as-vcs: 10 sh fences, 10 executed/u); + for (const slug of [ + 'first-evidence-assertion', + 'request-evidence-as-sd-jwt-vc', + 'run-oid4vci-interoperability-checks', + 'request-evidence-from-an-application', + 'return-a-governed-value', + 'assert-a-role-bound-relationship', + 'refuse-unsafe-evidence-requests', + 'verify-an-assertion-as-a-consumer', + 'control-who-can-request-evidence', + 'issue-fhir-evidence-as-vcs', + ]) { + assert.match(output, new RegExp(`${slug}: \\d+ sh fences, \\d+ executed`, 'u')); + } assert.match(output, /Checked 10 tutorials\./u); }); +// The unexecuted surface is information a reviewer needs, not a rule: the +// install one-liner and the port-conflict recovery block are documented and +// never replayed, so the gate says so rather than pinning their text. +test('the gate names the sh fences it did not execute', async () => { + const { code, output } = await runGate({}, ['--dry-run', '--only', 'first-evidence-assertion']); + assert.equal(code, 0, output); + assert.match(output, /not executed: fence 01 under "Install Evidence Gateway"/u); + assert.match(output, /not executed: fence 21 under "If local ports are already in use"/u); +}); + test('--only accepts the current first Evidence tutorial', async () => { const { code, output } = await runGate({}, [ '--dry-run', @@ -72,8 +81,8 @@ test('--only accepts the current first Evidence tutorial', async () => { const branch = source.match(/\n\tfirst-evidence-assertion\)[\s\S]*?\n\t\t;;/u)?.[0]; assert.ok(branch, 'the first Evidence replay spec must exist'); assert.match(branch, /stop-background/u); - assert.match(branch, /run:5-6/u); - assert.match(branch, /source mock check --config mocks\/source\.yaml/u); + assert.match(branch, /run:Preview a synthetic source\|3/u); + assert.match(branch, /run:Try the SD-JWT VC serialization/u); }); test('--only accepts the role-bound relationship follow-up', async () => { @@ -141,7 +150,10 @@ test('the FHIR replay tracks the read-through adapter for cleanup', async () => /\n\tissue-fhir-evidence-as-vcs\)[\s\S]*?\n\t\t;;/u, )?.[0]; assert.ok(branch, 'the FHIR replay spec must exist'); - assert.match(branch, /"run:3"\s+"track-pid:fhir-read-through\.pid"/u); + assert.match( + branch, + /"run:Run a live FHIR read-through adapter"\s+"track-pid:fhir-read-through\.pid"/u, + ); assert.match(source, /track-pid:\*\) emit_track_pid_step/u); assert.match(source, /BACKGROUND_PIDS\+=\("\$tracked_pid"\)/u); }); @@ -181,8 +193,8 @@ test('the application tutorial replays the Python client from this checkout', as )?.[0]; assert.ok(branch, 'the application replay spec must exist'); assert.match(branch, /"python-client"/u); - assert.match(branch, /"private_key_jwt"/u); assert.match(branch, /person-123 is_adult=True/u); + assert.match(branch, /person-456 is_adult=False/u); }); test('the caller-access replay expects the privacy-safe refusal audit line', async () => { @@ -230,6 +242,184 @@ test('the tutorial coverage check fails on an unregistered page', async () => { } }); +// --------------------------------------------------------------------------- +// Heading addressing +// --------------------------------------------------------------------------- + +// Build a tutorials directory the gate will accept: every excluded page must +// exist, and the one registered page under test is the real one, edited. +async function tutorialFixtureRoot(edit) { + const source = await readFile(gate, 'utf8'); + const excluded = extractBashArray(source, 'EXCLUDED_EVIDENCE_TUTORIALS'); + const root = await mkdtemp(join(tmpdir(), 'evidence-tutorial-heading-test-')); + for (const slug of excluded) { + await writeFile(join(root, `${slug}.mdx`), '---\ntitle: stub\n---\n'); + } + const page = await readFile( + resolve(scriptDir, '../src/content/docs/tutorials/first-evidence-assertion.mdx'), + 'utf8', + ); + await writeFile(join(root, 'first-evidence-assertion.mdx'), edit(page)); + return root; +} + +// The point of heading addressing. A writer who adds a command block under a +// heading the journey already runs must not have to touch the gate, and the +// added block must be replayed rather than silently skipped. +test('a command block added under a replayed heading needs no gate change', async () => { + const root = await tutorialFixtureRoot((page) => + page.replace( + '\n## Verify before reading\n', + '\n```sh\nevidencectl request list\n```\n\n## Verify before reading\n', + ), + ); + try { + const before = await runGate({}, ['--dry-run', '--only', 'first-evidence-assertion']); + assert.equal(before.code, 0, before.output); + const baseline = before.output.match( + /first-evidence-assertion: (\d+) sh fences, (\d+) executed/u, + ); + assert.ok(baseline, before.output); + + const { code, output } = await runGate( + { EVIDENCE_TUTORIAL_DOCS_ROOT: root }, + ['--dry-run', '--only', 'first-evidence-assertion'], + ); + assert.equal(code, 0, output); + const added = output.match(/first-evidence-assertion: (\d+) sh fences, (\d+) executed/u); + assert.ok(added, output); + assert.equal(Number(added[1]), Number(baseline[1]) + 1); + assert.equal(Number(added[2]), Number(baseline[2]) + 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +// The trade heading addressing makes: a renamed heading is a structural edit +// to the journey, so it fails, by name, before any command runs. +test('a renamed heading fails the gate by name', async () => { + const root = await tutorialFixtureRoot((page) => + page.replace('\n## Request an assertion\n', '\n## Ask for an assertion\n'), + ); + try { + const { code, output } = await runGate( + { EVIDENCE_TUTORIAL_DOCS_ROOT: root }, + ['--dry-run', '--only', 'first-evidence-assertion'], + ); + assert.notEqual(code, 0, 'a renamed heading must fail the gate'); + assert.match(output, /no sh fence answers to "Request an assertion"/u); + // The message has to be actionable: it names the headings the page does + // carry, so the fix is reading the list rather than the script. + assert.match(output, /Ask for an assertion/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +// A heading holding more than one sh fence cannot answer a step that runs +// exactly one command, so the gate says which suffix is missing. +test('a one-fence step under a multi-fence heading names the missing occurrence', async () => { + const source = await readFile(gate, 'utf8'); + const root = await mkdtemp(join(tmpdir(), 'evidence-occurrence-test-')); + try { + await writeFile(join(root, 'index.tsv'), '01\t1\tRun it\n02\t2\tRun it\n'); + const harness = join(root, 'resolve.sh'); + await writeFile( + harness, + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + await liftFunction(source, 'resolve_fences'), + await liftFunction(source, 'resolve_one_fence'), + 'resolve_one_fence tutorial "Run it" "$1" background', + '', + ].join('\n'), + ); + const { code, output } = await runShell(`bash ${harness} ${root}`); + assert.notEqual(code, 0, 'an ambiguous one-fence step must fail'); + assert.match(output, /names 2/u); + assert.match(output, /\|/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Behaviour assertions +// --------------------------------------------------------------------------- + +async function runAssertTranscript(asserts, transcript) { + const source = await readFile(gate, 'utf8'); + const root = await mkdtemp(join(tmpdir(), 'evidence-asserts-test-')); + const log = join(root, 'run.log'); + await writeFile(log, transcript); + const harness = join(root, 'assert.sh'); + await writeFile( + harness, + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + await liftFunction(source, 'assert_transcript'), + `SPEC_ASSERTS=(${asserts.map((entry) => `'${entry}'`).join(' ')})`, + `assert_transcript tutorial '${log}'`, + '', + ].join('\n'), + ); + try { + return await runShell(`bash ${harness}`); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test('a retained behaviour assertion missing from the transcript fails', async () => { + const { code, output } = await runAssertTranscript( + ['VERIFIED', 'DISCLOSURE RELEASED is_adult'], + '==> fence 12\nVERIFIED\n==> fence 19\nACCESS AUTHORIZED adult-status age-check requester=x\n', + ); + assert.notEqual(code, 0, 'a missing behaviour must fail the gate'); + assert.match(output, /DISCLOSURE RELEASED is_adult/u); +}); + +test('a transcript showing every retained behaviour passes', async () => { + const { code, output } = await runAssertTranscript( + ['VERIFIED', 'DISCLOSURE RELEASED is_adult'], + 'VERIFIED\nDISCLOSURE RELEASED is_adult\n', + ); + assert.equal(code, 0, output); +}); + +// Every retained assertion has to earn its place by regressing silently. The +// two the gate must never lose are the refusal that actually refused and the +// tamper that was actually caught, and both must be words a tool printed. A +// page that echoes its own verdict asserts nothing: the echo survives the +// regression it was supposed to catch and leaves the transcript quietly clean. +test('the refusal tutorial still asserts the refusal and the tamper', async () => { + const source = await readFile(gate, 'utf8'); + const branch = source.match( + /\n\trefuse-unsafe-evidence-requests\)[\s\S]*?\n\t\t;;/u, + )?.[0]; + assert.ok(branch, 'the refusal replay spec must exist'); + assert.match(branch, /"HTTP 403"/u); + assert.match(branch, /"evidencectl: Evidence response verification failed"/u); + // Startup chatter a successful exit already proves does not belong here. + assert.doesNotMatch(branch, /Evidence ready at/u); + assert.doesNotMatch(branch, /Prepared request:/u); + assert.doesNotMatch(branch, /Local Evidence stopped/u); +}); + +// This gate proves the documented commands still run. It does not police what +// a page says, and the two arrays below are how it used to: one pinned how +// many command blocks a page held, the other pinned strings the page had to +// keep. Both made ordinary prose edits fail CI, and neither verified anything +// replay does not already verify. Reintroducing either is the regression this +// test exists to catch. +test('the gate pins neither fence counts nor page strings', async () => { + const source = await readFile(gate, 'utf8'); + assert.doesNotMatch(source, /SPEC_FENCES/u); + assert.doesNotMatch(source, /SPEC_LITERALS/u); +}); + test('--only refuses a slug that is not registered', async () => { const { code, output } = await runGate({}, ['--dry-run', '--only', 'no-such-tutorial']); assert.notEqual(code, 0, 'an unregistered slug must fail the gate'); @@ -360,19 +550,39 @@ async function fenceScratch() { return root; } -// Run the gate's own run-fails emitter over one fence, and return the journey -// lines it emits. The function is lifted out of the gate rather than restated -// here, so this exercises the shipped code: sourcing the gate would run it. +// Lift one named function out of the gate. Sourcing the gate would run it, so +// the tests below exercise the shipped text of the function instead of +// restating it. +async function liftFunction(source, name) { + const lifted = source.match( + new RegExp(`\\n${name}\\(\\) \\{\\n[\\s\\S]*?\\n\\}\\n`, 'u'), + )?.[0]; + assert.ok(lifted, `${name} must exist in the gate`); + return lifted; +} + +// Run the gate's own run-fails emitter over one fence addressed by heading, +// and return the journey lines it emits. async function emitRunFailsStep(fenceBody) { const source = await readFile(gate, 'utf8'); - const emitter = source.match(/\nemit_run_fails_step\(\) \{\n[\s\S]*?\n\}\n/u)?.[0]; - assert.ok(emitter, 'the run-fails emitter must exist'); + const emitter = [ + await liftFunction(source, 'resolve_fences'), + await liftFunction(source, 'resolve_one_fence'), + await liftFunction(source, 'emit_run_fails_step'), + ].join('\n'); const root = await mkdtemp(join(tmpdir(), 'evidence-refusal-test-')); await writeFile(join(root, 'fence-09.sh'), fenceBody); + await writeFile(join(root, 'index.tsv'), '09\t1\tRefuse before reading\n'); const harness = join(root, 'emit.sh'); await writeFile( harness, - ['#!/usr/bin/env bash', 'set -euo pipefail', emitter, 'emit_run_fails_step tutorial 9 "$1"', ''].join('\n'), + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + emitter, + 'emit_run_fails_step tutorial "Refuse before reading" "$1"', + '', + ].join('\n'), ); const { stdout } = await execFileAsync('bash', [harness, root]); return { root, journey: `set -euo pipefail\n${stdout}` }; diff --git a/docs/site/scripts/check-tutorial.sh b/docs/site/scripts/check-tutorial.sh index 5d962f278..bbfb4f30b 100755 --- a/docs/site/scripts/check-tutorial.sh +++ b/docs/site/scripts/check-tutorial.sh @@ -33,9 +33,11 @@ # 2 bad CLI argument # # Drift detection: -# - the script asserts EXPECTED_STEP_COUNT / EXPECTED_VERIFY_COUNT commands -# were extracted from the matching sections; bump these constants when you -# intentionally add or remove a documented command +# - the script reports the commands it extracted from each section rather +# than pinning how many there are, so adding or removing a documented +# command needs no change here. It fails only when a section yields none, +# which means the heading it reads was renamed and the runner would +# otherwise pass by doing nothing # - after compose comes up, the script asserts every entry in # EXPECTED_SERVICES is in `running` state and that EXPECTED_RUNNING_TOTAL # services are running in all; bump both when you intentionally add or @@ -46,9 +48,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TUTORIAL="$REPO_ROOT/src/content/docs/tutorials/first-run-with-solmara-lab.mdx" -EXPECTED_STEP_COUNT=4 -EXPECTED_VERIFY_COUNT=4 +TUTORIAL="${SOLMARA_TUTORIAL_PAGE:-$REPO_ROOT/src/content/docs/tutorials/first-run-with-solmara-lab.mdx}" EXPECTED_DEMO_ARTIFACTS=3 # Every service the tutorial names or gives a host port. The topology holds far # more; EXPECTED_RUNNING_TOTAL below covers the rest as a count, because the @@ -131,27 +131,23 @@ while IFS= read -r line; do VERIFY+=("$line") done < <(extract_section_commands "Verify") -if ((${#STEPS[@]} != EXPECTED_STEP_COUNT)); then - printf 'tutorial drift: expected %d shell commands in Steps section, extracted %d:\n' \ - "$EXPECTED_STEP_COUNT" "${#STEPS[@]}" >&2 - for cmd in "${STEPS[@]}"; do - printf ' %s\n' "$cmd" >&2 - done - printf 'if this change was intentional, update EXPECTED_STEP_COUNT in %s\n' \ - "${BASH_SOURCE[0]}" >&2 - exit 1 -fi +# The counts are reported below, not pinned: a writer may add or remove a +# documented command without touching this file, and the runner simply runs +# what the page now says. Empty is the one count that is drift, because it +# means the heading this reads was renamed and every later step would pass by +# running nothing at all. +require_commands() { + local section="$1" extracted="$2" + if ((extracted == 0)); then + printf 'tutorial drift: no shell commands under its "%s" heading\n' "$section" >&2 + printf 'The section was renamed or removed, so this runner would execute nothing and still pass.\n' >&2 + printf 'Point %s at the heading the page carries now.\n' "${BASH_SOURCE[0]}" >&2 + exit 1 + fi +} -if ((${#VERIFY[@]} != EXPECTED_VERIFY_COUNT)); then - printf 'tutorial drift: expected %d shell commands in Verify section, extracted %d:\n' \ - "$EXPECTED_VERIFY_COUNT" "${#VERIFY[@]}" >&2 - for cmd in "${VERIFY[@]}"; do - printf ' %s\n' "$cmd" >&2 - done - printf 'if this change was intentional, update EXPECTED_VERIFY_COUNT in %s\n' \ - "${BASH_SOURCE[0]}" >&2 - exit 1 -fi +require_commands Steps "${#STEPS[@]}" +require_commands Verify "${#VERIFY[@]}" printf 'extracted %d Steps commands from tutorial:\n' "${#STEPS[@]}" for i in "${!STEPS[@]}"; do diff --git a/docs/site/scripts/check-tutorial.test.mjs b/docs/site/scripts/check-tutorial.test.mjs new file mode 100644 index 000000000..232bb915e --- /dev/null +++ b/docs/site/scripts/check-tutorial.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const siteRoot = new URL('..', import.meta.url); +const gatePath = fileURLToPath(new URL('check-tutorial.sh', import.meta.url)); +const pagePath = fileURLToPath( + new URL('../src/content/docs/tutorials/first-run-with-solmara-lab.mdx', import.meta.url), +); + +async function dryRun(env = {}) { + return execFileAsync('bash', ['scripts/check-tutorial.sh', '--dry-run'], { + cwd: siteRoot, + encoding: 'utf8', + env: { ...process.env, ...env }, + }); +} + +test('the dry run reports the commands it extracted instead of pinning how many', async () => { + const { stdout } = await dryRun(); + + // Registration only. The count is reported so a reviewer sees the journey; + // asserting a specific number here would rebuild the tripwire the gate just + // dropped, one file further out. + assert.match(stdout, /extracted \d+ Steps commands from tutorial:/u); + assert.match(stdout, /extracted \d+ Verify commands from tutorial:/u); +}); + +test('a section the page no longer carries fails, rather than running nothing', async () => { + const page = await readFile(pagePath, 'utf8'); + assert.match(page, /^## Verify$/mu, 'fixture assumption: the page carries a Verify section'); + + const dir = await mkdtemp(join(tmpdir(), 'solmara-tutorial-')); + const edited = join(dir, 'page.mdx'); + await writeFile(edited, page.replace(/^## Verify$/mu, '## Check the run')); + + await assert.rejects( + () => dryRun({ SOLMARA_TUTORIAL_PAGE: edited }), + (error) => { + assert.match(error.stderr, /no shell commands under its "Verify" heading/u); + return true; + }, + ); +}); + +test('the gate pins no extraction counts', async () => { + const source = await readFile(gatePath, 'utf8'); + + // These two constants made the page answerable to the gate: adding or + // removing a documented command failed the build until someone bumped a + // number. The service and artifact expectations below are a different thing + // and stay: they compare what the page states to what actually runs. + assert.doesNotMatch(source, /EXPECTED_STEP_COUNT/u); + assert.doesNotMatch(source, /EXPECTED_VERIFY_COUNT/u); + assert.match(source, /EXPECTED_RUNNING_TOTAL=\d+/u); + assert.match(source, /EXPECTED_SERVICES=\(/u); + assert.match(source, /EXPECTED_DEMO_ARTIFACTS=\d+/u); +}); diff --git a/docs/site/scripts/evidence-production-build-docs.test.mjs b/docs/site/scripts/evidence-production-build-docs.test.mjs index fdacb93ac..e3d35489d 100644 --- a/docs/site/scripts/evidence-production-build-docs.test.mjs +++ b/docs/site/scripts/evidence-production-build-docs.test.mjs @@ -11,7 +11,24 @@ async function page(path) { return readFile(resolve(siteRoot, path), 'utf8'); } -test('production Evidence tutorials keep the build, Transit, optional Mint, and Compose boundaries explicit', async () => { +// These tutorials describe deployments this repository cannot replay: they need +// a real Vault or OpenBao, a deployment repository, and a target host. This file +// is therefore a drift check on the pages themselves, and it is deliberately a +// small one. +// +// One test decides whether an assertion belongs here: if a page lost this, would +// an adopter be left less safe, with nothing else noticing? A token that reaches +// a command line or stays on disk, a private key that becomes exportable, a +// service that starts holding a provider token, a retired signing version that +// can still sign, a boundary between two services that quietly disappears. +// Those stay. +// +// Command spelling, directory layouts, placeholder names, page structure, and +// component usage do not. They are what a page says rather than what the +// deployment must be, and pinning them here only makes these pages harder to +// write. If you are adding an assertion because a page happens to contain a +// string, stop. +test('production Evidence tutorials keep their secret handling, signing, and Mint boundaries explicit', async () => { const [build, transit, rotation, mint, compose] = await Promise.all([ page('src/content/docs/tutorials/build-and-deploy-evidence-project.mdx'), page('src/content/docs/tutorials/move-evidence-to-production-signing.mdx'), @@ -20,37 +37,34 @@ test('production Evidence tutorials keep the build, Transit, optional Mint, and page('src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx'), ]); - assert.match(build, /evidencectl build/u); - assert.match(build, /\.evidence\/dev/u); - assert.match(build, /evidence --runtime "\/runtime\.yaml" verify-audit/u); + // The access token goes into an owner-only file, never onto a command line or + // into shell history. assert.match(build, /install -m 600 \/dev\/null ""/u); - assert.match(build, /Authorization: Bearer /u); - assert.match(build, /environments\/production\/evidence/u); - assert.match(build, /\/[\s\S]*public-keys\//u); - assert.match(build, /\/environments\/production\/mint\/mint\.yaml"/u); assert.match(mint, /signer\.kind: transit/u); assert.match(mint, /memory-only/u); - assert.match(mint, /umask 077\nmint token/u); + // The issued token is created owner-only and removed after use. + assert.match(mint, /umask 077/u); assert.match(mint, /rm -f ""/u); - assert.match(mint, /:\/run\/registry-evidence/u); + // Two services, two signing paths: sharing one would let either sign as the + // other. assert.match(compose, /Do not share the Evidence Gateway proxy or socket with Mint/u); - assert.match(compose, /docker compose down/u); - assert.match(compose, / { diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index d63dc4094..ae60004d9 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -1,19 +1,16 @@ // Guards the Registry Stack 1.0 product outcomes and their stable entry points. import assert from 'node:assert/strict'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; import { test } from 'node:test'; +import { cliReferenceSidebar } from '../src/lib/cli-reference-sidebar.mjs'; import { RETIRED_RELAY_ROUTE_TARGETS } from '../src/lib/relay-v2-retirement-redirects.mjs'; const siteRoot = resolve(import.meta.dirname, '..'); const configSource = readFileSync(resolve(siteRoot, 'astro.config.mjs'), 'utf8'); const homepageSource = readFileSync(resolve(siteRoot, 'src/content/docs/index.mdx'), 'utf8'); -const quickstartSource = readFileSync( - resolve(siteRoot, 'src/content/docs/start/quickstart.mdx'), - 'utf8', -); const validationSource = readFileSync( resolve(siteRoot, 'src/content/docs/verify/index.mdx'), 'utf8', @@ -42,6 +39,73 @@ function hasDocForSlug(slug) { ].some((path) => existsSync(path) && !/^draft: true$/m.test(readFileSync(path, 'utf8'))); } +// Every slug the built site publishes from the hand-authored content +// collection, in the form the sidebar uses to address it: `start/when-to-use` +// for a leaf file, `configure` for a directory index, and the empty string for +// the homepage. Starlight's `draft: true` is what removes a page from the built +// site, so a draft page is not published and is not expected to be navigable. +// +// Product documentation under `products/` is pulled from the source repos by +// scripts/sync-repo-docs.mjs and seated by scripts/generate-sidebar.mjs, which +// generate-sidebar.test.mjs already pins doc-for-doc against the manifest. It +// is also a build artifact, absent until `npm run generate` runs, so this walk +// skips it rather than asserting on a tree that may not exist. +function publishedSlugs() { + const root = resolve(siteRoot, 'src/content/docs'); + const slugs = []; + + function walk(directory, prefix) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + if (prefix === '' && entry.name === 'products') continue; + walk(path, `${prefix}${entry.name}/`); + continue; + } + if (!/\.mdx?$/.test(entry.name)) continue; + if (/^draft: true$/m.test(readFileSync(path, 'utf8'))) continue; + const name = entry.name.replace(/\.mdx?$/, ''); + slugs.push(name === 'index' ? prefix.slice(0, -1) : `${prefix}${name}`); + } + } + + walk(root, ''); + return slugs.sort(); +} + +// Every slug the hand-authored sidebar addresses. Three sources feed it, +// because three things put an entry in that array. +function seatedSlugs() { + const seated = new Set([...sidebarSource.matchAll(/slug: '([^']+)'/g)].map((m) => m[1])); + // The homepage is seated by route rather than by slug. + if (/link: '\/'/.test(sidebarSource)) seated.add(''); + // The command-line reference group is spread in from + // src/lib/cli-reference-sidebar.mjs, so its slugs never appear in the config + // text. That module returns [] while the generated index carries + // `draft: true`, which is the same state in which the generated CLI pages are + // themselves draft and so are not published either. + // + // That group seats the index and the six binaries, not the subcommand pages + // under them. Publishing the command-line reference will therefore make this + // test name every subcommand page at once, and seating them is part of that + // publish rather than a fault in this gate. + for (const group of cliReferenceSidebar()) { + for (const item of group.items) seated.add(item.slug); + } + return seated; +} + +// Published pages the sidebar deliberately does not seat, each with the reason +// it is acceptable that it stays unreachable from the navigation. +// +// The sidebar is the whole of this site's navigation: RegistryHeader.astro +// renders a wordmark, search, the docset switcher, a theme selector, and a +// GitHub link, and nothing else. A published page with no seat is reachable +// only by search or by already knowing its URL, so an entry here is a debt, +// not a category. Add one only with the decision that keeps the page +// published, and delete it the moment the page gets a seat. +const UNSEATED_PUBLISHED_PAGES = new Map([]); + function assertOrdered(source, expectations, label) { let position = -1; for (const expectation of expectations) { @@ -51,51 +115,69 @@ function assertOrdered(source, expectations, label) { } } +// Top level is a list of tasks an adopter can name, not a list of products. +// The product that serves a task is named inside the section, so a reader who +// does not yet know which product they need can still pick a door. test('uses the adopter-first top-level flow in its published order', () => { assert.deepEqual(topLevelLabels(sidebarSource), [ 'Start', - 'Answer with Evidence Gateway', + 'Answer a bounded question', 'Connect an existing registry', - 'Operate across products', - 'Security', + 'Consume and verify assertions', + 'Authenticate callers', + 'Publish a Discovery index', + 'Operate and secure', + 'Understand the design', 'Reference', ]); }); -test('publishes one overview route for every task-flow section', () => { +test('publishes one overview route for every task-flow section that has one', () => { for (const [label, route] of [ ['Start', "link: '/'"], - ['Answer with Evidence Gateway', "slug: 'start/evidence-quickstart'"], + ['Answer a bounded question', "slug: 'start/evidence-quickstart'"], ['Connect an existing registry', "slug: 'configure'"], - ['Operate across products', "slug: 'operate/advanced'"], - ['Security', "slug: 'security'"], + ['Operate and secure', "slug: 'operate/advanced'"], ['Reference', "slug: 'reference'"], ]) { const section = topLevelSection(sidebarSource, label); assert.ok(section, `could not isolate ${label}`); assert.match(section, new RegExp(route.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } + + // Consume and verify assertions ships without an overview because no page + // yet addresses a relying party who has not chosen a product. The section is + // three tutorials that each stand alone, so it opens on the first of them + // rather than on a page written for a different reader. + const consume = topLevelSection(sidebarSource, 'Consume and verify assertions'); + assert.ok(consume, 'could not isolate Consume and verify assertions'); + assert.doesNotMatch(consume, /label: 'Overview'/); }); -// A page that names one product belongs under that product, so a reader -// following one adoption path never leaves it. The two cross-product sections -// keep only what applies to every deployment. -test('files product-scoped pages under their product, not under the cross-product sections', () => { - const crossProduct = topLevelSection(sidebarSource, 'Operate across products'); +// A page that names one product belongs under that product while the reader is +// still adopting it, so a reader following one adoption path never leaves it. +// Operate and secure is the exception the operator earns: after handoff the +// reader is on call for a running deployment, not choosing a product, so pages +// that name a runtime sit beside the ones that do not. +test('files adoption-time pages under their product', () => { const relay = topLevelSection(sidebarSource, 'Connect an existing registry'); - for (const relayOnly of ['operate', 'operate/relay']) { - const entry = new RegExp(`slug: '${relayOnly.replaceAll('/', '\\/')}' \\}`); - assert.doesNotMatch( - crossProduct, - entry, - `${relayOnly} documents Registry Relay and belongs in the Relay section`, - ); - assert.match(relay, entry, `the Relay section must carry ${relayOnly}`); - } + const operate = topLevelSection(sidebarSource, 'Operate and secure'); + assert.match( + relay, + /slug: 'operate\/relay' \}/, + 'running a Relay deployment is a Relay page and belongs in the Relay section', + ); + assert.doesNotMatch(operate, /slug: 'operate\/relay' \}/); + // The operator handoff is the entry to the operator's own section, and it + // named Relay only because that is where it used to sit. + assert.match(operate, /slug: 'operate' \}/); + assert.doesNotMatch(relay, /slug: 'operate' \}/); - const security = topLevelSection(sidebarSource, 'Security'); + // Evidence Gateway's security model is product-scoped, so it stays with the + // product rather than in the cross-product security group. + const security = topLevelSection(sidebarSource, 'Operate and secure'); assert.doesNotMatch(security, /slug: 'security\/evidence'/); - const evidence = topLevelSection(sidebarSource, 'Answer with Evidence Gateway'); + const evidence = topLevelSection(sidebarSource, 'Answer a bounded question'); assert.match(evidence, /slug: 'security\/evidence'/); }); @@ -118,13 +200,19 @@ test('publishes one Relay reader journey without the retired V1 routes', () => { 'Relay reader journey', ); // The section mirrors the Evidence Gateway shape: an overview and the first - // hands-on tutorial in the open, then the deeper phases collapsed behind the + // hands-on tutorial in the open, then the deeper phases grouped behind the // phase they belong to. assertOrdered( connect, - ["label: 'Author a project'", "label: 'Operate Relay'"], + ["label: 'Author a project'", "label: 'Call a Relay API'"], 'Relay phase group', ); + // The caller's half of Relay is its own group: authoring and operating pages + // address the institution publishing the API, not the application calling it. + assert.match(connect, /slug: 'reference\/relay-client-api'/); + // Relay's operational posture specification is a Relay page, so it is seated + // here rather than a second time in the Reference specification register. + assert.match(connect, /slug: 'spec\/rs-op-posture'/); // Relay V2 is the only Relay the site documents, so the section carries no // preview group beside the maintained journey and none of the V1 source // tutorials it replaced. @@ -138,28 +226,19 @@ test('publishes one Relay reader journey without the retired V1 routes', () => { ]) { assert.doesNotMatch(sidebarSource, new RegExp(retired)); assert.doesNotMatch(homepageSource, new RegExp(retired)); - assert.doesNotMatch(quickstartSource, new RegExp(retired)); } assert.match(homepageSource, /\]\(tutorials\/publish-governed-sqlite-registry\/\)/); - assert.match( - quickstartSource, - /\]\(\.\.\/\.\.\/tutorials\/publish-governed-sqlite-registry\/\)/, - ); assert.doesNotMatch(homepageSource, /tutorials\/verify-claim-registry-api/); - assert.doesNotMatch(quickstartSource, /tutorials\/verify-claim-registry-api/); }); test('gives Evidence Gateway a lane on both front doors without a retired Notary path', () => { assert.match(homepageSource, /\]\(start\/evidence-quickstart\/\)/); - assert.match(quickstartSource, /\]\(\.\.\/evidence-quickstart\/\)/); assert.match(homepageSource, /tutorials\/first-evidence-assertion/); - assert.match(quickstartSource, /tutorials\/first-evidence-assertion/); assert.doesNotMatch(homepageSource, /Expose Notary|verify-claim-registry-api/); - assert.doesNotMatch(quickstartSource, /Expose Notary|verify-claim-registry-api/); }); test('organizes Evidence Gateway tasks without publishing the obsolete Relay composition', () => { - const evidence = topLevelSection(sidebarSource, 'Answer with Evidence Gateway'); + const evidence = topLevelSection(sidebarSource, 'Answer a bounded question'); assertOrdered( evidence, [ @@ -168,18 +247,32 @@ test('organizes Evidence Gateway tasks without publishing the obsolete Relay com // anything. "slug: 'tutorials/first-evidence-assertion'", "label: 'Learn locally'", - "label: 'Connect a source'", + "label: 'Connect your own source'", + "label: 'Worked examples'", "label: 'Prepare and deploy'", - "label: 'Authenticate callers'", - // Relying-party verification and wallet delivery are different audiences - // with different deployments, so they are separate groups. - "label: 'Verify as a relying party'", "label: 'Deliver to wallets'", - "label: 'Operate Evidence Gateway'", + // Reference a reader opens with the deployment in front of them, so it + // ends this section instead of starting a Reference lookup. + "slug: 'reference/evidence-configuration'", + "slug: 'reference/evidence-problems'", + "label: 'HTTP API'", ], 'Evidence Gateway task group', ); assert.doesNotMatch(evidence, /label: 'Verify and trust'/); + // Token issuance and relying-party verification are separate audiences that + // reach Evidence Gateway from outside it, so each is a section of its own + // rather than a group buried in the provider's path. + assert.doesNotMatch(evidence, /label: 'Authenticate callers'/); + assert.doesNotMatch(evidence, /label: 'Verify as a relying party'/); + // explanation/integration-patterns held two seats, which left Starlight + // unable to say which one is the active page and made prev/next ambiguous. + // Its one seat is the advanced half of connecting a source. + assert.equal( + [...sidebarSource.matchAll(/slug: 'explanation\/integration-patterns'/g)].length, + 1, + ); + assert.match(evidence, /slug: 'explanation\/integration-patterns'/); assert.doesNotMatch(evidence, /first-run-with-solmara-lab|Relay-protected|over a Relay/); assert.equal(hasDocForSlug('tutorials/first-run-with-solmara-lab'), false); assert.match( @@ -219,7 +312,24 @@ test('does not publish the retired pre-1.0 cutover page', () => { ); assert.doesNotMatch(sidebarSource, /pre-1\.0-cutover/); assert.doesNotMatch(homepageSource, /pre-1\.0-cutover/); - assert.doesNotMatch(quickstartSource, /pre-1\.0-cutover/); +}); + +// `start/quickstart` was a second chooser beside `start/when-to-use`: both told +// a reader which of the two products answered their problem, and only one of +// them had a seat. It is retired rather than repurposed, so the four redirects +// that pointed at it now land on the chooser that stayed, and so does its own +// route, which was published and so has readers holding links to it. +test('does not publish the retired second stack chooser', () => { + assert.equal( + existsSync(resolve(siteRoot, 'src/content/docs/start/quickstart.mdx')), + false, + ); + assert.doesNotMatch(sidebarSource, /start\/quickstart/); + assert.doesNotMatch(homepageSource, /start\/quickstart/); + assert.match( + configSource, + /'\/start\/quickstart\/': internalRedirect\('\/start\/when-to-use\/'\)/, + ); }); test('every hand-authored sidebar slug resolves to a published documentation page', () => { @@ -229,11 +339,48 @@ test('every hand-authored sidebar slug resolves to a published documentation pag assert.deepEqual(missing, []); }); +// The inverse of the assertion above, and the one whose absence let three +// security seats disappear in 6a73ea65f without a single check going red. +// A seat that points nowhere breaks the build; a page that nothing points at +// breaks only the reader, silently. +test('every published page has a sidebar seat or a reasoned allowlist entry', () => { + const seated = seatedSlugs(); + const orphans = publishedSlugs().filter( + (slug) => !seated.has(slug) && !UNSEATED_PUBLISHED_PAGES.has(slug), + ); + + assert.deepEqual( + orphans, + [], + 'published pages the sidebar does not reach, so a reader finds them only by ' + + 'search or by already knowing the URL: ' + + `${orphans.join(', ')}. Give each one a seat in astro.config.mjs, or add it ` + + 'to UNSEATED_PUBLISHED_PAGES with the decision that keeps it published.', + ); +}); + +test('keeps the unseated-page allowlist free of stale entries', () => { + const seated = seatedSlugs(); + const published = new Set(publishedSlugs()); + + for (const [slug, reason] of UNSEATED_PUBLISHED_PAGES) { + assert.ok( + published.has(slug), + `${slug} is allowlisted as unseated but is not a published page: drop the entry`, + ); + assert.ok( + !seated.has(slug), + `${slug} now has a sidebar seat: drop its UNSEATED_PUBLISHED_PAGES entry`, + ); + assert.ok(reason.trim().length > 0, `${slug} needs a reason, not an empty string`); + } +}); + test('legacy first-run entry points redirect to supported 1.0 paths', () => { assert.match(configSource, /'\/start\/': internalRedirect\('\/'\)/); assert.match( configSource, - /'\/start\/see-it-live\/': internalRedirect\('\/start\/quickstart\/'\)/, + /'\/start\/see-it-live\/': internalRedirect\('\/start\/when-to-use\/'\)/, ); assert.match( configSource, @@ -241,7 +388,7 @@ test('legacy first-run entry points redirect to supported 1.0 paths', () => { ); assert.match( configSource, - /'\/tutorials\/first-run-with-registry-lab\/': internalRedirect\('\/start\/quickstart\/'\)/, + /'\/tutorials\/first-run-with-registry-lab\/': internalRedirect\('\/start\/when-to-use\/'\)/, ); // The retired V1 source tutorials still resolve: their redirects moved into // the Relay V2 retirement module, so assert that map rather than the config diff --git a/docs/site/scripts/smoke-docs-deployment.mjs b/docs/site/scripts/smoke-docs-deployment.mjs index 14654ad99..644375df7 100644 --- a/docs/site/scripts/smoke-docs-deployment.mjs +++ b/docs/site/scripts/smoke-docs-deployment.mjs @@ -59,7 +59,7 @@ async function requireRoute(read, pathname) { export async function smokeDocsDeployment({ read, releasedTag, - deepRoute = '/start/quickstart/', + deepRoute = '/start/when-to-use/', } = {}) { if (!releaseTagPattern.test(releasedTag ?? '')) { throw new Error('released tag must be canonical v.. text'); @@ -120,7 +120,7 @@ export async function smokeDocsDeployment({ } export function parseSmokeArgs(args) { - const parsed = { attempts: 1, deepRoute: '/start/quickstart/' }; + const parsed = { attempts: 1, deepRoute: '/start/when-to-use/' }; while (args.length > 0) { const option = args.shift(); if (option === '--root' && args[0]) parsed.root = resolve(args.shift()); diff --git a/docs/site/scripts/smoke-docs-deployment.test.mjs b/docs/site/scripts/smoke-docs-deployment.test.mjs index 722d1be17..6ec14def0 100644 --- a/docs/site/scripts/smoke-docs-deployment.test.mjs +++ b/docs/site/scripts/smoke-docs-deployment.test.mjs @@ -25,7 +25,7 @@ function fixture(overrides = {}) { 'Released docs.Version', ), ], - ['/start/quickstart/', html(`${origin}/start/quickstart/`)], + ['/start/when-to-use/', html(`${origin}/start/when-to-use/`)], ['/dev/', html(`${origin}/dev/`)], ['/v/1.2.3/', html(`${origin}/v/1.2.3/`)], ['/pagefind/pagefind.js', Buffer.from('search')], @@ -48,7 +48,7 @@ test('smokes root, deep, development, version, search, and discovery routes', as assert.deepEqual( await smokeDocsDeployment({ read: fixture(), releasedTag }), { - deepRoute: '/start/quickstart/', + deepRoute: '/start/when-to-use/', releasedTag, versionPath: '/v/1.2.3/', }, diff --git a/docs/site/scripts/stage-production-docsets.test.mjs b/docs/site/scripts/stage-production-docsets.test.mjs index 9a0810fc7..d29de92bf 100644 --- a/docs/site/scripts/stage-production-docsets.test.mjs +++ b/docs/site/scripts/stage-production-docsets.test.mjs @@ -68,7 +68,7 @@ async function createFixture(t, { collision = null } = {}) { ); await write(archiveRoot, '_astro/app.js', 'console.log("version");\n'); await write(rootOutput, 'index.html', html('/')); - await write(rootOutput, 'start/quickstart/index.html', html('/start/quickstart/')); + await write(rootOutput, 'start/when-to-use/index.html', html('/start/when-to-use/')); await write(rootOutput, 'index.md', '# Released index\n'); await write(rootOutput, 'llms.txt', '# Released machine docs\n'); await write(rootOutput, 'sitemap-index.xml', '\n'); @@ -151,10 +151,10 @@ test('promotes unchanged released files to root and the exact version route', as ); assert.match( await readFile( - resolve(fixture.docsRoot, 'dist/preview/start/quickstart/index.html'), + resolve(fixture.docsRoot, 'dist/preview/start/when-to-use/index.html'), 'utf8', ), - /url=\/start\/quickstart\//, + /url=\/start\/when-to-use\//, ); }); diff --git a/docs/site/src/content/docs/configure/discovery.mdx b/docs/site/src/content/docs/configure/discovery.mdx index 67244c616..4e21212e9 100644 --- a/docs/site/src/content/docs/configure/discovery.mdx +++ b/docs/site/src/content/docs/configure/discovery.mdx @@ -259,3 +259,10 @@ credentials or native request data to Registry Discovery. {/* Evidence: `crates/registry-discoveryctl/src/build.rs`, `BuildError`; `crates/registry-discovery/src/query.rs`, `service_matches_filters`; `crates/registry-discovery-client/src/selection.rs`, exact selection errors and no-native-I/O test. */} + +## Next + +- [Configure Evidence Gateway](../evidence/) to set the native trust an application uses once + Discovery has selected an Evidence provider. +- [Author a Registry Relay project](../relay/) to set the native trust an application uses once + Discovery has selected a Relay provider. diff --git a/docs/site/src/content/docs/configure/enable-sd-jwt-vc.mdx b/docs/site/src/content/docs/configure/enable-sd-jwt-vc.mdx index 4cf3dcb7d..6a07a2c22 100644 --- a/docs/site/src/content/docs/configure/enable-sd-jwt-vc.mdx +++ b/docs/site/src/content/docs/configure/enable-sd-jwt-vc.mdx @@ -182,3 +182,10 @@ unclaimed. For deterministic source-tree proof of the format and tamper refusals, see the [maintained SD-JWT VC demo](https://github.com/registrystack/registry-stack/blob/main/products/evidence/SD-JWT-VC-DEMO.md). + +## Next + +- [Configure OID4VCI wallet delivery](../evidence-oid4vci/) to deliver this SD-JWT VC response to + a wallet over the protocol Evidence Gateway itself refuses to speak. +- [Evidence Gateway security model](../../security/evidence/) for the complete boundary this + response sits inside, beyond the credential-lifecycle exclusions listed above. diff --git a/docs/site/src/content/docs/configure/evidence-oid4vci.mdx b/docs/site/src/content/docs/configure/evidence-oid4vci.mdx index 6a9a0dbf0..689f53b11 100644 --- a/docs/site/src/content/docs/configure/evidence-oid4vci.mdx +++ b/docs/site/src/content/docs/configure/evidence-oid4vci.mdx @@ -386,13 +386,6 @@ ceiling is `maximumOffers / offerLifetimeSeconds` per second. Saturation refuses without evicting a live authorized exchange. Short offer and access-token lifetimes and a transaction code reduce the usefulness of copied offer material. -## Next - -- [Configure Registry Mint](../mint/) to issue the access tokens `evidence-oid4vci` uses to - authenticate to Evidence Gateway and to protect its own `POST /offers` endpoint. -- [Configure Evidence Gateway](../evidence/) for the deployment this service requests credentials - from. - ## Troubleshooting | Symptom | Bounded cause | Corrective action | @@ -411,3 +404,10 @@ transaction code reduce the usefulness of copied offer material. Protocol errors deliberately do not reveal whether a secret value was unknown, expired, consumed, or locked out. Operational logs must not add that distinction or print codes, tokens, nonces, proofs, holder keys, credentials, selectors, subject identifiers, raw issuers, or raw audiences. + +## Next + +- [Configure Registry Mint](../mint/) to issue the access tokens `evidence-oid4vci` uses to + authenticate to Evidence Gateway and to protect its own `POST /offers` endpoint. +- [Configure Evidence Gateway](../evidence/) for the deployment this service requests credentials + from. diff --git a/docs/site/src/content/docs/configure/evidence.mdx b/docs/site/src/content/docs/configure/evidence.mdx index 0a49d289c..ecbad4e56 100644 --- a/docs/site/src/content/docs/configure/evidence.mdx +++ b/docs/site/src/content/docs/configure/evidence.mdx @@ -389,3 +389,12 @@ It does not contact an identity provider, Mint, or a source endpoint. Use [Build and deploy an Evidence Gateway project](../../tutorials/build-and-deploy-evidence-project/) for the full handoff, and [Evidencectl command reference](../../reference/evidencectl/) for the command contract. + +## Next + +- [Configure Registry Mint](../mint/) to issue access tokens for this deployment when it has no + other identity provider. +- [Enable SD-JWT VC in a deployment](../enable-sd-jwt-vc/) to serialize the same assertion as a + wallet-usable credential instead of a signed JWS. +- [Verify and interpret the Evidence Gateway audit chain](../../operate/evidence-audit/) for the + audit history this deployment produces once handed to an operator. diff --git a/docs/site/src/content/docs/configure/index.mdx b/docs/site/src/content/docs/configure/index.mdx index e6c67e321..74f422389 100644 --- a/docs/site/src/content/docs/configure/index.mdx +++ b/docs/site/src/content/docs/configure/index.mdx @@ -25,6 +25,14 @@ Relay reads read-only SQLite and nothing else. The institution decides in SQL what leaves the system of record, and the contract then decides what leaves the API. Only views can be bound as sources, so a raw table never becomes a route. +Naming any other engine as a source's kind is a compile-time refusal, not a +runtime warning. + +A project can bind more than one named source, and each resource picks +exactly one source and one view. Every source declares an expected schema +fingerprint, a SHA-256 digest of its reviewed schema, so a source whose +structure has drifted from what was reviewed refuses to compile rather than +silently serving a changed shape. `relayctl inspect` reports the structure you are allowed to reason about: objects, columns, declared types, nullability, key membership, and a schema @@ -39,6 +47,21 @@ Choose the source profile the institution can actually operate: | Source revision | Captured content digest | Explicitly unversioned | | Available operations | Every declared operation | Identifier read and named exact lookup | +A live read-only source compiles only identifier read and named exact lookup +operations. A list or a collection search against a live source is a +compile-time refusal, so bounded bbox search and any other collection search +need a reviewed snapshot source instead. A published property is typed as one +of `String`, `Boolean`, `Integer`, `Date`, `DateTime`, `Year`, `YearMonth`, or +`ControlledCode`, chosen independently of the SQLite column's own storage +type. + +This path assumes a source that is, or can be exposed as, read-only SQLite +views. Evidence Gateway answers a different kind of question over a different +source shape: named fixed HTTP JSON requests or a reviewed SQLite extract, +returned as one signed assertion rather than a published API. See +[Configure Evidence Gateway](./evidence/) if a bounded, minimum-disclosure +assertion is what the reader needs instead of a published Registry Relay API. + ## Author the contract, not the database `registry.yaml` is the governed agreement. diff --git a/docs/site/src/content/docs/configure/mint.mdx b/docs/site/src/content/docs/configure/mint.mdx index 3630f2c0b..0ec1f959c 100644 --- a/docs/site/src/content/docs/configure/mint.mdx +++ b/docs/site/src/content/docs/configure/mint.mdx @@ -352,3 +352,12 @@ records were corrupted or reordered. | Evidence Gateway rejects a token that Registry Mint minted | `accessTokens.claims` on Registry Mint and the resource server's own claim-name configuration name different claims for the same authority field. | Align every claim name (`principal`, `requesterTags`, `evidenceAudience`, `grantId`, `grantAuthority`, and `actor` where used) between the two configurations. | | Registry Relay conceals a registered operation as `404 resource.not_found` | The Mint registration does not contain the selected operation or access-profile scope, or the token audience does not equal the Relay runtime audience. | Set `accessTokens.audiences` to the one Relay audience and register the exact compiled scope for this client. Do not copy scope from the request. | | `GET /ready` returns `503` | No client is currently registered, the client registry failed to load, the audit writer is poisoned, or the signing provider is unavailable. | Check startup or reload diagnostics and audit storage. Add a valid client, restore and verify audit storage, or restore the Transit proxy and pinned version. Provider readiness recovers after a successful self-test. | + +## Next + +- [Author a Registry Relay project](../relay/) to declare the `access.scope` values a registered + client's `authorization.scopes` must match. +- [Configure Evidence Gateway](../evidence/) to align `accessTokens.claims` with the resource + server's own claim-name configuration. +- [Rotate credentials, keys, certificates, and trust](../../operate/advanced/rotate-credentials-and-trust/) for ongoing + rotation of Registry Mint's signing material and registered client keys. diff --git a/docs/site/src/content/docs/configure/relay.mdx b/docs/site/src/content/docs/configure/relay.mdx index fc8fbdd97..f8028788d 100644 --- a/docs/site/src/content/docs/configure/relay.mdx +++ b/docs/site/src/content/docs/configure/relay.mdx @@ -191,7 +191,9 @@ operations: The named search makes the query shape and its authorization independently reviewable. A client with only the list scope cannot search, and a client with only the protected search scope cannot -list. The request is explicit: +list. [Configure Registry Mint](../mint/) to issue the access token that carries a scope such as +`registry:business:premises-search-registrar` when the deployment has no other identity provider. +The request is explicit: ```http GET /v2/resources/registered-premises/searches/within-bbox?bbox=100,13,101,14 @@ -340,3 +342,11 @@ different interpretation. | Production checking reports a review error | The sidecar is missing, not reviewed, stale, or does not match the inventory | Complete the institutional review and regenerate the affected report. | | A field is rejected | It is not in the selected access profile's disclosure profile | Review and change the governed access profile, then repeat the full workflow. | | Packaging refuses the destination | The output directory exists or its closure is unsafe | Select a new empty revisioned directory. | + +## Next + +- [Configure Registry Mint](../mint/) to issue tokens for the scopes declared in this project's + access profiles. +- [Operate Registry Relay](../../operate/relay/) to deploy the packaged revision. +- [Semantics, classification, and disclosure in Relay](../../explanation/relay-semantics-and-disclosure/) + for the concepts this authoring model builds on. diff --git a/docs/site/src/content/docs/explanation/data-minimization-and-purpose-limitation.mdx b/docs/site/src/content/docs/explanation/data-minimization-and-purpose-limitation.mdx index edc92de3c..29474c3b5 100644 --- a/docs/site/src/content/docs/explanation/data-minimization-and-purpose-limitation.mdx +++ b/docs/site/src/content/docs/explanation/data-minimization-and-purpose-limitation.mdx @@ -197,7 +197,7 @@ See the [known limitations hub](../known-limitations/) for the full inventory. ## Operator responsibilities: what the design leaves to you -The minimization and purpose-limitation posture described above is the design default, but the +The minimization and purpose-limitation posture this page describes is the design default, but the operator owns the configuration. Several of the protections are conditional, and a reviewer should test the deployment, not the design, for each one. diff --git a/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx b/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx index e3bba5fe9..8aac09760 100644 --- a/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx +++ b/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx @@ -252,7 +252,7 @@ A few boundaries matter when you evaluate this design: - An audience-scoped assertion is not a general-purpose credential: its subject binding is scoped to the audience named in it, so it is meaningful to that relying party and to no other. A holder-bound credential is presentable to several verifiers, and pays for that with the - correlation described above. + correlation described in [Subjects do not travel, and do not correlate](#subjects-do-not-travel-and-do-not-correlate). - Verifying a presentation is not preventing replay: a key-binding JWT proves the presenter held the confirmation key's private key when it was signed, over exactly those bytes. Comparing the challenge nonce is not consuming it, the same presentation verifies again, and retiring a nonce diff --git a/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx b/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx index cc5459a42..b8e0dd7d6 100644 --- a/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx +++ b/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx @@ -51,7 +51,7 @@ independent oversight. This mapping is hand-written documentation. No product emits it: Relay's artifact generator deliberately produces no safeguards matrix and no standards-alignment artifact, and a test holds -that boundary in place. Treat the table below as a reviewed argument you can check against the +that boundary in place. Treat the table in [Safeguards support](#safeguards-support) as a reviewed argument you can check against the cited code, not as an artifact a build step generated. {/* Evidence: the test generated_inventory_covers_required_v1_artifact_classes_only asserts that diff --git a/docs/site/src/content/docs/explanation/integration-patterns.mdx b/docs/site/src/content/docs/explanation/integration-patterns.mdx index 7e0d93303..d57b664fd 100644 --- a/docs/site/src/content/docs/explanation/integration-patterns.mdx +++ b/docs/site/src/content/docs/explanation/integration-patterns.mdx @@ -48,7 +48,7 @@ Evidence Gateway answers one predefined requirement about one set of subjects wi assertion carrying the answer rather than the record. Registry Mint is a supporting service that issues the short-lived access tokens either surface verifies when a deployment has no identity provider. -The patterns below describe Relay and Evidence Gateway. +The patterns that follow describe Relay and Evidence Gateway. ## Country evidence mesh diff --git a/docs/site/src/content/docs/explanation/trusted-context-constraints.mdx b/docs/site/src/content/docs/explanation/trusted-context-constraints.mdx index 1d7aac13f..ae6313979 100644 --- a/docs/site/src/content/docs/explanation/trusted-context-constraints.mdx +++ b/docs/site/src/content/docs/explanation/trusted-context-constraints.mdx @@ -142,7 +142,7 @@ procedure that produces the SQLite file for Relay. Neither response carries one. Relay has no diagnostics command and no configuration report. What is inspectable about a running deployment is what its own metadata routes serve, under the visibility its contract compiled, plus -its audit log. The processing entries described above are the deployment's declaration of legal +its audit log. The processing entries described in [Relay records governance in the contract and enforces purpose in the token](#relay-records-governance-in-the-contract-and-enforces-purpose-in-the-token) are the deployment's declaration of legal basis, purpose, recipient class, and safeguards, and an operator can choose to publish them, bind them to the operations they cover, or withhold them entirely. diff --git a/docs/site/src/content/docs/operate/evidence-audit.mdx b/docs/site/src/content/docs/operate/evidence-audit.mdx index 78b185b16..50b609485 100644 --- a/docs/site/src/content/docs/operate/evidence-audit.mdx +++ b/docs/site/src/content/docs/operate/evidence-audit.mdx @@ -148,5 +148,11 @@ epoch, with a fresh path and a new `hashKeyVersion`: The matching master remains necessary to verify an archived epoch. Do not replace a retained master in place, merge segments from distinct epochs, or delete a chain to make a replacement key work. -Continue with [Retention and persistent state](../retention-and-persistent-state/) for the -surrounding operator procedures. +## Next + +- [Retention and persistent state](../retention-and-persistent-state/) for the surrounding + operator procedures. +- [Rotate credentials, keys, certificates, and trust](../advanced/rotate-credentials-and-trust/) + to rotate the audit-integrity key alongside a deployment's other credentials. +- [Inspect and diagnose a running deployment](../advanced/inspect-and-diagnose/) to use this + audit chain while diagnosing a failing deployment. diff --git a/docs/site/src/content/docs/operate/index.mdx b/docs/site/src/content/docs/operate/index.mdx index 14716d14a..2099d9de9 100644 --- a/docs/site/src/content/docs/operate/index.mdx +++ b/docs/site/src/content/docs/operate/index.mdx @@ -48,6 +48,10 @@ The installer verifies both downloaded binaries against the release `SHA256SUMS` before anything reaches the install directory, installs both or neither, and refuses any other platform rather than guessing. It does not verify release authenticity. +The signed checksum chain that does, and the checks behind it, are recorded in +[OpenSSF and release trust](../security/openssf-evidence/). +Replace `| bash` with `| less` to read the installer before you run it on a host +you operate. For a higher-assurance installation, follow [release verification](https://github.com/registrystack/registry-stack/blob/v0.21.0/release/VERIFY.md) for the pinned tag, then rerun the installer with `RELAY_ASSET_DIR` pointing at diff --git a/docs/site/src/content/docs/operate/relay.mdx b/docs/site/src/content/docs/operate/relay.mdx index cd831d6f1..01e12417b 100644 --- a/docs/site/src/content/docs/operate/relay.mdx +++ b/docs/site/src/content/docs/operate/relay.mdx @@ -34,7 +34,8 @@ administrative trust boundary. Prepare a dedicated Unix service identity, a private listener behind Transport Layer Security (TLS) termination, a sealed package, the matching snapshot or live read-only source, an audit location, independent audit-integrity and cursor-encryption keys, and a token issuer for protected -access profiles. +access profiles. [Configure Registry Mint](../../configure/mint/) when the deployment has no other +identity provider to serve as that issuer. Do not place the package, source, secret, or audit path in a shared writable directory. For snapshot mode, make the SQLite file immutable outside Relay, preferably with a read-only mount. Relay verifies its captured digest before and after every statement, but a process cannot exclude a @@ -186,3 +187,13 @@ Rollback activates a complete prior package only with its compatible source and | A protected access profile returns `404 resource.not_found` | The issuer or token does not satisfy that profile's exact scope | Correct the issuer or caller authority. Do not expose a weaker profile as fallback. | | A spatial request returns `406 format.unsupported` | The selected access profile does not disclose a primary geometry or the format request is unsupported | Select an entitled geometry-bearing profile or request JSON or JSON-LD. | | A response is withheld | The terminal audit write failed | Restore the audit sink and verify its integrity before accepting traffic. | + +## Next + +- [Rotate credentials, keys, certificates, and trust](../advanced/rotate-credentials-and-trust/) + to rotate the audit-integrity, cursor-encryption, or token issuer credentials this deployment + used. +- [Retention and persistent state](../retention-and-persistent-state/) to plan audit segment + retention beyond what this guide covers. +- [Inspect and diagnose a running deployment](../advanced/inspect-and-diagnose/) to diagnose a + failing readiness or authorization check after deployment. diff --git a/docs/site/src/content/docs/reference/api-stability.mdx b/docs/site/src/content/docs/reference/api-stability.mdx index dc006bdf4..1cb433d99 100644 --- a/docs/site/src/content/docs/reference/api-stability.mdx +++ b/docs/site/src/content/docs/reference/api-stability.mdx @@ -194,7 +194,7 @@ Discovery routes under `/.well-known/` follow RFC 8615 and their own upstream pr conventions, so they carry no version prefix in any product. The `evidence-oid4vci` supporting service has eight public routes. They are unversioned by OID4VCI -protocol convention and remain outside the stack compatibility promise described above. +protocol convention and remain outside the compatibility promise this page describes. ## Enforcement diff --git a/docs/site/src/content/docs/reference/apis/index.mdx b/docs/site/src/content/docs/reference/apis/index.mdx index f14270d4e..a21d9583f 100644 --- a/docs/site/src/content/docs/reference/apis/index.mdx +++ b/docs/site/src/content/docs/reference/apis/index.mdx @@ -54,7 +54,7 @@ evidence-oid4vci openapi --output oid4vci.openapi.json ### Rendered source -The card below shows how the rendered Evidence Gateway artifact is selected and links to its +The card shows how the rendered Evidence Gateway artifact is selected and links to its operations. It is generated from `src/data/openapi-sources.yaml`. diff --git a/docs/site/src/content/docs/reference/apis/registry-evidence.mdx b/docs/site/src/content/docs/reference/apis/registry-evidence.mdx index 473714bcb..4f82af1eb 100644 --- a/docs/site/src/content/docs/reference/apis/registry-evidence.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-evidence.mdx @@ -16,7 +16,7 @@ standards_referenced: [Open the Evidence Gateway API operations](../evidence/) -The generated API reference linked above is the authoritative route reference. It is built from +That generated API reference is the authoritative route reference. It is built from Evidence Gateway's generated OpenAPI document. The development docset reads the checked-out current source; archived docsets read their selected release ref. This page carries the context the specification does not: what the service asserts, how authentication works, which response formats diff --git a/docs/site/src/content/docs/reference/contracts.mdx b/docs/site/src/content/docs/reference/contracts.mdx index edea2e963..79337a22d 100644 --- a/docs/site/src/content/docs/reference/contracts.mdx +++ b/docs/site/src/content/docs/reference/contracts.mdx @@ -118,7 +118,7 @@ response and its owning source remain authoritative for runtime behavior. Relay issues no credential. It signs no response, holds no issuing key, and serves no credential-support route. [Evidence Gateway](../../products/registry-evidence/) owns signed assertions and the public verification keys it serves at `/.well-known/evidence/jwks.json`. -Its assertion contract is frozen at Version 1 and is documented with the product rather than listed in the table above. +Its assertion contract is frozen at Version 1 and is documented with the product rather than listed in this page's contract table. A client that needs a signed, minimum-disclosure answer calls Evidence Gateway directly. Evidence Gateway may read a Relay-protected API as one of its fixed HTTP sources, which is a source diff --git a/docs/site/src/content/docs/reference/environment-variables.mdx b/docs/site/src/content/docs/reference/environment-variables.mdx index f08bb79b5..184118da7 100644 --- a/docs/site/src/content/docs/reference/environment-variables.mdx +++ b/docs/site/src/content/docs/reference/environment-variables.mdx @@ -25,7 +25,7 @@ None of the three binaries expands environment references inside its configurati ## Relay -The `relay` binary reads the variables below. +The `relay` binary reads these variables. | Name | Purpose | Default or required | | --- | --- | --- | @@ -48,13 +48,13 @@ A reference that matches neither grammar makes the runtime document invalid, so ### Relay installer -The install script reads the variables below. They are read by the script, not by the running binary. +The install script reads these variables. They are read by the script, not by the running binary. | Name | Purpose | Default or required | | --- | --- | --- | | `RELAY_VERSION` | Relay tag to install. A published installer asset embeds its own tag and refuses an override that does not match it. | Defaults to the installer's pinned tag. | | `RELAY_INSTALL_DIR` | Directory the script installs into. | Defaults to `~/.local/bin`. | -| `RELAY_ASSET_DIR` | Directory of already-downloaded release assets to read instead of downloading. Use it after verifying a release with `release/VERIFY.md`. | Optional. | +| `RELAY_ASSET_DIR` | Directory of already-downloaded release assets to read instead of downloading. Use it after verifying a release with [`release/VERIFY.md`](https://github.com/registrystack/registry-stack/blob/v0.21.0/release/VERIFY.md). | Optional. | The script verifies the downloaded `relay` and `relayctl` binaries against the release `SHA256SUMS` before anything reaches the install directory. It installs both binaries together or @@ -101,7 +101,7 @@ and the bundle it binds. The Evidence Gateway toolset installer stages `evidence`, `evidencectl`, `mint`, and `evidence-oid4vci`, then verifies every binary against `SHA256SUMS` before replacement begins. If a replacement fails, it attempts to restore the previous four-binary set. Checksum verification does -not authenticate `SHA256SUMS`; follow the tag-frozen `release/VERIFY.md` procedure when authenticity +not authenticate `SHA256SUMS`; follow the tag-frozen [`release/VERIFY.md`](https://github.com/registrystack/registry-stack/blob/v0.21.0/release/VERIFY.md) procedure when authenticity matters. The installer reads these variables; the installed binaries do not. | Name | Purpose | Default or required | @@ -119,7 +119,7 @@ matters. The installer reads these variables; the installed binaries do not. ## Registry Mint -The `mint` binary reads the variables below. +The `mint` binary reads these variables. | Name | Purpose | Default or required | | --- | --- | --- | @@ -136,7 +136,7 @@ values. See the [Registry Mint reference](../mint/) for the full configuration s ## Source -The fixed names above are transcribed from the CLI definitions, binary entry points, and install +The fixed environment variable names in this reference are transcribed from the CLI definitions, binary entry points, and install scripts. Relay CLI ownership is in `crates/registry-relay-v2/src/cli.rs`, with logging in `main.rs`, secret grammar in `contract.rs`, and resolution in `startup.rs`. Evidence CLI ownership is in `crates/registry-evidence/src/cli.rs`, logging in `main.rs`, and file-secret resolution in diff --git a/docs/site/src/content/docs/reference/evidence-configuration.mdx b/docs/site/src/content/docs/reference/evidence-configuration.mdx index 88cecab17..bb95e5e59 100644 --- a/docs/site/src/content/docs/reference/evidence-configuration.mdx +++ b/docs/site/src/content/docs/reference/evidence-configuration.mdx @@ -93,10 +93,10 @@ not an enumeration of every combination; consult the configuration guide for how constrains another's. Bounds under *where a rule elsewhere applies* are a second, independent reading. They do not -replace the alternatives above them: a rule elsewhere in the contract tightens whichever +replace the per-alternative constraints: a rule elsewhere in the contract tightens whichever alternative you took, once its condition holds. So `sources.*.baseUrl` always matches one of its two origin patterns, and a source authenticating with `none` must further match the narrower -loopback pattern printed below them. +loopback pattern listed under *where a rule elsewhere applies*. {/* Generated from src/data/generated/evidence-configuration.json, built from products/evidence/contracts/bundle.schema.yaml, diff --git a/docs/site/src/content/docs/reference/index.mdx b/docs/site/src/content/docs/reference/index.mdx index 66d26c400..042914e4b 100644 --- a/docs/site/src/content/docs/reference/index.mdx +++ b/docs/site/src/content/docs/reference/index.mdx @@ -15,7 +15,8 @@ standards_referenced: [] Use these references when you need an exact field, command, interface, contract, version, or term. Start with the task pages when you want -to build or change something. +to build or change something. Some of these pages are also navigated +from the task section that uses them. ## Configuration and commands diff --git a/docs/site/src/content/docs/reference/mint.mdx b/docs/site/src/content/docs/reference/mint.mdx index 26113666a..a662d3022 100644 --- a/docs/site/src/content/docs/reference/mint.mdx +++ b/docs/site/src/content/docs/reference/mint.mdx @@ -305,11 +305,11 @@ before sending this response. If that append fails, the token is not released. ### Errors Token and protocol error responses use `{"error": ""}`, from -`crates/registry-mint/src/error.rs`. Readiness has the separate response described below. +`crates/registry-mint/src/error.rs`. Readiness has the separate response described in [Other endpoints](#other-endpoints). | Code | Status | When | | --- | --- | --- | -| `invalid_request` | `400` | The content type is wrong; the bounded request body is unreadable, oversized, or timed out; a recognized form field is missing or duplicated; authentication methods are mixed; or `client_assertion_type` is not the exact `jwt-bearer` URN. Unknown form members are ignored. A missing `grant_type` lands here, not below. | +| `invalid_request` | `400` | The content type is wrong; the bounded request body is unreadable, oversized, or timed out; a recognized form field is missing or duplicated; authentication methods are mixed; or `client_assertion_type` is not the exact `jwt-bearer` URN. Unknown form members are ignored. A missing `grant_type` lands here, not under `unsupported_grant_type`. | | `unsupported_grant_type` | `400` | `grant_type` is present but is not `client_credentials`. | | `invalid_client` | `401` | Every client authentication failure: unknown client id, wrong authentication method, bad signature or secret, replayed `jti`, or expired assertion. Registry Mint collapses these into one code so the endpoint cannot be used to probe which client ids are registered. A failed HTTP Basic attempt carries `WWW-Authenticate: Basic realm="registry-mint"`. | | `server_error` | `500` | An internal failure, including failure to durably audit the token decision. | @@ -362,7 +362,7 @@ dependency. It treats Mint as an ordinary issuer configured with exact issuer, a types, algorithms, JWKS URI, authority claim names, maximum token lifetime, and denied key ids. Any issuer can be used only when it satisfies that complete token profile. -The exact wire shapes the diagram abbreviates are given in full below: +The exact wire shapes the diagram abbreviates are given in full: [Token endpoint contract](#token-endpoint-contract) for the assertion and token, [Other endpoints](#other-endpoints) for the key set path, and [How Evidence Gateway verifies these tokens](#how-evidence-gateway-verifies-these-tokens) for the claim names. diff --git a/docs/site/src/content/docs/reference/relayctl.mdx b/docs/site/src/content/docs/reference/relayctl.mdx index 3747ef60e..a6fd79a63 100644 --- a/docs/site/src/content/docs/reference/relayctl.mdx +++ b/docs/site/src/content/docs/reference/relayctl.mdx @@ -48,7 +48,7 @@ subcommands. | Option | Effect | | --- | --- | -| `--json` | The seven shared workflow commands emit `relayctl.report.v1` JSON with no header. `tooling editor` emits `relayctl.editor.v1`. `tooling language-server` continues to speak LSP and does not use this flag for report output. Accepted before or after the subcommand. | +| `--json` | The seven shared workflow commands emit `relayctl.report.v1` JSON in place of the readable rendering. `tooling editor` emits `relayctl.editor.v1`. `tooling language-server` continues to speak LSP and does not use this flag for report output. Accepted before or after the subcommand. | | `--version` | Prints `relayctl `, matching the release version of the asset. | | `--help` | Prints usage on standard output and exits with status `0`. | @@ -177,9 +177,13 @@ members: - `details`, a tagged object whose `kind` is `initialized`, `schema-inspection`, `check`, `generate`, `test`, `diff`, or `package`. -Without `--json`, the first line is `relayctl ` and the remaining lines are the -pretty-printed report. With `--json`, the report is the whole output. Rendering is deterministic and -ends with exactly one newline, so the same inputs produce the same bytes. +Without `--json`, the output is a readable rendering of that report: a first line stating the +outcome, then the detail indented under it. The rendering summarizes, so it leaves out parts of the +report a person reading a terminal does not need. `check` prints how many configuration key paths +each document accepts where the report lists them, and `generate` prints each artifact's identifier +and path where the report also carries its digest. With `--json`, the report is the whole output. +Both modes are deterministic and end with exactly one newline, so the same inputs produce the same +bytes. The JSON shape is best-effort for local automation. It is not a covered compatibility surface, and the [compatibility promise](../api-stability/) does not cover adopter tooling. @@ -239,6 +243,11 @@ On Linux amd64, the Relay installer downloads, verifies, and installs the matchi curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/relay-install.sh | bash ``` +The one-line form pipes a script into `bash`; replace `| bash` with `| less` to read it first. The +installer checks both binaries against the release `SHA256SUMS` and stops on a mismatch, but it does +not authenticate the release itself; [OpenSSF and release trust](../../security/openssf-evidence/) +records the signed checksum chain that does. + For Linux arm64 or macOS arm64, take the plain `relayctl` binary asset from the release. You can also use a plain asset on Linux amd64 or build from source. @@ -249,7 +258,8 @@ The release publishes three relayctl assets: - `relayctl--macos-arm64` Each asset is checked at build time to report `relayctl ` for the release version. Verify a -downloaded asset against the release checksums and signatures documented in `release/VERIFY.md` +downloaded asset against the release checksums and signatures documented in +[`release/VERIFY.md`](https://github.com/registrystack/registry-stack/blob/v0.21.0/release/VERIFY.md) before running it. To build from source at a pinned tag: diff --git a/docs/site/src/content/docs/reference/standards.mdx b/docs/site/src/content/docs/reference/standards.mdx index 9dabbd869..8f13ef6be 100644 --- a/docs/site/src/content/docs/reference/standards.mdx +++ b/docs/site/src/content/docs/reference/standards.mdx @@ -128,7 +128,7 @@ What these levels mean for integrators: - **Standard** links to the official standards body page. - **Status** is one of `used`, `referenced`, `evaluated`, `planned`, or `historical`. - **Used by** lists the registry stack projects covered by the displayed claim level. -- **Claim level** uses the six levels defined above. +- **Claim level** uses the six levels defined in [Claim levels](#claim-levels). - **Surface** names the specific output or endpoint that the claim applies to. - **Profile and notes** identifies the version or profile in use and any boundary conditions. - **Evidence** links to the source code, fixture, or document that supports the claim. @@ -138,7 +138,7 @@ What these levels mean for integrators: - OGC API Features and OGC API EDR were feature-gated routes on the retired Relay V1 runtime, and no maintained surface implements them. Both are recorded at `compares_against` with no user, and their pinned V1 tests are kept as the record of a surface that no longer ships. Relay's current spatial - surface is the CRS84 Point profile above, which makes no OGC API conformance claim. OGC API Records + surface is the CRS84 Point profile described in [Relay spatial wire-format profile](#relay-spatial-wire-format-profile), which makes no OGC API conformance claim. OGC API Records stays at `emits` because Registry Manifest still publishes static Records item collections; only Relay's live Records adapter went with V1. See [Relay V1 and registryctl retirement](../../decisions/relay-v1-and-registryctl-retirement-2026-08-11/). diff --git a/docs/site/src/content/docs/security/report-a-vulnerability.mdx b/docs/site/src/content/docs/security/report-a-vulnerability.mdx index 49d8ef06d..4ba071521 100644 --- a/docs/site/src/content/docs/security/report-a-vulnerability.mdx +++ b/docs/site/src/content/docs/security/report-a-vulnerability.mdx @@ -40,8 +40,8 @@ the report. [GitHub Security Advisories](https://github.com/registrystack/registry-stack/security/advisories/new). 2. If GitHub Security Advisories is unavailable, contact the maintainer through an existing private project channel instead of opening a public issue or pull request. -3. Include the affected commit, config shape, reproduction steps, and impact from the checklist - above. +3. Include the affected commit, config shape, reproduction steps, and impact from the + [Before you start](#before-you-start) checklist. Registry Stack aims to acknowledge private reports within 5 business days. diff --git a/docs/site/src/content/docs/spec/rs-pr-relayctl.mdx b/docs/site/src/content/docs/spec/rs-pr-relayctl.mdx index da060e45e..0e493eab2 100644 --- a/docs/site/src/content/docs/spec/rs-pr-relayctl.mdx +++ b/docs/site/src/content/docs/spec/rs-pr-relayctl.mdx @@ -31,6 +31,7 @@ Stack `v1.0.0`, not with a pre-1.0 release. | Version | Date | Status | Change | | --- | --- | --- | --- | +| 1.0.2 | 2026-08-19 | draft | Specified the default command output as a human-readable summary derived from the workflow report, replacing the `relayctl ` header and the verbatim report body. `--json` output is unchanged. | | 1.0.1 | 2026-08-13 | draft | Distinguished the seven project workflow commands from the bounded `tooling` namespace and specified the editor report and Language Server Protocol output separately from workflow reports. | | 1.0.0 | 2026-08-11 | draft | Replaced the retired `registryctl` compatibility contract with the `relayctl` contract. The identifier prefix changed from `REQ-PR-REGISTRYCTL-` to `REQ-PR-RELAYCTL-`, so no retired requirement carries a second meaning. REQ-PR-REGISTRYCTL-001 through REQ-PR-REGISTRYCTL-031 are retired in full and MUST NOT be reused. Requirements without a successor include the ten-root command surface, `registry-stack.yaml` project discovery, environment selection, the `http` and `spreadsheet` templates, the disposable development runtime, the trust and approved-set surfaces, the versioned report schemas, and the operator-owned deployment package. | | 0.2.0 | 2026-07-31 | draft | Aligned the retired contract with the 1.0 command and deployment design. | @@ -242,9 +243,11 @@ tagged by kind. REQ-PR-RELAYCTL-026: With `--json`, a project workflow command MUST write only its workflow report to standard output, and `tooling editor` MUST write only its editor-setup report. -Without `--json`, a project workflow command MUST write a `relayctl ` header followed by -the same report content, while `tooling editor` MUST write a human-readable summary derived from its +Without `--json`, a project workflow command MUST write a human-readable summary derived from its +workflow report, while `tooling editor` MUST write a human-readable summary derived from its editor-setup report. +A human-readable summary MUST carry the report status and every diagnostic, and MUST NOT be the +report document. `--json` MUST be accepted before or after the command. For `tooling language-server`, `--json` MUST NOT change the LSP byte stream or introduce command output. @@ -327,8 +330,10 @@ This specification is `verified`: the requirements describe implemented behavior tests. {/* Evidence: crates/registry-relayctl/src/lib.rs defines the seven workflow commands, bounded - tooling namespace, global --json flag, required package --output, exit-code constants, workflow - and editor report rendering, and language-server LSP dispatch. */} + tooling namespace, global --json flag, required package --output, exit-code constants, the + choice between report document and human-readable summary, and language-server LSP dispatch. */} +{/* Evidence: crates/registry-relayctl/src/report.rs renders the human-readable workflow summary and + pins that it carries the status and every diagnostic and is never the report document. */} {/* Evidence: crates/registry-relayctl/src/shared.rs is the single dependency seam from the command line into the shared tooling facade. */} {/* Evidence: crates/registry-relayctl/tests/cli_contract.rs pins the one-binary workflow and tooling diff --git a/docs/site/src/content/docs/start/evaluate-evidence.mdx b/docs/site/src/content/docs/start/evaluate-evidence.mdx index e2d12e5db..03f857193 100644 --- a/docs/site/src/content/docs/start/evaluate-evidence.mdx +++ b/docs/site/src/content/docs/start/evaluate-evidence.mdx @@ -13,16 +13,15 @@ standards_referenced: [] This page is for someone sizing up Evidence Gateway before committing infrastructure, security review, and operational capacity to it. It assumes the fit question -from [When Registry Stack fits](../when-to-use/) is already settled and asks +in [Which product fits your problem](../when-to-use/) is already settled and asks the next one: what does running it actually cost. ## Runtime footprint -Evidence Gateway is one crate, `registry-evidence`, and one binary, `evidence` -(`crates/registry-evidence/Cargo.toml`). There is no separate control plane or worker process: one +Evidence Gateway is one crate, `registry-evidence`, and one binary, `evidence`. There is no separate control plane or worker process: one Evidence Gateway process serves one operator-controlled trust domain. Production and evidence-grade signing also requires an operator-managed workload-local Transit proxy -(`products/evidence/README.md`). +([Evidence Gateway product overview](../../products/registry-evidence/)). At startup the process reads two inputs, both mounted read-only: a closed operator `runtime.yaml` that binds one listener, bundle directory, secret @@ -30,14 +29,14 @@ root, audit destination, and local TLS trust files; and an immutable, reviewed evidence bundle directory holding the deployment's YAML, Rhai scripts, schemas, codelists, and fixtures. Neither input may be writable to the service process; startup and readiness fail when either is incomplete, -inconsistent, or mutable (`products/evidence/OPERATOR-CONTRACT.md`). +inconsistent, or mutable ([operator contract](../../products/registry-evidence/operator-contract/)). It needs a governed ES256 P-256 public key whose `kid` is its RFC 7638 thumbprint, plus two independently generated raw secrets of at least 32 bytes, one for the audit hash chain and one for subject-binding pseudonyms. Production and evidence-grade mode use a workload-local Vault or OpenBao Transit proxy over a Unix socket. Local assurance may use an owner-only P-256 private JWK under the secret root (mode `0700`, file mode `0600`) -(`products/evidence/OPERATOR-CONTRACT.md`). +([operator contract](../../products/registry-evidence/operator-contract/)). The runtime requires a complete deployment layout like this: @@ -66,7 +65,7 @@ private local runtime layout shown here (`crates/registry-evidencectl/src/scaffo ## Dependencies -Evidence Gateway does not run against a database. `products/evidence/OPERATOR-CONTRACT.md` +Evidence Gateway does not run against a database. The [operator contract](../../products/registry-evidence/operator-contract/) states it directly: "Evidence Gateway Version 1 has no application database and persists no selector, source, evidence, or response data." The one place Evidence Gateway writes durable state is the audit trail, and that is a keyed JSONL @@ -76,8 +75,8 @@ a durable keyed JSONL chain" (`crates/registry-evidence/src/audit.rs`). An external durable audit service may own that storage instead, but nothing in the runtime requires a database engine to reach it. -The same boundary excludes a message broker and workers; `products/evidence/README.md` -lists both among what Version 1 does not include. `products/evidence/CONCEPT.md` +The same boundary excludes a message broker and workers; the [product overview](../../products/registry-evidence/) +lists both among what Version 1 does not include. The [concept](../../products/registry-evidence/concept/) states the infrastructure implication for the native deployment target directly: "It does not require Kubernetes, a message broker, a database, OPA, or a service mesh." @@ -103,7 +102,7 @@ Build the toolset from source with `cargo build --release --locked -p registry-evidence -p registry-evidencectl -p registry-mint`, or, for a tagged release that publishes it, install reproducible bare binaries through the pinned installer script -(`products/evidence/README.md`). +([Evidence Gateway product overview](../../products/registry-evidence/)). Starting with `v0.21.0`, Registry Stack publishes official `ghcr.io/registrystack/evidence:v0.21.0` and @@ -146,7 +145,7 @@ Rotation is the operator's job too: a deployment keeps one active signing key at a time and must retain every previous public key in the published JWKS for at least the maximum assertion validity plus allowed clock skew, or a verifier holding an older cached assertion will fail to check it -(`products/evidence/OPERATOR-CONTRACT.md`). +([operator contract](../../products/registry-evidence/operator-contract/)). ### Deployment inputs @@ -155,7 +154,7 @@ Evidence Gateway will start; a read-only mount is preferred. An operator can rem write bits with `chmod -R a-w bundle` and `chmod 444 runtime.yaml` before running `evidence check`. Editing either input means restoring write access, making the change, removing write access again, and rerunning the check -(`products/evidence/OPERATOR-CONTRACT.md`). There is no hot reload, override +([operator contract](../../products/registry-evidence/operator-contract/)). There is no hot reload, override layer, or runtime mutation API. ### Audit trail @@ -171,7 +170,7 @@ sealed segment. `evidence verify-audit` is the out-of-band command for proving sealed history was not tampered with, and the operator contract documents specific rollback hazards in detail, most notably that renaming or replacing the active segment incorrectly can silently fork the chain -(`products/evidence/OPERATOR-CONTRACT.md`). +([operator contract](../../products/registry-evidence/operator-contract/)). ### Readiness and liveness are different questions @@ -181,16 +180,16 @@ while any required secret or source credential is absent" signing provider, the pinned audit sink, and every source credential, including a bounded OAuth client-credentials bootstrap where that authentication kind is configured. Neither check sends a request to a source -or probes a source data endpoint (`products/evidence/OPERATOR-CONTRACT.md`). +or probes a source data endpoint ([operator contract](../../products/registry-evidence/operator-contract/)). Route traffic on `/ready`, not `/health`. Telemetry is opt-in: leaving `metricsListener` unset in `runtime.yaml` serves none of it, and setting it only opens a second, private-address-only -listener (`products/evidence/OPERATOR-CONTRACT.md`). +listener ([operator contract](../../products/registry-evidence/operator-contract/)). ## Performance posture -`products/evidence/PERFORMANCE.md` states its own scope plainly: nothing in +The [performance note](../../products/registry-evidence/performance/) states its own scope plainly: nothing in it is a Version 1 contract. There is no throughput commitment to evaluate against, only kept measurements. @@ -203,7 +202,7 @@ a disk barrier is in flight form the next batch, and one sync covers the whole batch, while every append still resolves only after the barrier that covers its own bytes (`crates/registry-evidence/src/audit.rs`). -The measurement kept in `products/evidence/OPERATOR-CONTRACT.md` under +The measurement kept in the [operator contract](../../products/registry-evidence/operator-contract/) under "Measured throughput" sustained 7057 requests per second at 128 concurrent requests with zero non-2xx responses and a 17.89 ms p50, on an Apple M5 Max under macOS, with every request running token verification, rate limiting, @@ -236,22 +235,22 @@ change" (root `AGENTS.md`). Evidence Gateway does not carry a separate, more permissive statement. Within that, Evidence Gateway's Version 1 assertion contract is treated as -implemented rather than exploratory. `products/evidence/README.md` gives its +implemented rather than exploratory. The [product overview](../../products/registry-evidence/) gives its status as "implemented Version 1 contracts, runtime, reference deployments, -and reproducible Evidence Gateway-specific verification gates," and -`products/evidence/OPERATOR-CONTRACT.md` carries the matching "Implemented +and reproducible Evidence Gateway-specific verification gates," and the +[operator contract](../../products/registry-evidence/operator-contract/) carries the matching "Implemented Version 1 operator contract" status. Four assertion cases, adult status, residence region, professional licence status, and legal-parent relationship, are coequal acceptance definitions; the product is not considered implemented while only a subset of them passes -(`products/evidence/AGENTS.md`). +([concept](../../products/registry-evidence/concept/)). What stays explicitly out of scope remains so until a separately approved profile changes it: document evidence, credential-lifecycle features beyond the SD-JWT VC serialization, multi-source fulfillment, the delegated-agent grant profile, a public or federated catalog, and OOTS execution are all -named non-goals rather than roadmap items (`products/evidence/CONCEPT.md`). -`products/evidence/OPERATOR-CONTRACT.md` closes on the same note: "Future +named non-goals rather than roadmap items ([concept](../../products/registry-evidence/concept/)). +The [operator contract](../../products/registry-evidence/operator-contract/) closes on the same note: "Future profiles require a separately approved concept and plan." {/* TODO[evidence]: no published versioning or backward-compatibility policy @@ -261,6 +260,6 @@ upgrade continuity. */} ## Next -- [When Registry Stack fits](../when-to-use/) +- [Which product fits your problem](../when-to-use/) - [Configure Evidence Gateway](../../configure/evidence/) - [Evidence Gateway API reference](../../reference/apis/registry-evidence/) diff --git a/docs/site/src/content/docs/start/quickstart.mdx b/docs/site/src/content/docs/start/quickstart.mdx deleted file mode 100644 index 17488cf5a..000000000 --- a/docs/site/src/content/docs/start/quickstart.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Start with Registry Stack -description: Answer a bounded question with Evidence Gateway, or publish protected records with Registry Relay over a reviewed SQLite view. -status: current -owner: registry-docs -source_repos: - - registry-stack -last_reviewed: "2026-08-11" -doc_type: explanation -locale: en -standards_referenced: [] ---- - -Pick the door that matches what your caller needs. To learn only a fact about -one subject, start with Evidence Gateway. To read specific records or fields, -start with Registry Relay over the supplied synthetic business Registry. Both -first runs use one terminal and synthetic data, and neither needs production -keys or an institution-owned source. - -## Answer a bounded question with Evidence Gateway - -Evidence Gateway signs the answer to one bounded question about one subject without -releasing the record behind it. -[Evidence Gateway overview](../evidence-quickstart/) explains the source, assertion, -verification, and audit boundaries. -[Get your first Evidence Gateway assertion](../../tutorials/first-evidence-assertion/) -then connects a visible Python registry, sends a real request, and verifies the -minimum answer before reading the answer. - -That first run needs no source checkout. The released `evidencectl` installer -provides the toolset and the local runtimes the tutorial uses. - -## Publish protected records with Registry Relay - -Registry Relay serves one Registry per process from a sealed package compiled out -of one reviewed contract over read-only SQLite views. -[Publish a governed SQLite registry](../../tutorials/publish-governed-sqlite-registry/) -builds a small business register from scratch: a SQLite view, a reviewed -contract, `relayctl check --production`, `generate`, and `package`, then a -running service answering with the two fields the review released and refusing -the third. - -Take this path when the institution can expose a reviewed read-only SQLite view -of the records it wants to publish. Relay reads no other source: not a -spreadsheet, not a live third-party API, and not a raw database table. -Continue with [author a Registry Relay project](../../configure/relay/) once the -first register works. - -This run needs no source checkout either. The installer provides matching -`relay` and `relayctl` binaries for Linux amd64. `relayctl` also ships as a -plain release binary for Linux amd64, Linux arm64, and macOS arm64. The runtime -also ships as a container image, which is an operator input rather than a -tutorial prerequisite. - -## Keep the two doors separate - -An institution can operate Registry Relay and Evidence Gateway, but they are -independent products with separate sources, authorization, configuration, and -audit boundaries. An Evidence bundle may name a Relay-served API as one of its -fixed HTTP sources, but Evidence Gateway does not inherit Relay's authorization -decision, and Relay does not sign its responses. - -Choose the tutorial for the result you need. Start with the Evidence Gateway -tutorial for a signed, minimum-disclosure answer, or with the Relay tutorial for -a protected record API. diff --git a/docs/site/src/content/docs/start/when-to-use.mdx b/docs/site/src/content/docs/start/when-to-use.mdx index 2e357270d..b2004a0cb 100644 --- a/docs/site/src/content/docs/start/when-to-use.mdx +++ b/docs/site/src/content/docs/start/when-to-use.mdx @@ -1,6 +1,6 @@ --- -title: When Registry Stack fits -description: Decide whether Registry Stack matches the access problem your institution needs to solve. +title: Which product fits your problem +description: Decide whether Registry Stack matches the access problem your institution needs to solve, and which of its two doors answers it. status: current owner: registry-docs source_repos: diff --git a/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx b/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx index 3911d5de6..c549b2f1f 100644 --- a/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx +++ b/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx @@ -173,7 +173,7 @@ evidencectl dev --detach ``` ```text -Evidence Gateway ready at http://127.0.0.1:8080 +Evidence ready at http://127.0.0.1:8080 Mint ready at http://127.0.0.1:8081 ``` @@ -247,6 +247,17 @@ It does not record either source identifier or the boolean value. Return to the registry terminal and press `Ctrl+C`. +## If a local port is already in use + +This tutorial needs three loopback ports: `8002` for `registry.py`, and `8080` and `8081` for +Evidence Gateway and Registry Mint. If another tutorial's services are still running, +`evidencectl dev --detach` cannot bind. Stop them with `evidencectl dev stop` in that project, or +start this one on two unused ports with `--evidence-port` and `--mint-port`. + +Port `8002` appears twice inside `registry.py`, in the OpenAPI `servers` URL and in the listener, +and again in the `--openapi` argument. Change all three before running `evidencectl new`, because +the project retains the server URL it was created from. + ## Next - [Issue registered-parent evidence from OpenCRVS](../verify-a-registered-parent-with-opencrvs/) diff --git a/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx b/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx index f0991e829..2c8116dbf 100644 --- a/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx +++ b/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx @@ -44,6 +44,11 @@ evidence --version evidencectl --version ``` +These binaries produce the candidate you hand to an operator, so verify the downloaded assets against the +signed `SHA256SUMS` for that tag before you install them, following +[OpenSSF and release trust](../../security/openssf-evidence/). To read the installer before it runs, +replace `| bash` with `| less`. + Keep all source responses, credentials, tokens, and local `.evidence/` state outside the candidate. ## Add production metadata and fixtures diff --git a/docs/site/src/content/docs/tutorials/connect-a-sqlite-extract.mdx b/docs/site/src/content/docs/tutorials/connect-a-sqlite-extract.mdx index 47fb218f5..686d43f70 100644 --- a/docs/site/src/content/docs/tutorials/connect-a-sqlite-extract.mdx +++ b/docs/site/src/content/docs/tutorials/connect-a-sqlite-extract.mdx @@ -44,7 +44,10 @@ printing selector or source values. ## Read the editable path -The files that make one source work are grouped by responsibility: +These are the files that make one source work, grouped by responsibility. The project holds more +than this: a `.gitignore` that keeps `secrets/` out of version control, a project marker file, +disposable local key material under `secrets/`, and the editor schema mappings your YAML tooling +reads. ```text registry-status/ diff --git a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx index 06430b602..3bf1b8c60 100644 --- a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx +++ b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx @@ -41,8 +41,10 @@ cd institution-evidence ``` The project now contains the retained `source.openapi.yaml` and empty editable directories for -`selectors`, `sources`, `adapters`, `schemas`, `questions`, and `derivations`. The command does not -invent a source, question, fixture, or deployment policy. +`selectors`, `sources`, `adapters`, `schemas`, `questions`, `derivations`, and `fixtures`. It also +holds a `.gitignore` that keeps `secrets/` out of version control, a project marker file, the +disposable local keys, and the editor schema mappings your YAML tooling reads. The command does +not invent a source, question, fixture, or deployment policy. ## Review the available source fields @@ -73,20 +75,21 @@ evidencectl source suggest \ --select /date_of_birth ``` -The command writes new files only: +It writes six new files, and prints one `wrote` line for each in this order: ```text sources/people.yaml adapters/people-prepare.rhai -adapters/people-extract.rhai schemas/people-parameters.schema.yaml schemas/people-response.schema.yaml +adapters/people-extract.rhai schemas/people-facts.schema.yaml ``` `sources/people.yaml` is an ordinary Version 1 source object, not an intermediate template. The schemas and scripts are the same artifacts the generated local bundle will contain. A repeated -command refuses to overwrite any edited file. +command refuses to overwrite any edited file: it names the files that already exist and writes +nothing at all, rather than writing the new ones and stopping partway. ## Define the authorized selector @@ -227,5 +230,12 @@ creates one private local generation, and runs the real `evidence check` gate be missing artifact, unresolved draft marker, invalid secret reference, unbounded schema, or inconsistent selector binding stops the generation. +`--detach` returns once both services answer on loopback, so the pair keeps running after the +command exits. Stop it when you are finished: + +```sh +evidencectl dev stop +``` + Continue with [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/) after the project's source, derivation, and synthetic acceptance cases have been independently reviewed. diff --git a/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx b/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx index 0cd795988..b04dd6be5 100644 --- a/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx +++ b/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx @@ -106,11 +106,8 @@ Review the governed policies before starting Evidence Gateway: evidencectl access policy list ``` -```text -POLICY QUESTIONS -age-checks adult-status -service-routing age-bracket -``` +The command prints a tab-separated `POLICY` and `QUESTIONS` table, one row per policy: `age-checks` +holding `adult-status`, and `service-routing` holding `age-bracket`. The commands write the reviewable policy documents to `access/policies/age-checks.yaml` and `access/policies/service-routing.yaml`. @@ -233,11 +230,8 @@ Review the active client assignments: evidencectl access client list ``` -```text -CLIENT STATUS POLICIES -age-checker active age-checks -service-router active service-routing -``` +The command prints a tab-separated `CLIENT`, `STATUS`, and `POLICIES` table. Both `age-checker` and +`service-router` are `active`, each under the single policy it was registered with. ## Use the application assigned the policy @@ -358,15 +352,11 @@ the application. That token remains valid for up to 300 seconds. Try to prepare a fresh request as the revoked client: ```sh -if evidencectl request prepare adult-status \ +evidencectl request prepare adult-status \ --purpose age-check \ --subject person_id=person-123 \ --client age-checker \ --name age-checker-revoked -then - echo 'unexpected request preparation success' >&2 - exit 1 -fi ``` ```text diff --git a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx index a0c6c9f73..0e55b24a7 100644 --- a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx +++ b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx @@ -59,6 +59,11 @@ evidencectl --version The installer provides `evidencectl` and the local Evidence Gateway and Registry Mint runtimes used later in the tutorial. +The command pipes a script from GitHub straight into `bash`, so replace `| bash` with `| less` first if you +would rather read it before it runs. On a tutorial machine that is your own call, but before these binaries +reach a host that serves anyone else, check them against the signed `SHA256SUMS` chain described in +[OpenSSF and release trust](../../security/openssf-evidence/). + ## Preview a synthetic source Create a working directory: @@ -174,6 +179,7 @@ The new project separates those decisions from the retained API description: ```text adult-status/ +├── evidence-project.yaml ├── source.openapi.yaml ├── selectors/ ├── sources/ @@ -181,6 +187,7 @@ adult-status/ ├── schemas/ ├── questions/ ├── derivations/ +├── fixtures/ └── secrets/ ``` diff --git a/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx b/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx index 8bc0ad0d7..f5ed5e32c 100644 --- a/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx +++ b/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-06" +last_reviewed: "2026-08-19" doc_type: how-to persona: - operator @@ -62,11 +62,11 @@ bind with a dedicated named volume shared only by the proxy and Evidence Gateway The Evidence Gateway service mounts five independently owned paths: ```text -candidate/bundle -> /etc/registry-evidence/bundle read-only -runtime.docker.yaml -> /etc/registry-evidence/runtime.yaml read-only -Evidence Gateway secret root -> /run/secrets/registry-evidence read-only -Evidence Gateway audit volume -> /var/lib/registry-evidence writable -Transit socket directory -> /run/registry-evidence socket access +candidate/bundle -> /etc/registry-evidence/bundle read-only +runtime.docker.yaml -> /etc/registry-evidence/runtime.yaml read-only +Evidence secret root -> /run/secrets/registry-evidence read-only +Evidence audit volume -> /var/lib/registry-evidence writable +Transit socket directory -> /run/registry-evidence socket access ``` Keep the bundle and runtime read-only. A read-only mount establishes their immutability, but does @@ -78,6 +78,8 @@ Prepare the persistent audit volume for that same identity before startup. Do no root to compensate for an audit volume with the wrong owner. ```yaml +# Mounts and service identity only. This is not a complete service definition; +# the note below this block names what it leaves out. services: evidence: image: @@ -91,6 +93,12 @@ services: - :/run/registry-evidence:ro ``` +That snippet shows the mounts and the service identity only. The maintained adapter at +`docker/compose/docker-compose.yaml` in the Registry Stack repository carries the rest of the +service posture, including dropped capabilities, `no-new-privileges`, and a read-only `/dev/shm` +that replaces the writable one Docker adds by default. Start from that file rather than from the +mounts alone. + Bind Evidence Gateway to a private Compose-network address. Put TLS termination and public routing in an operator-controlled service ahead of that listener. @@ -101,16 +109,54 @@ ownership, paths, and trust files are in place: ```sh docker compose run --rm evidence \ - --runtime /etc/registry-evidence/runtime.yaml check + --runtime /etc/registry-evidence/runtime.yaml check --require-runtime-dependencies +``` + +It prints one line and exits zero: + +```text +Evidence deployment / passed check ( requirements) ``` +Both revisions are your own candidate's, and the count is the number of requirements in your +bundle, so the line will not match anyone else's. + +`--require-runtime-dependencies` is what turns this into a check of the container rather than of +the files. Without it, the command compiles the bundle, compiles the source plans, refuses a +mounted extract already older than its source allows, and validates the mounted secret material. +With it, the command also opens the configured audit path, signs a self-test +message through the Transit proxy and verifies it against the governed public JWK, and resolves +every configured source credential. It appends no audit event in either form, so a check that fails +leaves the chain exactly as it found it. + Changing only the container runtime does not change the governed bundle, so it does not require the fixture suite to run again. Run fixtures again when the bundle changes. -The check exits successfully only when the container can read its immutable inputs, resolve the -secret root, validate the configured audit path, reach the Transit proxy, match the pinned provider -version to the governed public JWK, and validate the runtime and bundle together. It does not append -an audit event. +## Start the service + +```sh +docker compose up -d +docker compose ps +``` + +The service reads its runtime from `REGISTRY_EVIDENCE_RUNTIME`, which the maintained image already +points at `/etc/registry-evidence/runtime.yaml`, and its default command is `serve`. No further +arguments are needed. + +Evidence Gateway writes line-delimited JSON to stdout. It announces the listener only after every +listener is bound, so the announcement means the port is this deployment's and not something else's: + +```sh +docker compose logs evidence | grep 'evidence service listening' +``` + +That record carries the bundle revision, the runtime revision, the bind host, and the port. If no +such line appears, read the whole log: startup failures are reported there, and the container will +have exited. + +The runtime in this guide binds a Compose-network address with no published ports, so `/health` is +reachable from another service on the same Compose network and not from your host. Publish a port +only behind the TLS-terminating service you control. ## Keep revisions distinct @@ -131,13 +177,23 @@ Do not share the Evidence Gateway proxy or socket with Mint. ## Stop without deleting the audit history -Stop the Compose services without removing the audit volume. Retention and audit-chain verification -remain operator responsibilities. +`docker compose down` removes the containers and the network it created. It leaves named volumes +alone, so the audit chain survives the stop: ```sh docker compose down +docker volume ls | grep evidence-audit ``` +The volume is still listed. Starting the service again appends to the same chain rather than +beginning a new one. + +:::danger +`docker compose down -v` deletes that volume with everything else. The audit chain is +hash-linked and has no second copy, so the history is gone and cannot be reconstructed. Retention +and audit-chain verification remain operator responsibilities. +::: + ## Next - [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/) diff --git a/docs/site/src/content/docs/tutorials/issue-a-birth-certificate-vc-from-opencrvs.mdx b/docs/site/src/content/docs/tutorials/issue-a-birth-certificate-vc-from-opencrvs.mdx index 782959abe..5a2f2516d 100644 --- a/docs/site/src/content/docs/tutorials/issue-a-birth-certificate-vc-from-opencrvs.mdx +++ b/docs/site/src/content/docs/tutorials/issue-a-birth-certificate-vc-from-opencrvs.mdx @@ -7,7 +7,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-07" +last_reviewed: "2026-08-19" doc_type: tutorial persona: - assertion provider @@ -31,7 +31,7 @@ Credential (SD-JWT VC). The credential exposes `givenName`, `familyName`, `dateO 'The completed OpenCRVS registered-parent tutorial project', 'A Record Search client for the public OpenCRVS demo', 'The Evidence Gateway toolset', - 'curl and an editor', + 'curl, python3, awk, and an editor', ]} /> @@ -476,8 +476,13 @@ The supported value retains the governed evidence form: } ``` +The angle brackets are this page's, not the payload's. Your output carries the date of birth and +location identifier the demo instance holds; the names appear here because you already searched +for them. + The signed credential carries an always-visible `birthCertificate` container and four nested -disclosures. Count the disclosure segments in the verified credential: +disclosures, one for each top-level field in `schemas/birth-certificate.yaml`. Count the +disclosure segments in the credential: ```sh awk -F '~' '{print "disclosures:", NF - 2}' opencrvs-birth-certificate.sd-jwt @@ -487,26 +492,62 @@ awk -F '~' '{print "disclosures:", NF - 2}' opencrvs-birth-certificate.sd-jwt disclosures: 4 ``` +An SD-JWT VC serializes as the issuer JWT, one segment per disclosure, and a trailing tilde, which +is why the count is the number of tilde-separated fields minus two. + Evidence Gateway's V1 verifier checks the complete stored SD-JWT VC response. Wallet presentation, omission of selected disclosures, and key-binding JWT validation remain outside this profile. ## Inspect the audit and clean up -Stop the services, verify the last operation, and remove the sealed local generation: +Stop the services: ```sh evidencectl dev stop +``` + +Inspect the last operation: + +```sh evidencectl audit show --last-operation -evidencectl dev clean ``` -The audit records the authorized requirement, purpose, requester pseudonym, decision, response -format, and disclosed concept. It does not record the child selector, OpenCRVS token, source -response, or certificate fields. +```text +ACCESS AUTHORIZED birth-certificate civil-registration-extract requester= +DISCLOSURE RELEASED birth_certificate +``` + +The requester value changes on each fresh project. + +Read those two lines for what they leave out. +They name the requester pseudonym, the question, the purpose it was authorized under, and the one +concept released. +Neither line carries a given name, a family name, a date of birth, or a location identifier, so +the trail records that a birth certificate was released without becoming a second copy of it. +Neither line carries the child's national ID, the OpenCRVS token, or the search response. +An operator reviewing this trail can establish who asked, why, and what was released, and cannot +reconstruct the certificate from it. + +Remove the sealed local generation: + +```sh +evidencectl dev clean +``` Delete the Record Search client in OpenCRVS when you finish. Remove its two credential files when you no longer need the project. +## Troubleshooting + +| Symptom | Cause | Resolution | +| --- | --- | --- | +| The request returns no assertion for a subject that worked earlier | The demo was reset and the synthetic registration no longer exists under that national ID, so the fixed search matched nothing. | Sign in to the demo as the prerequisite tutorial describes and confirm the record is still there. Leave the extractor's `no_match` path alone; a missing registration is an answer. | +| Evidence Gateway reports an ambiguous source result | More than one registered birth now carries that national ID in the demo data. | Keep `resultLimit: 2` and the `ambiguous` outcome. An ambiguous civil-registration match is a fact about the record set. | +| The OpenCRVS token request fails with 401 | The Record Search client was deleted, its secret was rotated, or the demo reset its integrations. | Create a new Record Search client and rewrite `secrets/opencrvs-client-id` and `secrets/opencrvs-client-secret`. Keep both values out of shell history and tracked files. | +| Requests start failing after several runs | OpenCRVS audits its searches and applies a daily request limit on the integration demo. | Wait for the limit to reset before running the tutorial again. Do not add retries around the source. | +| Evidence Gateway reports a source dependency failure | The demo host was unreachable or slow, or the response exceeded `timeoutMilliseconds` or `maximumResponseBytes`. | Retry later. Treat the bounds as reviewed source policy, and raise one only after deciding it is right for the deployment. | +| The token request or the search fails with a certificate error | An intercepting proxy or an out-of-date trust store on your machine. | Repair the trust store, or exempt the demo hosts from interception. Do not reach for `--insecure`: the source denies redirects and reaches OpenCRVS over HTTPS only. | + ## Next - [Request another Evidence Gateway assertion as SD-JWT VC](../request-evidence-as-sd-jwt-vc/) diff --git a/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx b/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx index 4531bf9f4..872c58a0a 100644 --- a/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx +++ b/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx @@ -1,12 +1,11 @@ --- title: Issue minimum-disclosure credentials from FHIR description: Connect Evidence Gateway to a public FHIR R4 server and verify patient-coverage and healthcare-establishment answers as SD-JWT VC responses. -status: draft -draft: true +status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-10" +last_reviewed: "2026-08-19" doc_type: tutorial persona: - assertion provider @@ -20,9 +19,9 @@ import QuickstartMeta from '../../../components/QuickstartMeta.astro'; Connect Evidence Gateway to the public SMART Health IT FHIR R4 server through a minimal local read-through adapter, then verify two minimum-disclosure answers as Selective Disclosure JSON Web -Token Verifiable Credential (SD-JWT VC) responses. One answer protects a patient by disclosing a -coverage result rather than a FHIR resource. The other demonstrates the same pattern for a -healthcare establishment. +Token Verifiable Credential (SD-JWT VC) responses. One answer protects a patient: it discloses a +coverage result instead of the FHIR resource behind it. The other applies the same pattern to a +healthcare establishment, where the subject is an organization rather than a person. @@ -80,49 +80,134 @@ In either case, Evidence Gateway returns no assertion. ## Choose a synthetic child -Open the [public DHIS2 demo](https://play.im.dhis2.org/stable-2-43-1/) and sign in with its -provider-published shared credentials: - -```text -Username: admin -Password: district -``` - -In Tracker Capture, select the `Child Programme` and the `Ngelehun CHC` organisation unit. -Choose a synthetic child whose `Birth` and `Baby Postnatal` events carry a value in all five -immunization fields. -Those events may be `ACTIVE` or `COMPLETED`. - -Note its tracked entity identifier and keep it out of tracked files. -The identifier selects the source record. -It will be used for the DHIS2 read and the Evidence Gateway request, but it will not appear in the -signed assertion. - -## See the DHIS2 boundary - -Create an owner-only curl configuration at `.local/dhis2.curl`: +Create a working directory and an owner-only curl configuration at `.local/dhis2.curl`: ```sh +mkdir dhis2-immunization-tutorial +cd dhis2-immunization-tutorial umask 077 mkdir -p .local touch .local/dhis2.curl chmod 600 .local/dhis2.curl ``` -Open the file and add the public demo credential: +Open the file and add the provider-published shared credential for the +[public DHIS2 demo](https://play.im.dhis2.org/stable-2-43-1/): ```text user = "admin:district" ``` -Set the source and subject values in your terminal: +The same account signs in to the demo's web interface, where Tracker Capture shows the +`Child Programme` records this tutorial reads. + +Set the source values in your terminal: ```sh export DHIS2_BASE_URL='https://play.im.dhis2.org/stable-2-43-1' export DHIS2_PROGRAM_ID='IpHINAT79UW' -export DHIS2_TRACKED_ENTITY_ID='' ``` +Not every synthetic child carries a value in all five immunization fields, so list a page of +`Child Programme` records with the field selection the source will use: + +```sh +curl --silent --show-error --fail \ + --config .local/dhis2.curl \ + --get \ + --url "$DHIS2_BASE_URL/api/tracker/trackedEntities" \ + --data-urlencode "program=$DHIS2_PROGRAM_ID" \ + --data-urlencode 'orgUnitMode=ACCESSIBLE' \ + --data-urlencode 'fields=trackedEntity,enrollments[program,events[programStage,status,dataValues[dataElement,value]]]' \ + --data-urlencode 'pageSize=200' \ + --output .local/dhis2-candidates.json +``` + +The [DHIS2 Tracker API](https://docs.dhis2.org/en/develop/using-the-api/dhis-core-version-243/tracker.html) +applies `orgUnitMode=ACCESSIBLE` when a request sends no `orgUnits`, so this page spans every +organisation unit the demo account can read. +The response wraps the records in a `trackedEntities` array beside a `pager` object. + +Create `choose-dhis2-subject.py`. The script picks the first record that carries all five +readings without contradicting itself, applying the same programme, stage, status, and +data-element rule the source extractor applies later. It writes only that record's identifier to +an owner-only local file and prints no identifier: + +```python +import json +import os +import pathlib + +STAGES = {"A03MvHHogjR", "ZzYYXq4fJie"} +STATUSES = {"ACTIVE", "COMPLETED"} +ELEMENTS = { + "bx6fsa0t90x": "bcg", + "ebaJjqltK5N": "opv", + "vTUhAUZFoys": "penta", + "FqlgKAG8HOu": "measles", + "rxBfISxXS2U": "yellow_fever", +} + +page = json.loads(pathlib.Path(".local/dhis2-candidates.json").read_text()) +records = page.get("trackedEntities", []) +chosen = None +for record in records: + readings = {} + consistent = True + for enrollment in record.get("enrollments", []): + if enrollment.get("program") != "IpHINAT79UW": + continue + for event in enrollment.get("events", []): + if event.get("programStage") not in STAGES: + continue + if event.get("status") not in STATUSES: + continue + for reading in event.get("dataValues", []): + name = ELEMENTS.get(reading.get("dataElement")) + if name is None: + continue + value = reading.get("value") + if readings.setdefault(name, value) != value: + consistent = False + if consistent and len(readings) == len(ELEMENTS): + chosen = record["trackedEntity"] + break + +if chosen is None: + raise SystemExit( + f"none of the {len(records)} records on this page carries all five readings" + ) + +subject = pathlib.Path(".local/dhis2-subject.txt") +descriptor = os.open(subject, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) +with os.fdopen(descriptor, "w", encoding="utf-8") as destination: + destination.write(chosen) +print(f"chose 1 subject from {len(records)} records") +``` + +Run it: + +```sh +python3 choose-dhis2-subject.py +``` + +It prints only a count of the records it examined, so no synthetic identifier reaches your +screen, your shell history, or a tracked file. +When it reports that no record on the page qualifies, add `--data-urlencode 'page=2'` to the list +request and run both commands again. + +Read the chosen subject into your terminal: + +```sh +export DHIS2_TRACKED_ENTITY_ID="$(cat .local/dhis2-subject.txt)" +``` + +The identifier selects the source record. +It is used for the DHIS2 read and the Evidence Gateway request, and it does not appear in the +signed assertion. + +## See the DHIS2 boundary + Read the same bounded fields that Evidence Gateway will use: ```sh @@ -584,17 +669,41 @@ Local assurance keeps the project editable and does not require production fixtu ## Request the real assertion -Prepare a closed request and its independent verification expectations for the chosen subject: +Write the subject into an owner-only request file. The identifier is read from the file the +discovery script already wrote, so it is never typed and never expanded onto a command line: + +```sh +python3 - <<'PY' +import json +import os +import pathlib + +value = pathlib.Path("../.local/dhis2-subject.txt").read_text().strip() +selection = {"subjects": [{"role": "child", "field": "tracked_entity_id", "value": value}]} +descriptor = os.open( + "../.local/dhis2-subjects.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 +) +with os.fdopen(descriptor, "w", encoding="utf-8") as destination: + json.dump(selection, destination, separators=(",", ":")) + destination.write("\n") + +print("Subject file: ready") +PY +``` + +Prepare a closed request and its independent verification expectations for that subject: ```sh evidencectl request prepare immunization-summary \ --purpose care-continuity \ - --subject "child:tracked_entity_id=$DHIS2_TRACKED_ENTITY_ID" \ + --subjects-file ../.local/dhis2-subjects.json \ --name immunization-summary ``` -Use this only with the public synthetic record. In a real deployment, selectors need an input -path that does not expose identifiers in shell history or process arguments. +Use this only with the public synthetic record. +`evidencectl` takes either `--subject` or `--subjects-file` and refuses both, so this request +carries no identifier on its command line. It reads the file only when the file is a regular file +you own with exactly one link and mode 0600. Send that request across the Evidence Gateway HTTP boundary: @@ -679,15 +788,33 @@ Inspect the last operation: evidencectl audit show --last-operation ``` -The audit records the authorized access and the five disclosed concept identifiers. -It does not record their values or the DHIS2 response. +```text +ACCESS AUTHORIZED immunization-summary care-continuity requester= +DISCLOSURE RELEASED bcg_recorded_as_administered, opv_dose_count_recorded, penta_dose_count_recorded, measles_recorded_as_administered, yellow_fever_recorded_as_administered +``` + +The requester value changes on each fresh project. + +Read those two lines for what they leave out. +They name the requester pseudonym, the question, the purpose it was authorized under, and the five +concepts released. +Neither line carries a `true`, a `false`, or a dose count, so the audit trail cannot answer the +question it recorded. +Neither line carries the tracked entity identifier, an event identifier, a data element, or any +part of the DHIS2 response. +An operator reviewing this trail can establish who asked, why, and which readings were released, +and cannot learn the child's immunization record from it. Remove the stopped local generation and the local DHIS2 artifacts: ```sh evidencectl dev clean cd .. -rm -f .local/dhis2-response.json \ +rm -f choose-dhis2-subject.py \ + .local/dhis2-candidates.json \ + .local/dhis2-subject.txt \ + .local/dhis2-subjects.json \ + .local/dhis2-response.json \ .local/dhis2.curl \ .local/dhis2.openapi.yaml ``` @@ -696,6 +823,18 @@ Keep the project editable while evaluating the source. Before deployment, add project-specific fixtures, replace the demo account with a least-privilege service account, review the source acquisition posture, and build a reviewed production candidate. +## Troubleshooting + +| Symptom | Cause | Resolution | +| --- | --- | --- | +| `curl` fails with 404 against `$DHIS2_BASE_URL` | The demo publishes each DHIS2 release under its own version path, and this tutorial pins `stable-2-43-1`. | Take the current version path from the demo landing page, then update `DHIS2_BASE_URL` and the `pathTemplate` prefix in `sources/child-tracker.yaml`. Re-read the Tracker API reference for that version before trusting the field names. | +| `curl` fails with 401 | The shared demo account was rotated or disabled. | Take the credential the demo publishes now and update `.local/dhis2.curl`, `secrets/dhis2-username`, and `secrets/dhis2-password`. Do not put a real DHIS2 account in these files. | +| `choose-dhis2-subject.py` reports that no record on the page carries all five readings | The demo data was reset, or this page of records holds no complete Child Programme case. | Request a later page with `page=2`, or raise `pageSize`. Do not drop a concept from the question so a partial record qualifies. | +| Writing the subject file fails with `FileExistsError` | `os.open` refuses `O_EXCL` when the file is already there, so a second run cannot overwrite it. | Remove `../.local/dhis2-subjects.json` and run the step again. | +| Evidence Gateway returns no assertion for a subject that worked earlier | The record changed: a reading was removed, or an `ACTIVE` and a `COMPLETED` event now disagree. | Run the discovery step again and pick a current subject. Leave `merge_reading` refusing conflicts. A disagreement between two events is a fact about the record. | +| Evidence Gateway reports a source dependency failure | The shared demo was slow or unreachable, or the response exceeded `timeoutMilliseconds` or `maximumResponseBytes`. | Retry later. Treat the bounds as reviewed source policy, and raise one only after deciding it is right for the deployment. | +| `curl` fails with a certificate error | An intercepting proxy or an out-of-date trust store on your machine. | Repair the trust store, or exempt the demo host from interception. Do not reach for `--insecure`: the source denies redirects and reaches the demo over HTTPS only. | + ## Next - [See Evidence Gateway refuse unsafe requests](../refuse-unsafe-evidence-requests/) diff --git a/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx index 7d2d03a1d..987ec7f09 100644 --- a/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx +++ b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx @@ -35,8 +35,26 @@ uses another name. ## Create one key per service Run the provider administration commands through an authenticated operator session, not from the -Evidence Gateway or Mint container. Create non-derived, non-exportable P-256 keys with plaintext -backup disabled: +Evidence Gateway or Mint container. + +Settle three things before you create the key. Decide which provider instance and mount hold this +signing identity for as long as the key is in service. Confirm that the provider's own backup and +restore covers that mount, because there is no key-level export or plaintext backup to fall back on. +Record the mount, the key name, the service and environment the key serves, and the operator +identity authorized to rotate it, in the deployment record rather than in application configuration. + +:::caution +A key created with `exportable=false` and `allow_plaintext_backup=false` never releases its private +material, and the pair is one-way in the permissive direction only: a provider administrator can set +either field to `true` on this key later, and Vault and OpenBao then refuse to set it back to +`false`. Evidence Gateway and Mint accept a Transit key only while both fields read `false`, so +exporting the key retires that key name from both services permanently, for every version under it. +There is no supported way to move this private key to another key manager or to sign with it outside +the provider. The recovery path is a new key and +[rotating the signing key](../rotate-evidence-signing-keys/), not export. +::: + +Create non-derived, non-exportable P-256 keys with plaintext backup disabled: ```sh vault write transit/keys/evidence-signing \ @@ -198,6 +216,12 @@ listener "unix" { } ``` +Keep `use_auto_auth_token` at `"force"`. Under `"force"` the proxy replaces any token on the +incoming request with its own auto-auth token, so the proxy identity is the only identity that +reaches the provider through this socket. Under the weaker `true`, the proxy attaches its auto-auth +token only when the request carries none, and a token supplied by whatever reached the socket is +used instead. + Add the deployment's reviewed `auto_auth` method and provider trust settings. Use a separate socket, identity, and configuration for Mint. diff --git a/docs/site/src/content/docs/tutorials/publish-and-consume-discovery-index.mdx b/docs/site/src/content/docs/tutorials/publish-and-consume-discovery-index.mdx index 7762581e9..44b2d407f 100644 --- a/docs/site/src/content/docs/tutorials/publish-and-consume-discovery-index.mdx +++ b/docs/site/src/content/docs/tutorials/publish-and-consume-discovery-index.mdx @@ -215,17 +215,6 @@ remains active. - The verifier applied adopter-owned native trust and used the existing clients without routing credentials, native requests, or responses through Discovery. -## Next - -- [Build and run a Registry Discovery index](../../configure/discovery/) with your approved HTTPS - origins, deployment directory, and runtime limits. -- [Registry Discovery is an index](../../explanation/discovery-as-an-index/) explains why trust and - invocation stay in the native products. -- [Request Evidence from an application](../request-evidence-from-an-application/) shows the native - Evidence relying-party workflow. -- [Publish a governed SQLite registry](../publish-governed-sqlite-registry/) shows the native Relay - publication and request workflow. - ## Troubleshooting | Symptom | Cause | Fix | @@ -239,3 +228,14 @@ remains active. `complete_evidence_and_relay_journeys_build_select_trust_and_invoke_natively`, proves the trust refusal occurs before credential construction and provider traffic. Maintainer reviewed and accepted this trust-boundary claim on 2026-08-14. */} + +## Next + +- [Build and run a Registry Discovery index](../../configure/discovery/) with your approved HTTPS + origins, deployment directory, and runtime limits. +- [Registry Discovery is an index](../../explanation/discovery-as-an-index/) explains why trust and + invocation stay in the native products. +- [Request Evidence from an application](../request-evidence-from-an-application/) shows the native + Evidence relying-party workflow. +- [Publish a governed SQLite registry](../publish-governed-sqlite-registry/) shows the native Relay + publication and request workflow. diff --git a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx index d1b3c0ff4..08e02bbc5 100644 --- a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -66,6 +66,12 @@ relayctl --version If either version command is not found, add `~/.local/bin` to your `PATH`. +The installer checks both binaries against the release `SHA256SUMS` before either one reaches +your `PATH`, which catches a truncated or corrupted download but not a substituted release. +Replace `| bash` with `| less` to read the installer first, and before you run these binaries +anywhere but your own machine, verify the signed checksum chain described in +[OpenSSF and release trust](../../security/openssf-evidence/). + ## Start a project `relayctl init` writes a starter project into a new directory: @@ -75,25 +81,19 @@ relayctl init business-registry cd business-registry ``` -It reports the files it created: +It reports the files it created. Every `relayctl` command reports in the same shape: one line +saying what happened, then the detail underneath. Add `--json` to any of them for the full report +as JSON, which carries more than the summary a person reads. -```json -{ - "status": "success", - "diagnostics": [], - "details": { - "kind": "initialized", - "files": [ - "registry.yaml", - "runtime.yaml", - "governance/identifier-lifecycle.yaml", - "governance/classification-review.yaml", - "governance/legal-basis.yaml", - "governance/processing.dpv.yaml", - "codelists/record-lifecycle.yaml" - ] - } -} +```text +Initialized an authoring project. 7 files written. + registry.yaml + runtime.yaml + governance/identifier-lifecycle.yaml + governance/classification-review.yaml + governance/legal-basis.yaml + governance/processing.dpv.yaml + codelists/record-lifecycle.yaml ``` `registry.yaml` is the contract: what the register means and what it may release. `runtime.yaml` @@ -105,7 +105,7 @@ kinds before the end. The institution's database is an input, not part of the project. Create a small one here. -Open `registry.sql` in your editor and add this: +Create `registry.sql` and put this in it: ```sql CREATE TABLE businesses ( @@ -157,28 +157,52 @@ Ask `relayctl` what it sees in the database: relayctl inspect registry.sqlite ``` -The report lists every table, view, and column, and opens with a fingerprint of the whole schema: - -```json -{ - "status": "success", - "diagnostics": [], - "details": { - "kind": "schema-inspection", - "fingerprint": "sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18", - "objects": [ ... ] - } -} -``` - -Copy that fingerprint. Writing it into the contract is how you say which schema you reviewed. If -someone later adds, drops, or retypes a column, the fingerprint changes and Relay refuses to -serve rather than guessing whether your review still applies. +The report opens with a fingerprint of the whole schema, then lists every table, view, index, and +column: + +```text +Inspected the SQLite structure. 3 objects. + fingerprint sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18 + + index sqlite_autoindex_businesses_1 on businesses + table businesses + registration_number TEXT not null primary key + record_revision TEXT not null + lifecycle_state TEXT not null + recorded_at TEXT not null + legal_name TEXT not null + legal_form TEXT not null + registered_address TEXT not null + registrar_note TEXT not null + view relay_registered_businesses + registration_number TEXT nullable + record_revision TEXT nullable + lifecycle_state TEXT nullable + recorded_at TEXT nullable + legal_name TEXT nullable + legal_form TEXT nullable + registered_address TEXT nullable +``` + +The fingerprint covers the schema statements SQLite stored, not the rows. Inserting or editing +records never changes it. Reformatting a `CREATE TABLE` or `CREATE VIEW` does change it, even +when the resulting schema is the same, because the stored statement text is part of what is +hashed. The same stored schema always produces the same fingerprint, on any machine and on every +run, so a value that differs from the one this tutorial prints means your schema text differs, not +that your run went wrong. + +Copy your own fingerprint out of that report. Writing it into the contract is how you say which +schema you reviewed. If someone later adds, drops, or retypes a column, the fingerprint changes +and Relay refuses to serve rather than guessing whether your review still applies. ## Write the contract -Replace `registry.yaml` with this. It names the register, binds the resource to the view, -publishes three properties, and then discloses only two of them. +The contract names the register, binds the resource to the view, publishes three properties, and +then discloses only two of them. It is shown here one section at a time. Empty `registry.yaml` +first, then append each block below in the order it appears; together they are the file +`relayctl check` reads. + +Start with what the document is: ```yaml apiVersion: relay.registrystack.org/v2alpha1 @@ -187,7 +211,15 @@ metadata: id: business-registry version: draft-1 title: Business register +``` + +`metadata.version` is your own label for this revision of the contract. It is not the revision +Relay computes and reports, which is a digest of the compiled contract and which you cannot set +by hand. +Next, who the register belongs to and what it claims to be authoritative about: + +```yaml registry: registryIdentifier: urn:example:registry:businesses name: Business register @@ -201,7 +233,16 @@ registry: - name: govstack-digital-registries version: 3.0.0-alpha.2 status: directional +``` +`authoritativeScope` is the sentence a caller reads to decide whether this register answers their +question at all. `alignmentTargets` needs at least one entry, and `status: directional` is the +honest setting for an alignment you have read but not conformance-tested. + +Then the three roles that have to be attributable to someone, and where locally defined terms +live: + +```yaml governance: controller: urn:example:authority:registrar publisher: urn:example:authority:registrar @@ -209,7 +250,15 @@ governance: semantics: localVocabulary: https://registry.example.invalid/vocabulary/ +``` + +All three roles are the registrar here because one institution holds all three. They are separate +keys because in a real deployment they often are not the same body, and the audit chain is only +meaningful when someone is named as answerable for it. +Next, the vocabularies this contract classifies against: + +```yaml classifications: privacy: scheme: https://w3id.org/dpv @@ -221,13 +270,30 @@ classifications: scheme: https://id.registrystack.org/vocab/handling version: "1" provenanceRef: governance/classification-review.yaml +``` +Every classification later in the file is a term from one of these three schemes, pinned to a +version. `provenanceRef` points at the review record that says a person agreed with those terms, +which is the file the production gate will check later in this tutorial. + +Now the source, which is where your fingerprint goes: + +```yaml sources: registry: kind: sqlite profile: snapshot - expectedSchemaFingerprint: sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18 + expectedSchemaFingerprint: sha256: +``` + +Replace `sha256:` with the value `relayctl inspect` printed for your +database. Nothing else in the tutorial substitutes for it: a contract carrying anyone else's +fingerprint is refused with `source.schema_fingerprint_mismatch`. + +The resource is the largest section, so it arrives in five parts. First its identity and the view +it binds to: +```yaml resources: - id: registered-business title: Registered business @@ -241,6 +307,15 @@ resources: institutional: public handling: public status: reviewed +``` + +`view` names the view you wrote, never a base table. `classificationDefaults` applies to anything +in this resource that does not classify itself, so the defaults are what you would have to +override to publish something more sensitive. + +Then the four columns that carry record identity rather than content: + +```yaml recordContext: recordIdentifier: sourceColumn: registration_number @@ -252,6 +327,15 @@ resources: recordedAt: sourceColumn: recorded_at sourceColumnClassifications: {} +``` + +`recordContext` is why an answer can say which record it is, which revision of it you got, and +whether that record is still current. The codelist has to list every lifecycle value the view can +produce, so a value the register invents later is a refusal rather than a surprise in an answer. + +Then the properties the contract knows about: + +```yaml properties: legalName: label: Legal name @@ -274,6 +358,14 @@ resources: type: string sourceRequired: true semanticTerm: local:registeredAddress +``` + +All three properties are declared, including `registeredAddress`. Declaring a property is not +publishing it: the next block decides who gets which of them. + +Then the disclosure and access decision: + +```yaml disclosureProfiles: public: properties: @@ -286,6 +378,17 @@ resources: public: access: public disclosureProfile: public +``` + +`disclosureProfiles.public` lists only `legalName` and `legalForm`. A property that is declared +and not disclosed is one the register knows about and this audience does not get, and declaring +it is what lets you disclose it later to a different audience without touching the view. +`access: public` means anonymous, and that is the whole authorization decision for this +deployment, which is why it will need no identity provider later. + +Then why the register is doing this at all: + +```yaml processingDescriptions: - id: consultation operationRefs: @@ -296,7 +399,15 @@ resources: dpvProfileRef: governance/processing.dpv.yaml safeguards: - property-minimization +``` + +A processing description binds an operation to a purpose, an audience, and a legal basis on file. +Its identifier is what the audit chain records against each released answer, so a released field +can always be traced back to the stated reason for releasing it. +Finally, what a caller may read about the register itself: + +```yaml metadataVisibility: service: public resources: public @@ -305,18 +416,6 @@ metadataVisibility: processing: public ``` -Paste your own fingerprint into `expectedSchemaFingerprint` if it differs from the one above. - -Three parts of that file are worth reading twice. - -`properties` declares `registeredAddress`, but `disclosureProfiles.public` lists only `legalName` -and `legalForm`. A property that is declared and not disclosed is a property the register knows -about and this audience does not get. Declaring it is what lets you disclose it later to a -different audience without touching the view. - -`access: public` means anonymous. That is the whole authorization decision for this deployment, -which is why it will need no identity provider later. - `metadataVisibility` is set to `public` throughout because a public audience has to be able to resolve the schema and vocabulary the answer points at. A contract that publishes an answer anonymously but hides the documents that explain it is refused, not served. @@ -328,20 +427,23 @@ relayctl check . ``` The check compiles the contract, opens the database read-only to confirm the view and columns -exist, and reports the revision of what it compiled: +exist, and reports the revision of what it compiled. The two counts are the accepted +configuration key paths: how many keys `registry.yaml` and `runtime.yaml` will each take, not how +many yours uses: -```json -{ - "status": "success", - "diagnostics": [], - "details": { - "kind": "check", - "contract_revision": "sha256:", - "production": false - } -} +```text +Authoring check passed. + contract revision sha256: + registry key paths + runtime key paths ``` +Your run prints real values where this page shows placeholders. The counts belong to the relayctl +release you installed rather than to anything you wrote, and the revision changes whenever the +compiled contract does, so pinning either one here would only tell you what an older release once +printed. Both are stable for a given release and a given contract, which is what lets a later step +compare one revision against another. + Nothing is running yet. `check` reads, and writes nothing. ## Generate the artifacts @@ -350,21 +452,23 @@ Nothing is running yet. `check` reads, and writes nothing. relayctl generate . ``` -This writes 22 artifacts under `generated/`: an OpenAPI 3.1 description, JSON Schema and SHACL -shapes, a JSON-LD context and vocabulary, the capability inventory, the audit event schema, and -the review reports. Every one of them is derived from the contract, so none of them can describe -a field the contract does not disclose. +This writes the published description of the register under `generated/`: an OpenAPI 3.1 +description, JSON Schema and SHACL shapes, a JSON-LD context and vocabulary, the capability +inventory, the audit event schema, and the review reports. Every one of them is derived from the +contract, so none of them can describe a field the contract does not disclose. Running `generate` +again on an unchanged project rewrites the same bytes. Two of the generated files are review inputs rather than outputs. `generated/reports/classification-inventory.json` is the list of source columns and output properties with the handling each one carries. `generated/governance/classification-review-starter.yaml` is a pre-filled review record for -exactly that inventory: +exactly that inventory. Its `classificationInventoryDigest` is a digest of your own inventory, +so it changes whenever the contract changes what is classified: ```yaml apiVersion: relay.registrystack.org/classification-review/v1 kind: ClassificationReview registryIdentifier: urn:example:registry:businesses -classificationInventoryDigest: sha256:b01c845f9aabc500bae3756dd3daa855c182f540d48ccc5a341ccd2ca25e5e1f +classificationInventoryDigest: sha256: method: generated reviewer: urn:example:authority:registrar reviewDate: pending-review @@ -382,28 +486,24 @@ authority. Only a person supplies the rest. relayctl check . --production ``` -The starter project is refused, and the diagnostics say exactly why: +The starter project is refused, and the diagnostics say exactly why. Each one gives its severity, +its code, the file and key it is about, and the sentence underneath: -```json -{ - "status": "refused", - "diagnostics": [ - {"code": "codelist.unreviewed", "location": "codelists/record-lifecycle.yaml", - "message": "production codelists must be institutionally reviewed"}, - {"code": "classification.review_inventory_stale", - "location": "governance/classification-review.yaml:classificationInventoryDigest", - "message": "the classification review does not bind the current inventory"}, - {"code": "classification.review_registry_stale", - "location": "governance/classification-review.yaml:registryIdentifier", - "message": "the classification review is bound to another Registry"}, - {"code": "classification.review_date_invalid", - "location": "governance/classification-review.yaml:reviewDate", - "message": "the review date must be a canonical calendar date"}, - {"code": "classification.review_unreviewed", - "location": "governance/classification-review.yaml:status", - "message": "production classification requires reviewed institutional evidence"} - ] -} +```text +Production check refused. + + error codelist.unreviewed codelists/record-lifecycle.yaml + production codelists must be institutionally reviewed + error classification.review_inventory_stale governance/classification-review.yaml:classificationInventoryDigest + the classification review does not bind the current inventory + error classification.review_registry_stale governance/classification-review.yaml:registryIdentifier + the classification review is bound to another Registry + error classification.review_date_invalid governance/classification-review.yaml:reviewDate + the review date must be a canonical calendar date + error classification.review_unreviewed governance/classification-review.yaml:status + production classification requires reviewed institutional evidence + +5 errors, 0 warnings. ``` This is the governed result. A contract that compiles is not a contract an institution has @@ -411,14 +511,15 @@ agreed to publish, and `--production` is the difference between the two. ## Record the review -Replace `governance/classification-review.yaml` with the review a person signs off. The digest -comes from the starter file `generate` just wrote: +Replace `governance/classification-review.yaml` with the review a person signs off. Copy +`classificationInventoryDigest` out of the starter file `generate` wrote, and use the date on +which you actually read the inventory: ```yaml apiVersion: relay.registrystack.org/classification-review/v1 kind: ClassificationReview registryIdentifier: urn:example:registry:businesses -classificationInventoryDigest: sha256:b01c845f9aabc500bae3756dd3daa855c182f540d48ccc5a341ccd2ca25e5e1f +classificationInventoryDigest: sha256: method: manual reviewer: urn:example:authority:registrar reviewDate: 2026-08-11 @@ -465,43 +566,40 @@ Now run the production check again: relayctl check . --production ``` -```json -{ - "status": "success", - "diagnostics": [], - "details": { - "kind": "check", - "contract_revision": "sha256:", - "production": true - } -} +```text +Production check passed. + contract revision sha256: + registry key paths + runtime key paths ``` +The contract revision is not the one the first check reported: run this yourself and you will see +a different digest here than the authoring check printed above. The revision covers the governed +files the contract points at, so recording the review changed it, and every answer the service +gives will carry this value rather than the earlier one. + ## Seal the package ```sh relayctl package . --output package ``` -```json -{ - "status": "success", - "details": { - "kind": "package", - "manifest": { - "packageRevision": "sha256:", - "contractRevision": "sha256:", - "sourceSchemaFingerprints": { - "registry": "sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18" - } - } - } -} +```text +Sealed a deployment package. artifacts, files. + package version relay.registrystack.org/package/v1alpha3 + package revision sha256: + contract revision sha256: + artifact bindings + + source schema fingerprints + registry sha256:b3c73e50829bf63f8034bac74ce23c9b387fa4e84ca0afc27bb98d5eccc0fe18 ``` -The package holds the compiled contract, the generated artifacts, and the governed files, with a -digest for each. It does not hold the database. Packaging recompiles under the production -profile, so a package cannot be produced from a revision that would fail `check --production`. +That report summarizes `package/relay-package.json`, which names every file and artifact in +the package with a digest for each and records the full observed schema of every source. The +package holds the compiled contract, the generated artifacts, and the governed files. It +does not hold the database. Packaging recompiles under the production profile, so a package +cannot be produced from a revision that would fail `check --production`. ## Serve it @@ -536,16 +634,30 @@ to start when any group or other bit is set on it. Keep the key for the life of too. The chain is bound to it, so a new key against an existing `var/audit.jsonl` is a startup failure rather than a fresh start. +Relay writes line-delimited JSON to standard output. It logs `relay startup began`, and then +`relay service listening` with the bound address once the port is actually held. If the second +line does not appear, the service did not start and the log says why. + Leave that running and open a second shell in the same directory. ## Ask the register a question ```sh curl -s http://127.0.0.1:8080/ready +``` + +```json +{"status":"ready"} +``` + +Then ask for a record: + +```sh curl -s http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-0001 ``` -The record answer, abridged to the part that matters: +The answer, abridged to the part this step is about. The real one also carries the URLs a caller +follows to the generated schema, vocabulary, and JSON-LD context: ```json { @@ -565,7 +677,7 @@ The record answer, abridged to the part that matters: "accessProfile": "public", "disclosureProfile": "public", "selectedFields": ["legalName", "legalForm"], - "contractRevision": "sha256:", + "contractRevision": "sha256:", "sourceRevision": {"profile": "snapshot", "status": "versioned", "value": "sha256:"} } } @@ -596,12 +708,16 @@ curl -s 'http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-0001 "type": "https://id.registrystack.org/problems/registry-relay/request/fields_invalid", "title": "Field selection is invalid", "status": 400, - "code": "request.fields_invalid" + "code": "request.fields_invalid", + "detail": "field selection is invalid", + "traceId": "" } ``` The refusal is a request error, not a permission error, because from this audience's side the -field does not exist. An unknown record is a separate refusal: +field does not exist. `traceId` is the same identifier the service logged for that request, which +is how an operator ties a caller's complaint to one log line without the caller quoting the +answer. An unknown record is a separate refusal: ```sh curl -s http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-9999 @@ -609,9 +725,12 @@ curl -s http://127.0.0.1:8080/v2/resources/registered-business/records/BIZ-9999 ```json { + "type": "https://id.registrystack.org/problems/registry-relay/consultation/unresolved", "title": "Requested record was not resolved", "status": 404, - "code": "consultation.unresolved" + "code": "consultation.unresolved", + "detail": "the requested record was not resolved", + "traceId": "" } ``` @@ -627,9 +746,11 @@ Stop the service with `Ctrl+C` and read the first audit line: head -n 1 var/audit.jsonl ``` +The line is one JSON object. Abridged to the fields this step is about: + ```json { - "envelope_id": "...", + "envelope_id": "", "prev_hash": null, "record": { "operationIdentifier": "registered-business.read", @@ -639,14 +760,19 @@ head -n 1 var/audit.jsonl "disclosureProfile": "public", "selectedProperties": ["legalName", "legalForm"], "processingDescriptionIdentifiers": ["consultation"], - "contractRevision": "sha256:", + "contractRevision": "sha256:", "sourceRevision": {"profile": "snapshot", "status": "versioned", "value": "sha256:"}, "phase": "attempt" }, - "record_hash": "..." + "record_hash": "" } ``` +The envelope also carries `timestamp_unix_ms`. The full `record` carries more than is shown: the +registry identifier, the revision of the access rules that were applied, the operation surface +and wire format, the trace identifier shared with the log line, and the handling levels the +answer was released under. None of them is a field value either. + Read what is not there. The line records that an anonymous caller reached the `registered-business.read` operation, and that the contract scoped the answer to the properties `legalName` and `legalForm` under the `consultation` processing description. It does not record @@ -685,6 +811,8 @@ unset RELAY_AUDIT_KEY | `project.destination_not_empty` from `relayctl init` | The target directory already has files | Initialize into a new directory name. | | `contract.yaml_invalid` from `relayctl check` | A required key is missing, an unknown key is present, or the YAML does not parse | Every key in the contract above is required. `registry.alignmentTargets` needs at least one entry. | | `resource.view_unknown` | `source.view` names something that is not a view in the database | Relay binds to views only. Add a `CREATE VIEW` for the columns you intend to publish. | +| `source.schema_fingerprint_invalid` | `expectedSchemaFingerprint` still holds the `sha256:` placeholder | Run `relayctl inspect registry.sqlite` and paste the value it prints. | +| `source.schema_fingerprint_mismatch` | The contract holds a fingerprint from a different schema, usually because the SQL was retyped rather than copied | Run `relayctl inspect registry.sqlite` again and paste the current value. | | `metadata.reference_visibility_invalid` | A public audience cannot resolve the schema or vocabulary the answer points at | Set `metadataVisibility.resources` and `.semantics` to `public` when any access profile is public. | | `classification.review_inventory_stale` | The contract changed after the review was recorded | Run `relayctl generate .` again and copy the new digest from `generated/governance/classification-review-starter.yaml`. | | `the required audit sink is not ready` at startup | `var/` is not owner-only, or `RELAY_AUDIT_KEY` differs from the key that started the existing chain | `chmod 700 var`, and either restore the original key or remove `var/audit.jsonl` to start a new chain. | diff --git a/docs/site/src/content/docs/tutorials/query-relay-client.mdx b/docs/site/src/content/docs/tutorials/query-relay-client.mdx index bfbc21477..69530244f 100644 --- a/docs/site/src/content/docs/tutorials/query-relay-client.mdx +++ b/docs/site/src/content/docs/tutorials/query-relay-client.mdx @@ -1,12 +1,11 @@ --- title: Query Registry Relay with Python description: Install the thin Python client, read a synthetic business record, handle an optional continuation, revalidate a document, and handle a Relay refusal. -status: draft -draft: true +status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-11" +last_reviewed: "2026-08-19" doc_type: tutorial persona: - consumer or verifier @@ -15,8 +14,6 @@ standards_referenced: - openapi --- -{/* This tutorial is not registered with an executable docs runner. Run reader-mode verification before changing status to current. */} - import QuickstartMeta from '../../../components/QuickstartMeta.astro'; The Registry Relay Python client gives a consumer or verifier one synchronous method for each @@ -37,24 +34,34 @@ Complete [Publish a governed SQLite registry](../publish-governed-sqlite-registr the **Serve it** step. Leave Relay running and open a second shell. The resulting deployment is anonymous, contains synthetic records only, and listens at `http://127.0.0.1:8080`. +That second shell also needs the `relay` binary on its path. The install below reads the exact +release version from it rather than asking you to type one. + Prebuilt Relay client packages start with Registry Stack v0.20.0. The running Relay must therefore be v0.20.0 or later. Install the client from that exact Relay release, using the wheel that matches this machine. ## Install the client and read a record -### Do - -Create an isolated directory and virtual environment. The shell reads the exact release version -from the running Relay and selects the wheel for the current machine. +Work in a directory of its own, so the wheel and the virtual environment stay out of the registry +project you built in the prerequisite: ```sh mkdir relay-python-query cd relay-python-query python3 -m venv .venv . .venv/bin/activate +``` + +Now read the release version from the running Relay, select the wheel for this machine, and +install it. The release workflow attaches Python wheels to the matching GitHub Release, so the +download comes from that release rather than from PyPI: +```sh VERSION="$(relay --version | awk '{print $2}' | sed 's/^v//')" +case "$VERSION" in + *-dev*) echo "This relay was built from source ($VERSION), not installed from a release" >&2; exit 1 ;; +esac case "$(uname -s)-$(uname -m)" in Linux-x86_64) WHEEL_PLATFORM="linux_x86_64" ;; Linux-aarch64|Linux-arm64) WHEEL_PLATFORM="linux_aarch64" ;; @@ -66,7 +73,15 @@ curl -fLO "https://github.com/registrystack/registry-stack/releases/download/v${ python -m pip install "./${WHEEL}" ``` -Create `query_relay.py`: +The wheel uses the Python 3.10 stable ABI, so supported newer Python versions import the same +file. The three platform names in that `case` are the whole set: the release provides no Windows +or Intel macOS wheel. + +The first `case` stops a build that came from source rather than from a release. Such a build +reports a `-dev` version, which names no published release and so has no wheel to download. + +Create `query_relay.py`. It constructs a client against the running deployment and reads one +record: ```python import json @@ -88,36 +103,28 @@ Run it: python query_relay.py ``` -### See - ```text complete BIZ-0001 {"legalForm": "COOPERATIVE", "legalName": "Aurora Freight Cooperative"} ``` -### Understand - -The release workflow attaches Python wheels to the matching GitHub Release. It does not publish -this package to PyPI. The wheel uses the Python 3.10 stable ABI and can be imported by supported -newer Python versions. +`complete` distinguishes a returned representation from a cache revalidation response. The record +envelope stays a plain Python mapping, so reading it is ordinary dictionary access and the client +carries no schema for your records. `domainData` holds only the two fields the synthetic Relay +contract discloses, not the registered address and registrar note stored beside them in the same +row. -`complete` distinguishes a returned representation from a cache revalidation response. The -record envelope remains a plain Python mapping. Its `domainData` contains only the two fields -disclosed by the synthetic Relay contract. - -### Adapt - -The release does not provide a Windows or Intel macOS wheel. Pass `fields=["legalName"]` to -`read_record` to narrow the response. A caller cannot use fields to widen the contract's -disclosure profile. Use the [Relay client API reference](../../reference/relay-client-api/) -before adding authentication or private certificate roots. +Pass `fields=["legalName"]` to `read_record` to narrow that response further. Narrowing is the only +direction available: a caller cannot use fields to widen the contract's disclosure profile. Read +the [Relay client API reference](../../reference/relay-client-api/) before adding authentication or +private certificate roots. ## Handle an optional continuation exactly -### Do - -Append this discovery loop to `query_relay.py`, then run the file again: +Relay returns a continuation only when a further page exists, so a caller has to branch on its +presence instead of assuming it. Append this discovery loop to `query_relay.py`, then run the file +again: ```python page = client.resources(page_size=1) @@ -133,36 +140,29 @@ while True: page = client.continue_resources(continuation) ``` -### See - -The new final line is: +The run ends with one new line: ```text registered-business ``` -### Understand - -This deployment has one resource, so its first page has no continuation. On a deployment with -more resources, `continuation` is exactly `{"cursor": ""}`. Pass that returned -mapping unchanged to `continue_resources`. Do not extract its cursor, rebuild the mapping, or add -first-page options. +This deployment has one resource, so its first page has no continuation and the loop breaks on the +first pass. On a deployment with more resources, `continuation` is exactly +`{"cursor": ""}`. Pass that returned mapping unchanged to `continue_resources`. Do +not extract its cursor, rebuild the mapping, or add first-page options. Record-list and search pages use route-bound continuation mappings. Hand those unchanged to `continue_list_records` or `continue_search`, respectively. The client never advances a page on its own. -### Adapt - -Persist a continuation only as the complete returned mapping. The matching continuation method -validates it again, so a resource, record-list, or search continuation cannot be substituted for -another route. +If you persist a continuation between runs, persist the complete returned mapping. The matching +continuation method validates it again, so a resource, record-list, or search continuation cannot +be substituted for another route. ## Revalidate OpenAPI with a strong entity tag -### Do - -Append this conditional request and run the file: +The OpenAPI document is cacheable, which lets a second request ask Relay whether the copy you +already hold is still current. Append this conditional request and run the file: ```python first = client.openapi() @@ -174,9 +174,7 @@ print(second["kind"]) print(second["etag"] == first["etag"]) ``` -### See - -The four new lines are: +The run ends with four new lines: ```text complete @@ -185,23 +183,20 @@ not_modified True ``` -### Understand - The first response carries raw OpenAPI bytes and a validated strong ETag. The second call sends that tag as `If-None-Match`. Relay answers `304 Not Modified`, and the binding returns a -`not_modified` outcome with the echoed tag and trace identifier instead of a body. +`not_modified` outcome with the echoed tag and trace identifier instead of a body. Nothing in that +second outcome carries the document, so a caller that discarded the first body has nothing left to +serve. -### Adapt - -Store the complete response body and ETag together. Reuse the stored body only when the next -outcome is `not_modified` and its ETag matches. Conditional requests remain explicit for every -cacheable method. +Store the complete response body and ETag together, then reuse the stored body only when the next +outcome is `not_modified` and its ETag matches. Revalidation stays explicit for every cacheable +method: the client sends `If-None-Match` only on a call where you passed `etag`. ## Handle a Relay refusal -### Do - -Append this request for a missing synthetic record and run the file: +A record identifier the register does not hold is a refusal, not an empty result. Append this +request for a missing synthetic record and run the file: ```python from registry_relay_client import RelayClientError @@ -215,9 +210,7 @@ except RelayClientError as error: print(error.code) ``` -### See - -The three new lines are: +The run ends with three new lines: ```text problem @@ -225,17 +218,13 @@ problem consultation.unresolved ``` -### Understand - A valid Relay Problem becomes `RelayClientError` with stable, value-free attributes. The client does not include response bodies, request selectors, credentials, URLs, or header values in the error. Only a registered `429` Problem can carry `retry_after_seconds`. -### Adapt - -Branch on `kind`, then use `status` and `code` when they are present. Treat `trace_id` as the -correlation value for an operator. Decide at the application boundary whether a request is safe -to repeat because the client performs no automatic retries. +Branch on `kind` first, then use `status` and `code` when they are present. Treat `trace_id` as the +correlation value to give an operator. Decide at your application boundary whether a request is +safe to repeat, because the client performs no automatic retries. ## Clean up @@ -259,22 +248,23 @@ registry project remains available for later Relay exercises. - A strong-ETag revalidation path that distinguishes complete and `304` outcomes. - A refusal path that uses stable error facts without exposing request or credential values. -## Next - -- [Read the Relay client API reference](../../reference/relay-client-api/) for every Rust, Python, - and Node operation and outcome shape. -- [Author a Registry Relay project](../../configure/relay/) to add list, lookup, search, artifact, - and SDMX operations to a deployment. -- [Review errors and status codes](../../reference/errors/) before mapping failures into an - application-facing API. - ## Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | GitHub returns `404` for the wheel | The running Relay is older than v0.20.0, its exact release is not published, or the platform name is wrong | Use a published v0.20.0 or later Relay, then download the wheel from that exact release for one of the three platforms in this tutorial. | +| `built from source, not installed from a release` | `relay --version` reports a `-dev` version, which every build outside the release workflow does | Install `relay` with the installer in the prerequisite, or download a client wheel from whichever release you want a client for. | | `No matching distribution found` | The wheel filename or local path does not match the downloaded asset | Keep the original release filename and install that exact local file. | | `Connection refused` | The synthetic Relay is not listening on port 8080 | Return to the publishing tutorial, start Relay, and leave it running in its shell. | | `configuration` at construction | The base URL is not HTTPS or loopback HTTP, or it contains credentials, a query, or a fragment | Use `http://127.0.0.1:8080` for this local deployment. | | `protocol` during a response | Relay returned a response outside the fixed media type, trace, ETag, Problem, or body contract | Record `trace_id` when present and inspect the Relay operator logs. Do not parse the rejected body in application code. | | A repeated call returns `not_modified` | The supplied strong ETag still identifies the current representation | Reuse the body stored with that same ETag. | + +## Next + +- [Read the Relay client API reference](../../reference/relay-client-api/) for every Rust, Python, + and Node operation and outcome shape. +- [Author a Registry Relay project](../../configure/relay/) to add list, lookup, search, artifact, + and SDMX operations to a deployment. +- [Review errors and status codes](../../reference/errors/) before mapping failures into an + application-facing API. diff --git a/docs/site/src/content/docs/tutorials/refuse-unsafe-evidence-requests.mdx b/docs/site/src/content/docs/tutorials/refuse-unsafe-evidence-requests.mdx index 640ef9cf9..73d5ea97f 100644 --- a/docs/site/src/content/docs/tutorials/refuse-unsafe-evidence-requests.mdx +++ b/docs/site/src/content/docs/tutorials/refuse-unsafe-evidence-requests.mdx @@ -47,7 +47,7 @@ evidencectl dev --detach ``` ```text -Evidence Gateway ready at http://127.0.0.1:8080 +Evidence ready at http://127.0.0.1:8080 Mint ready at http://127.0.0.1:8081 ``` @@ -163,24 +163,18 @@ PY Try to verify the changed response: ```sh -if evidencectl verify tampered-response.jws.json \ +evidencectl verify tampered-response.jws.json \ --context .evidence/requests/refusal-check/verification.json \ --output tampered-response.verified.json -then - echo 'unexpected verification success' >&2 - exit 1 -fi -test ! -e tampered-response.verified.json -echo 'TAMPER REFUSED' ``` ```text -evidencectl: Evidence Gateway response verification failed -TAMPER REFUSED +evidencectl: Evidence response verification failed ``` -Verification fails without publishing a trusted payload. Your application must make decisions -only from the verifier output, never from an unverified response body. +Verification fails without publishing a trusted payload: no `tampered-response.verified.json` +appears. Your application must make decisions only from the verifier output, never from an +unverified response body. ## Clean up diff --git a/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx b/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx index ea1343d95..f7fa5762c 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx @@ -201,20 +201,16 @@ PY Verification must fail and must not create trusted output: ```sh -if evidencectl verify scalar-tampered.sd-jwt \ +evidencectl verify scalar-tampered.sd-jwt \ --context .evidence/requests/scalar-vc/verification.json \ - --output scalar-tampered.verified.json; then - printf 'Expected tampered credential refusal\n' >&2 - exit 1 -fi -test ! -e scalar-tampered.verified.json -printf 'Tampered credential refused\n' + --output scalar-tampered.verified.json ``` ```text -Tampered credential refused +evidencectl: Evidence response verification failed ``` +Verification refuses the changed credential and writes no `scalar-tampered.verified.json`. The disclosure digest is covered by the issuer signature. Altering the disclosure breaks the verified relationship between the signed digest and the disclosed value. diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx index fe8ab28e4..faafb0791 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-06" +last_reviewed: "2026-08-19" doc_type: tutorial persona: - consumer or verifier @@ -15,6 +15,11 @@ standards_referenced: [] import QuickstartMeta from '../../../components/QuickstartMeta.astro'; +This is the relying party's side of Evidence: the side that asks a question and acts on the +answer. In production a relying party runs neither Evidence Gateway nor the source behind it, so +nothing on this page configures a deployment. You restart the local one from the previous tutorial +only so there is something to call. + Complete [Get your first Evidence assertion](../first-evidence-assertion/) before starting this tutorial. There you drove the Evidence boundary from a terminal with `evidencectl` and `curl`. Here you move the same boundary into application code: your program obtains its own access token, sends @@ -293,7 +298,7 @@ expectations. ## Pin the procedure -Write what you just reviewed into a file the application owns. This step makes no network call: it +Write what you reviewed into a file the application owns. This step makes no network call: it transforms the document you already read, so the identifiers and the revision are transcribed rather than copied by hand. The constants at the top are the application's own, stated rather than read: diff --git a/docs/site/src/content/docs/tutorials/return-a-governed-value.mdx b/docs/site/src/content/docs/tutorials/return-a-governed-value.mdx index d005c9f05..b9e19f933 100644 --- a/docs/site/src/content/docs/tutorials/return-a-governed-value.mdx +++ b/docs/site/src/content/docs/tutorials/return-a-governed-value.mdx @@ -130,7 +130,7 @@ evidencectl dev --detach ``` ```text -Evidence Gateway ready at http://127.0.0.1:8080 +Evidence ready at http://127.0.0.1:8080 Mint ready at http://127.0.0.1:8081 ``` @@ -225,6 +225,21 @@ evidencectl dev clean Return to the registry terminal and press `Ctrl+C`. +## If the local services refuse to start + +`evidencectl dev --detach` refuses a project that still holds a local session, reporting +`local development state already exists and is not a completed stopped session`. That happens when +the first tutorial's services were left running. Stop and remove that session, then start the new +generation: + +```sh +evidencectl dev stop +evidencectl dev clean +``` + +If ports `8080` or `8081` belong to something else, pass `--evidence-port` and `--mint-port` with +two unused ports, as [your first assertion](../first-evidence-assertion/) describes. + ## Next - [Control which applications can request Evidence Gateway](../control-who-can-request-evidence/) diff --git a/docs/site/src/content/docs/tutorials/run-oid4vci-interoperability-checks.mdx b/docs/site/src/content/docs/tutorials/run-oid4vci-interoperability-checks.mdx index 945796812..bac2999bb 100644 --- a/docs/site/src/content/docs/tutorials/run-oid4vci-interoperability-checks.mdx +++ b/docs/site/src/content/docs/tutorials/run-oid4vci-interoperability-checks.mdx @@ -211,15 +211,6 @@ The checks include no Android or iOS user-interface or device automation, live i data, durable state, or multi-replica deployment. They do not cover authorization code, DPoP, deferred or encrypted issuance, notification, credential status, or other credential formats. -## Next - -- [Configure OID4VCI wallet delivery](../../configure/evidence-oid4vci/) before deploying the - frozen profile. -- [Enable SD-JWT VC in a deployment](../../configure/enable-sd-jwt-vc/) to understand the - Evidence credential contract delivered through the adapter. -- [Read the known limitations](../../explanation/known-limitations/) before writing a public - interoperability claim. - ## Troubleshooting | Symptom | Cause | Resolution | @@ -232,3 +223,12 @@ deferred or encrypted issuance, notification, credential status, or other creden | The runner prints `Pinned Inji OID4VCI checking needs Java 17; the installed runtime is not Java 17.`, `Pinned Inji OID4VCI checking needs ANDROID_HOME or ANDROID_SDK_ROOT to name an installed Android SDK.`, or `Pinned Inji OID4VCI checking needs Android SDK platform 34 and Build Tools 33.0.1.` | The Android client prerequisites are missing or not selected. | Install a Java 17 JDK, set `JAVA_HOME`, install the named Android packages, and set `ANDROID_HOME` or `ANDROID_SDK_ROOT`. | | The runner prints `Pinned Inji OID4VCI checking needs full Xcode, not Command Line Tools alone.`, `Pinned Inji OID4VCI checking could not inspect installed iOS simulators.`, or `Pinned Inji OID4VCI checking needs an available iPhone 15 simulator.` | Full Xcode or the iPhone 15 simulator is missing or unavailable. | Install full Xcode, select it with `xcode-select`, accept its licence, and install an iPhone 15 simulator. | | A pinned upstream build fails | The checked revision, toolchain, or resolved dependencies did not reproduce in this environment. | Retain the failed result and environment details. Do not replace the pin with a mutable branch or describe the failed run as compatibility evidence. | + +## Next + +- [Configure OID4VCI wallet delivery](../../configure/evidence-oid4vci/) before deploying the + frozen profile. +- [Enable SD-JWT VC in a deployment](../../configure/enable-sd-jwt-vc/) to understand the + Evidence credential contract delivered through the adapter. +- [Read the known limitations](../../explanation/known-limitations/) before writing a public + interoperability claim. diff --git a/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx b/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx index 20c7d4c43..27a4a3916 100644 --- a/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx +++ b/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx @@ -31,7 +31,7 @@ receive them. 'The completed role-bound relationship tutorial', 'The Evidence Gateway toolset', 'Access to the public OpenCRVS demo', - 'curl and an editor', + 'curl, python3, and an editor', ]} /> @@ -607,7 +607,7 @@ evidencectl dev --detach ``` ```text -Evidence Gateway ready at http://127.0.0.1:8080 +Evidence ready at http://127.0.0.1:8080 Mint ready at http://127.0.0.1:8081 ``` @@ -622,21 +622,52 @@ Read the mother's national ID without leaving its value in shell history: printf 'Mother national ID: ' >&2 IFS= read -rs OPENCRVS_MOTHER_NID printf '\n' >&2 +export OPENCRVS_MOTHER_NID ``` -Prepare a request using Josh's national ID and that candidate-parent value: +Write both subjects into an owner-only request file. Josh's national ID is the child, and the +value you typed is the candidate parent: + +```sh +python3 - <<'PY' +import json +import os + +selection = { + "subjects": [ + {"role": "child", "field": "national_id", "value": "3617402568"}, + { + "role": "candidate-parent", + "field": "national_id", + "value": os.environ["OPENCRVS_MOTHER_NID"], + }, + ] +} +descriptor = os.open( + "../opencrvs-parent-subjects.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 +) +with os.fdopen(descriptor, "w", encoding="utf-8") as destination: + json.dump(selection, destination, separators=(",", ":")) + destination.write("\n") + +print("Subject file: ready") +PY +unset OPENCRVS_MOTHER_NID +``` + +Prepare a request from that file: ```sh evidencectl request prepare registered-parent \ --purpose relationship-check \ - --subject child:national_id=3617402568 \ - --subject "candidate-parent:national_id=$OPENCRVS_MOTHER_NID" \ + --subjects-file ../opencrvs-parent-subjects.json \ --name opencrvs-parent -unset OPENCRVS_MOTHER_NID ``` -Use this only with the public synthetic record. In a real deployment, selectors need an input -path that does not expose identifiers in shell history or process arguments. +Use this only with the public synthetic record. +`evidencectl` takes either `--subject` or `--subjects-file` and refuses both, so the value you +typed never reaches a command line. It reads the file only when the file is a regular file you own +with exactly one link and mode 0600. Preparation obtains short-lived local authorization and records verification expectations before a response exists. It does not contact OpenCRVS. @@ -765,22 +796,59 @@ boolean question instead. ## Inspect the audit and clean up -Stop the services, verify the audit chain, and remove the sealed local generation: +Stop the services: ```sh evidencectl dev stop +``` + +Inspect the last operation: + +```sh evidencectl audit show --last-operation -evidencectl dev clean ``` -The audit identifies each authorized question, purpose, requester pseudonym, decision, and -disclosed concept. It does not record selectors, the OpenCRVS token, source response, boolean, or -returned national IDs. +```text +ACCESS AUTHORIZED registered-parent-identifiers family-case-record requester= +DISCLOSURE RELEASED registered_parent_national_ids +``` + +The requester value changes on each fresh project. This view covers the last operation only, so it +shows the identifier question rather than the boolean one you asked first. + +Read those two lines for what they leave out. They name the requester pseudonym, the question, the +purpose it was authorized under, and the one concept released. That concept is a list of parent +identifiers, and the audit still carries no national ID: not the child's, not the mother's, and not +the candidate value you typed at the prompt. Neither line carries the OpenCRVS token, the search +response, the tracking ID, or the registration number. That gap is the point of the trail. An +operator can establish that a caller was authorized to receive registered parent identifiers under +a stated purpose, and cannot read those identifiers out of the audit. + +Remove the sealed local generation and the request file holding the two national IDs: + +```sh +evidencectl dev clean +rm -f ../opencrvs-parent-subjects.json +``` Keep the project, Record Search client, and two local credential files if you are continuing to the birth-certificate tutorial. Otherwise, delete the client in OpenCRVS and remove the credential files when you finish. +## Troubleshooting + +| Symptom | Cause | Resolution | +| --- | --- | --- | +| The OpenCRVS token request fails with 401 | The Record Search client was deleted, its secret was rotated, or the demo reset its integrations. | Create a new Record Search client and rewrite `secrets/opencrvs-client-id` and `secrets/opencrvs-client-secret`. Keep both values out of shell history and tracked files. | +| Signing in to the demo fails | The demo rotated its published logins, or the environment was rebuilt. | Take the current logins from the OpenCRVS Farajaland integration demo documentation. Do not use a real OpenCRVS account to complete this tutorial. | +| Writing the subject file fails with `FileExistsError` | `os.open` refuses `O_EXCL` when the file is already there, so a second run cannot overwrite it. | Remove `../opencrvs-parent-subjects.json` and run the step again. | +| The request returns no assertion | The demo was reset and the synthetic registration no longer exists under that national ID, so the fixed search matched nothing. | Confirm the record is still in the demo. Leave the extractor's `no_match` path alone; a missing registration is an answer. | +| Evidence Gateway reports a source protocol error | A parent identifier appears in the declaration without the record marking it authenticated, or the result set contradicts `total`. | Read the record in the demo. Leave the authentication check in place: an unverified parent entry is not a registered parent. | +| The relationship question answers `false` for a parent you believe is recorded | The candidate national ID does not match the identifier the registration records for that role. | Compare it with the record in the demo. `false` is the source's current answer, not a defect to tune away. | +| Requests start failing after several runs | OpenCRVS audits its searches and applies a daily request limit on the integration demo. | Wait for the limit to reset before running the tutorial again. Do not add retries around the source. | +| Evidence Gateway reports a source dependency failure | The demo host was unreachable or slow, or the response exceeded `timeoutMilliseconds` or `maximumResponseBytes`. | Retry later. Treat the bounds as reviewed source policy, and raise one only after deciding it is right for the deployment. | +| The token request or the search fails with a certificate error | An intercepting proxy or an out-of-date trust store on your machine. | Repair the trust store, or exempt the demo hosts from interception. Do not reach for `--insecure`: the source denies redirects and reaches OpenCRVS over HTTPS only. | + ## Next - [Request a birth certificate SD-JWT VC from OpenCRVS](../issue-a-birth-certificate-vc-from-opencrvs/) diff --git a/docs/site/src/content/docs/tutorials/verify-an-assertion-as-a-consumer.mdx b/docs/site/src/content/docs/tutorials/verify-an-assertion-as-a-consumer.mdx index 8c8a97859..993eb2a88 100644 --- a/docs/site/src/content/docs/tutorials/verify-an-assertion-as-a-consumer.mdx +++ b/docs/site/src/content/docs/tutorials/verify-an-assertion-as-a-consumer.mdx @@ -141,6 +141,16 @@ cannot reveal `person-123`. The opaque binding is meaningful only inside the audience and purpose for which the provider produced it. The same subject produces an unrelated binding for another audience or purpose. +## If the retained inputs are missing + +Every step reads files the first tutorial left behind. The Python steps raise `FileNotFoundError` +when `.evidence/requests/first-assertion/verification.json` or `verified.json` is absent, and +`evidence verify` reports `evidence: stored response verification failed (malformed)` when +`assertion.jws.json` is. All three mean the same thing: the earlier request was prepared under a +different `--name`, or that working directory is gone. Rerun the request and verification steps of +[Get your first Evidence Gateway assertion](../first-evidence-assertion/), naming the request +`first-assertion`, then return here. No service needs to be running for this tutorial. + ## Next - [Manage verifier trust and key rotation](../manage-evidence-verifier-trust/) diff --git a/docs/site/src/styles/custom.css b/docs/site/src/styles/custom.css index 89871b653..38f50d606 100644 --- a/docs/site/src/styles/custom.css +++ b/docs/site/src/styles/custom.css @@ -460,6 +460,10 @@ pre, } .sidebar-content details > summary { + /* The mark sits beside the label, not adrift at the pane edge: it belongs to + the word it opens. */ + justify-content: flex-start; + gap: 5px; padding: 0; } @@ -470,8 +474,14 @@ pre, padding: 10px 0 0; } +/* Collapsing is how this sidebar carries its depth, and four of the top-level + groups are collapsed too, so every group keeps its disclosure mark. Sized to + the uppercase label rather than to Starlight's default, which dwarfs it. */ .sidebar-content details > summary .caret { - display: none; + width: 13px; + height: 13px; + color: var(--registry-muted); + flex: none; } .sidebar-content .large { diff --git a/products/evidence/CONCEPT.md b/products/evidence/CONCEPT.md index 1bc82a889..1b0f81930 100644 --- a/products/evidence/CONCEPT.md +++ b/products/evidence/CONCEPT.md @@ -1124,7 +1124,7 @@ selectors, secrets, or document digests into this uninterpreted random value. A request against a requirement declaring the holder-bound mode of section 8.6 carries this nonce under exactly the same rules, and the assertion does not echo it, because a holder-bound assertion names no relying party to correlate it -with. Nothing above changes: the runtime still does not store it, and the +with. None of that changes: the runtime still does not store it, and the presenter binding that mode provides is proven to the relying party by a key-binding JWT over the relying party's own challenge, never by this value. @@ -1190,7 +1190,7 @@ the unsigned envelope: } ``` -The example above is audience-scoped and abbreviated. Every assertion states its declared `subjectBinding`. An audience-scoped one carries `audience` and the echoed `requestNonce` together, as this example does. A holder-bound one carries neither, and names its holder key in the confirmation claim of the serialization that section 8.6 restricts it to. +This example is audience-scoped and abbreviated. Every assertion states its declared `subjectBinding`. An audience-scoped one carries `audience` and the echoed `requestNonce` together, as this example does. A holder-bound one carries neither, and names its holder key in the confirmation claim of the serialization that section 8.6 restricts it to. The JWS object contains `protected`, `payload`, and `signature` members. `payload` is the base64url encoding of the exact UTF-8 JSON evidence bytes. This avoids a separate JSON canonicalization contract and does not duplicate the evidence object beside its signature. @@ -1210,12 +1210,12 @@ The unsigned success is deliberately distinct: } ``` -The nested object is complete on the wire; it is abbreviated above. The fixed -outer schema and markers ensure stored unsigned output does not claim a JWS -proof. The JWS verifier rejects this representation. A separate unsigned parser -may check schema and policy but returns an explicitly unverified result. Version -one never uses JWS `alg: none`, an empty signature, or a JWS-shaped unsigned -object. +The nested object is complete on the wire; it is abbreviated in this example. +The fixed outer schema and markers ensure stored unsigned output does not claim +a JWS proof. The JWS verifier rejects this representation. A separate unsigned +parser may check schema and policy but returns an explicitly unverified result. +Version one never uses JWS `alg: none`, an empty signature, or a JWS-shaped +unsigned object. ### 11.1 Response integrity and verification @@ -1805,7 +1805,7 @@ FactSet as transient `prior_facts`; Rust may bind a declared scalar fact to a complete fetch path segment. The response cannot select a source, origin, method, credential, or additional call. Section 15.7 adds one further closed kind, gated by an operator, that widens the fixed fetch into a declared set; -the refusal below is what it preserves, and every count it raises stays a +that section preserves the same refusal, and every count it raises stays a property of the bundle rather than of a response. Script-selected sources, URLs, methods, headers, credentials, retries, @@ -1926,7 +1926,7 @@ one closed acquisition kind for exactly that shape and adds nothing else. Unlike the other profiles in this section, this one is implemented. It was originally written with an adopter gate ahead of it, and that gate was waived by a deliberate product decision rather than met. Its Version 1 non-goals and -the refusals below are unaffected by that decision and remain in force. +this section's refusals are unaffected by that decision and remain in force. #### What the kind adds diff --git a/products/evidence/FIRST-CURL-TEST.md b/products/evidence/FIRST-CURL-TEST.md index 8a116b400..f2ffb5f3e 100644 --- a/products/evidence/FIRST-CURL-TEST.md +++ b/products/evidence/FIRST-CURL-TEST.md @@ -93,8 +93,8 @@ claim. The first-curl bundle and its matched grant both permit `unsigned-json`, so you may ask the same route for a visibly unsigned envelope. Run this before the -signed request below, because the harness shuts down as soon as it verifies the -signed response: +signed request in the next section, because the harness shuts down as soon as +it verifies the signed response: ```bash curl --fail-with-body \ diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index f9e9a7871..0cf632884 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -745,8 +745,9 @@ requires; it is what proves sealed history was not tampered with. ## Audit chain rotation and rollback -Audit-segment rotation below keeps one key and one continuous epoch. Rotating -the audit master is different and always starts a new epoch: +Audit-segment rotation, described later in this section, keeps one key and one +continuous epoch. Rotating the audit master is different and always starts a +new epoch: 1. Drain traffic and stop the sole writer. 2. Run `evidence verify-audit`; record the old chain head, bundle revision, @@ -856,7 +857,7 @@ Run against a running service, the command verifies sealed history only and says so in `active-segment`, because reading the active segment while a writer may be mid-append would race the write and risk reporting a partially written final record as corruption; that is expected and is not itself a finding. To -prove the active segment too, stop the service first, as under Rollback below. +prove the active segment too, stop the service first, as under Rollback. A gap in the sealed sequence, for example sequence 3 archived and removed while 1, 2, and 4 remain, is reported as a distinct missing-segment result naming the absent sequence and stating that it is not corruption, so an operator can tell @@ -1012,11 +1013,11 @@ makes the endpoint scrapable by every neighbouring workload. `127.0.0.1` with a same-pod or same-host collector is the shape that keeps the operator boundary the operator intended; any wider binding must be closed by a network policy, and the operator owns that control. - -The two request-boundary series above describe the HTTP boundary only. Version -1 publishes no source-call, signing, or credential-acquisition series. A slow -or failing upstream source is visible only as evidence-request duration and as -the problem code the boundary returned; signing, audit-chain, and + `evidence_http_requests_total` and `evidence_http_request_duration_seconds` +describe the HTTP boundary only. Version 1 publishes no source-call, signing, +or credential-acquisition series. A slow or failing upstream source is visible +only as evidence-request duration and as the problem code the boundary +returned; signing, audit-chain, and source-credential health are reported by `/ready` rather than by telemetry. Three unlabeled gauges are published on the same listener. None carries any of @@ -1105,12 +1106,12 @@ resolved by check; readiness owns them. Fixture evaluation covers positive, negative, boundary, missing-data, source-failure, existence-disclosure, and anti-reconstruction behavior without a running source. - -`evidence check --require-runtime-dependencies` is the pre-routing container -form. In addition to the checks above, it opens and verifies the audit writer, -requires the signer self-test, resolves source credentials without sending an -evidence-data request, and requires the configured access-token JWKS endpoint -to provide a usable key set. This fail-closed preflight does not change normal + `evidence check --require-runtime-dependencies` is the pre-routing container +form. In addition to what `evidence check` verifies, it opens and verifies the +audit writer, requires the signer self-test, resolves source credentials +without sending an evidence-data request, and requires the configured +access-token JWKS endpoint to provide a usable key set. This fail-closed +preflight does not change normal serving readiness, which retains its bounded issuer-outage behavior. For `assuranceProfile: local`, supervised Mint may use the exact canonical @@ -1329,9 +1330,9 @@ recommended deployment posture. Keep the shipped defaults and tune from observed traffic. ## Capacity planning - -The measured rate above is one host with one constant source. Sizing a real -deployment is a matter of finding which ceiling binds first, and for most + The rate in the Measured throughput section is one host with one constant +source. Sizing a real deployment is a matter of finding which ceiling binds +first, and for most deployments it is not Evidence. Outbound source concurrency binds first whenever the provider is slower than @@ -1377,11 +1378,11 @@ none of those. It appears only as `evidence_http_request_duration_seconds` rising while the request count stays flat, because Evidence is waiting on the provider and reporting success when the answer arrives; confirming that diagnosis needs source latency observed at the provider, which is why the -`concurrencyLimit` arithmetic above is worth doing before traffic rather than +`concurrencyLimit` arithmetic is worth doing before traffic rather than after. Because the audit sink commits in groups, a deployment held to few -requests in flight also pays a higher per-record audit cost than the table -above, which is a consequence of the low concurrency rather than a separate -problem to tune. +requests in flight also pays a higher per-record audit cost than the table in +the Measured throughput section, which is a consequence of the low concurrency +rather than a separate problem to tune. ## Verification and release limit diff --git a/products/evidence/PERFORMANCE.md b/products/evidence/PERFORMANCE.md index 64ebfae25..4d915c1de 100644 --- a/products/evidence/PERFORMANCE.md +++ b/products/evidence/PERFORMANCE.md @@ -65,7 +65,7 @@ Per-append cost is 3.1 to 3.8 ms. On macOS, `File::sync_all` issues `F_FULLFSYNC`, a true device write barrier. On Linux the same call is an ordinary `fsync`, which on NVMe is far cheaper. **These figures are a macOS floor and must be re-measured on the target Linux host -before they are quoted as production numbers or used to justify the work below.** +before they are quoted as production numbers or used to justify the group commit work.** ## Horizontal scaling works today diff --git a/products/evidence/SOURCE-TESTING.md b/products/evidence/SOURCE-TESTING.md index 8efdfa88f..0f96cb932 100644 --- a/products/evidence/SOURCE-TESTING.md +++ b/products/evidence/SOURCE-TESTING.md @@ -208,7 +208,8 @@ The shared cases are: expecting only the data-free unavailable result. It carries no lookup label because the provider has already hidden whether the upstream state was no match or ambiguity; the one neutral case satisfies both fixture coverage - categories while the HTTP contract cases above prove exact wire matching; + categories while the `unresolvedProblem` tuple and negative cases prove + exact wire matching; - `401`, `403`, `429`, and `5xx`; - timeout, redirect, and response larger than the configured maximum; - credentials rejected without any credential value in diagnostics; @@ -324,7 +325,7 @@ post-checkpoint gap list, see [`FIRST-CURL-TEST.md`](FIRST-CURL-TEST.md). For the same deterministic path exercised through the SD-JWT VC response format and its offline verifier, see [`SD-JWT-VC-DEMO.md`](SD-JWT-VC-DEMO.md). Both are mock-backed and credential-free, so neither is a live test and neither depends -on the ordering below. +on the required order for live tests. Live tests are implemented in a separate ignored integration-test target. The required order is: diff --git a/products/evidence/reference/authoring-projects/CONFIG.md b/products/evidence/reference/authoring-projects/CONFIG.md index c2f8f46fc..5ea4996ad 100644 --- a/products/evidence/reference/authoring-projects/CONFIG.md +++ b/products/evidence/reference/authoring-projects/CONFIG.md @@ -24,8 +24,8 @@ compile, and compiling is where the frozen contract applies. Every authored document is closed. Each type in `model.rs` and `marker.rs` carries `#[serde(deny_unknown_fields)]`, so a key the form does not know is a -rejection rather than something carried along. All names below are exact and -case-sensitive. +rejection rather than something carried along. All names in this reference are +exact and case-sensitive. ## What an authoring project holds @@ -377,7 +377,7 @@ does not state. `compile_concept` writes them into a generated codelist as its codes, and `validate_code` in `crates/registry-evidence/src/bundle.rs` requires each code to begin with an ASCII alphanumeric and to continue with ASCII alphanumerics, `.`, `_`, `:`, or `-`. A value such as `New York` satisfies -every rule in the table above and is then refused as an invalid codelist code. +every rule in the Answers table and is then refused as an invalid codelist code. The other three types carry no second grammar: a `boolean` compiles to an empty constraint object, a `bounded-integer` states the same bound in both layers, and a `reviewed-structured-value`'s named schema is the only authority on its @@ -581,7 +581,7 @@ model cannot ship without a line here, and a line here cannot outlive its key. The blocks are generated. After regenerating the schemas with `products/evidence/scripts/check-authoring-schema.sh`, run `products/evidence/scripts/check-config-key-paths.sh --write`, review the diff, -and document the new keys in the prose above. +and document the new keys in this page's prose. Parity is the same rule the frozen contracts are held to, and it is not the same promise. These schemas are adopter tooling. A key path leaving this diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 98e0a2580..e15eef416 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -909,7 +909,7 @@ "description": "Ordered Relay V2 technical handling levels: public, internal, confidential, and restricted.", "source": { "path": "products/relay-v2/CONCEPT.md", - "sha256": "9ffd47dc4e72770e924d050aa3e1d47225db3dc78bd762570a85a96f4a416799" + "sha256": "6502c4860b3ff48cb85f5833099be607c8d4b2d0d1126fb4b8e3a7790820a7d7" } }, { diff --git a/products/relay-v2/CONCEPT.md b/products/relay-v2/CONCEPT.md index 1353a4b85..09b6fc153 100644 --- a/products/relay-v2/CONCEPT.md +++ b/products/relay-v2/CONCEPT.md @@ -461,12 +461,12 @@ reprojection, spatial joins, or dynamic spatial extension. ### Bounded statistical-dataflow profile -The statistical routes above exist only for a compiled `bindings.sdmx`. Data -uses the frozen SDMX media types and supports a keyed route plus the identical -omitted-key alias. Structure reads expose only the exact generated dataflow and -DSD artifacts. Schema, availability, history, and structure-maintenance routes -do not exist. There are no placeholder responses that promise those future -surfaces. +The SDMX routes in the HTTP contract section exist only for a compiled +`bindings.sdmx`. Data uses the frozen SDMX media types and supports a keyed +route plus the identical omitted-key alias. Structure reads expose only the +exact generated dataflow and DSD artifacts. Schema, availability, history, and +structure-maintenance routes do not exist. There are no placeholder responses +that promise those future surfaces. The dataflow's one fixed access decision, metadata visibility, snapshot cache posture, source revision, query ceiling, and audit gates apply identically to @@ -535,7 +535,8 @@ Reviewed SQLite views are the source disclosure boundary. They exclude internal columns, normalize public values, delink identifiers, implement reviewed derivations, and expose only intended source bindings. Relay never accepts caller-created projections. A request may only narrow the authorized compiled -disclosure profile as described above. +disclosure profile described in the Registry operations and safe requester +minimization section. ### Small, explainable access decisions