diff --git a/crates/registry-manifest-core/src/lib.rs b/crates/registry-manifest-core/src/lib.rs index 3ed071cb4..0a75584e3 100644 --- a/crates/registry-manifest-core/src/lib.rs +++ b/crates/registry-manifest-core/src/lib.rs @@ -567,8 +567,6 @@ pub struct EvaluationProfileManifest { pub claim_id: String, pub subject_id_type: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_source_observed_age_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub evidence_pack: Option, } diff --git a/crates/registry-manifest-core/tests/metadata_core.rs b/crates/registry-manifest-core/tests/metadata_core.rs index 3b6059b17..ad26f487a 100644 --- a/crates/registry-manifest-core/tests/metadata_core.rs +++ b/crates/registry-manifest-core/tests/metadata_core.rs @@ -2117,7 +2117,6 @@ evaluation_profiles: ruleset: age-eligibility-v1 claim_id: age_eligibility subject_id_type: national_id - max_source_observed_age_seconds: 86400 requirements: - id: age_requirement title: Age requirement @@ -2175,6 +2174,37 @@ fn federated_evaluation_manifest_validates_and_renders_catalog_fields() { ); } +#[test] +fn evaluation_profile_rejects_removed_source_observed_age_field() { + let error = serde_yaml_ng::from_str::( + r#" +schema_version: registry-manifest/v1 +catalog: + id: removed-source-age + base_url: https://registry.example.test + title: Removed Source Age + publisher: + name: Example Registry +evaluation_profiles: + - id: age_eligibility_profile + ruleset: age-eligibility-v1 + claim_id: age_eligibility + subject_id_type: national_id + max_source_observed_age_seconds: 86400 +datasets: [] +codelists: [] +"#, + ) + .expect_err("source-observation age is not a manifest field"); + + assert!( + error + .to_string() + .contains("max_source_observed_age_seconds"), + "the rejection must name the removed key; got: {error}" + ); +} + #[test] fn evaluation_profile_evidence_pack_parses_compiles_and_renders_catalog() { let raw = r#" diff --git a/crates/registry-relay/demo/config/all_demos.metadata.yaml b/crates/registry-relay/demo/config/all_demos.metadata.yaml index 1f0b6cd74..b1ca9299c 100644 --- a/crates/registry-relay/demo/config/all_demos.metadata.yaml +++ b/crates/registry-relay/demo/config/all_demos.metadata.yaml @@ -29,12 +29,10 @@ evaluation_profiles: ruleset: benefits-person-v1 claim_id: benefits_person subject_id_type: id - max_source_observed_age_seconds: 86400 - id: education-student-v1 ruleset: education-student-v1 claim_id: education_student subject_id_type: id - max_source_observed_age_seconds: 86400 requirements: - id: benefits_person_requirement iri: https://demo.example.gov/requirements/benefits-person diff --git a/crates/registry-relay/demo/config/all_standards.metadata.yaml b/crates/registry-relay/demo/config/all_standards.metadata.yaml index 5246baa33..feb1c4d47 100644 --- a/crates/registry-relay/demo/config/all_standards.metadata.yaml +++ b/crates/registry-relay/demo/config/all_standards.metadata.yaml @@ -29,17 +29,14 @@ evaluation_profiles: ruleset: disability-status-v1 claim_id: disability_status subject_id_type: id - max_source_observed_age_seconds: 86400 - id: social-group-v1 ruleset: social-group-v1 claim_id: social_registry_group subject_id_type: id - max_source_observed_age_seconds: 86400 - id: farmer-status-v1 ruleset: farmer-status-v1 claim_id: farmer_status subject_id_type: id - max_source_observed_age_seconds: 86400 requirements: - id: disability_status_requirement iri: https://demo.example.gov/requirements/disability-status diff --git a/crates/registry-relay/demo/config/benefits_casework.metadata.yaml b/crates/registry-relay/demo/config/benefits_casework.metadata.yaml index 8dc8f44d6..6bd3f3375 100644 --- a/crates/registry-relay/demo/config/benefits_casework.metadata.yaml +++ b/crates/registry-relay/demo/config/benefits_casework.metadata.yaml @@ -27,7 +27,6 @@ evaluation_profiles: ruleset: benefits-person-v1 claim_id: benefits_person subject_id_type: id - max_source_observed_age_seconds: 86400 requirements: - id: benefits_person_requirement iri: https://demo.example.gov/requirements/benefits-person diff --git a/crates/registry-relay/demo/config/disability_registry.metadata.yaml b/crates/registry-relay/demo/config/disability_registry.metadata.yaml index 302d02f83..176519bce 100644 --- a/crates/registry-relay/demo/config/disability_registry.metadata.yaml +++ b/crates/registry-relay/demo/config/disability_registry.metadata.yaml @@ -27,17 +27,14 @@ evaluation_profiles: ruleset: disability-status-v1 claim_id: disability_status subject_id_type: id - max_source_observed_age_seconds: 86400 - id: social-group-v1 ruleset: social-group-v1 claim_id: social_registry_group subject_id_type: id - max_source_observed_age_seconds: 86400 - id: farmer-status-v1 ruleset: farmer-status-v1 claim_id: farmer_status subject_id_type: id - max_source_observed_age_seconds: 86400 requirements: - id: disability_status_requirement iri: https://demo.example.gov/requirements/disability-status diff --git a/crates/registry-relay/demo/config/education_registry.metadata.yaml b/crates/registry-relay/demo/config/education_registry.metadata.yaml index 5aa1b4dae..e8548ebf3 100644 --- a/crates/registry-relay/demo/config/education_registry.metadata.yaml +++ b/crates/registry-relay/demo/config/education_registry.metadata.yaml @@ -27,7 +27,6 @@ evaluation_profiles: ruleset: education-student-v1 claim_id: education_student subject_id_type: id - max_source_observed_age_seconds: 86400 requirements: - id: education_student_requirement iri: https://demo.example.gov/requirements/education-student diff --git a/crates/registry-relay/tests/entity_routes.rs b/crates/registry-relay/tests/entity_routes.rs index c5fdd573c..64e42b819 100644 --- a/crates/registry-relay/tests/entity_routes.rs +++ b/crates/registry-relay/tests/entity_routes.rs @@ -72,17 +72,14 @@ evaluation_profiles: ruleset: exact-name claim_id: exact_name subject_id_type: id - max_source_observed_age_seconds: 86400 - id: exact-name-targeted ruleset: exact-name-targeted claim_id: exact_name_targeted subject_id_type: id - max_source_observed_age_seconds: 86400 - id: hidden-name ruleset: hidden-name claim_id: hidden_name subject_id_type: id - max_source_observed_age_seconds: 86400 ecosystem_bindings: - id: baseline-dpi/v1 version: v1 diff --git a/docs/site/scripts/check-svg-a11y.mjs b/docs/site/scripts/check-svg-a11y.mjs index 69a4d7577..476e49c26 100644 --- a/docs/site/scripts/check-svg-a11y.mjs +++ b/docs/site/scripts/check-svg-a11y.mjs @@ -1,7 +1,14 @@ +#!/usr/bin/env node + import { readdir, readFile } from 'node:fs/promises'; -import { join, relative } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const scriptDir = dirname(scriptPath); + +const DEFAULT_IMAGE_DIR = resolve(scriptDir, '../public/images'); -const imageDir = 'public/images'; const expected = new Set([ 'registry-family-map.svg', 'registry-architecture-flow.svg', @@ -18,27 +25,333 @@ const expected = new Set([ 'standards-claim-levels.svg', ]); -const entries = await readdir(imageDir, { withFileTypes: true }); -const errors = []; -const seen = new Set(); +// Matches src/styles/custom.css's `img[src*='/images/'][src$='.svg']` rule: +// diagrams are pinned to a literal white surface in both themes, so that is +// the background every shipped diagram's text must clear 4.5:1 against. +export const DIAGRAM_SURFACE = '#ffffff'; +export const MIN_TEXT_CONTRAST = 4.5; + +// Only #rgb and #rrggbb are scoreable. SVG accepts far more (named colors, +// rgb()/hsl() functions, `none`, and hex forms carrying an alpha channel), but +// scoring those would mean shipping a color database and compositing rules. +// They are reported instead of guessed: the earlier code produced NaN for them, +// and NaN < 4.5 is false, so unreadable text passed the gate in silence. +const SCOREABLE_HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i; + +export function isScoreableColor(value) { + return SCOREABLE_HEX_RE.test(value); +} + +// Scoreable hex is normalized to lowercase six-digit form, so `#FFF`, `#fff`, +// and `#ffffff` are one color. They paint identical pixels, and reverse text is +// detected by comparing against DIAGRAM_SURFACE for equality: leaving the +// shorthand unexpanded made `fill="#fff"` on a dark chip miss that test and get +// scored 1:1 against a canvas it never sits on, failing a legible diagram. +// A message therefore quotes the expanded spelling rather than the authored +// one; the color is still the one in the file. +// Anything else is kept exactly as authored: it is never scored, only +// reported, and the report is only useful if it quotes the string the author +// can find in the file. +function normalizeColor(value) { + return isScoreableColor(value) ? expandHex(value.toLowerCase()) : value; +} -for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.svg')) continue; - const file = join(imageDir, entry.name); - const text = await readFile(file, 'utf8'); - seen.add(entry.name); - if (!/\s]/.test(text)) errors.push(`${relative('.', file)} missing `); - if (!/<desc[>\s]/.test(text)) errors.push(`${relative('.', file)} missing <desc>`); - if (!/role="img"/.test(text)) errors.push(`${relative('.', file)} missing role="img"`); +function expandHex(hex) { + const value = hex.slice(1); + if (value.length === 3) { + return `#${[...value].map((c) => c + c).join('')}`; + } + return `#${value}`; } -for (const name of expected) { - if (!seen.has(name)) errors.push(`public/images/${name} is missing`); +function srgbChannelToLinear(channel) { + const c = channel / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} + +function relativeLuminance(hex) { + if (!isScoreableColor(hex)) { + throw new TypeError(`cannot compute luminance for unsupported color ${hex}`); + } + const normalized = expandHex(hex); + const r = parseInt(normalized.slice(1, 3), 16); + const g = parseInt(normalized.slice(3, 5), 16); + const b = parseInt(normalized.slice(5, 7), 16); + return ( + 0.2126 * srgbChannelToLinear(r) + + 0.7152 * srgbChannelToLinear(g) + + 0.0722 * srgbChannelToLinear(b) + ); +} + +// WCAG 2 contrast ratio between two sRGB hex colors, in the range [1, 21]. +export function contrastRatio(hexA, hexB) { + const lumA = relativeLuminance(hexA); + const lumB = relativeLuminance(hexB); + const lighter = Math.max(lumA, lumB); + const darker = Math.min(lumA, lumB); + return (lighter + 0.05) / (darker + 0.05); +} + +// An SVG may carry more than one <style> element, so every block is read; +// later rules win, matching CSS source order for equally specific selectors. +const STYLE_BLOCK_RE = /<style[^>]*>([\s\S]*?)<\/style>/g; + +function parseClassFillMap(svgText) { + // className -> { color, order }. `order` is the rule's position across all + // <style> blocks, so a text carrying several classes can be resolved the way + // CSS resolves it. Every selector handled here is a single class, so all + // candidates have equal specificity and source order alone decides. + const classFills = new Map(); + let order = 0; + // Only single-class selectors are resolved (e.g. `.tag { fill: #000091; }`). + // Compound selectors like `.cardtitle.sm` are skipped: in the current + // diagrams they only ever override font-size, never fill. + for (const styleMatch of svgText.matchAll(STYLE_BLOCK_RE)) { + const ruleRe = /\.([\w-]+)\s*\{([^}]*)\}/g; + let rule; + while ((rule = ruleRe.exec(styleMatch[1]))) { + order += 1; + const [, className, body] = rule; + // A rule may declare fill more than once. CSS paints the last + // declaration, so reading the first would score a color the reader never + // sees, and it fails in the dangerous direction: a legible fill written + // ahead of an illegible one would hide the illegible one from the gate. + // Any value is captured, not just hex, so that a non-hex override of a + // hex fallback reaches the unscoreable check instead of being ignored. + let effectiveFill = null; + for (const declaration of body.matchAll(/fill:\s*([^;}]+)/g)) { + effectiveFill = declaration[1].trim(); + } + if (effectiveFill) classFills.set(className, { color: effectiveFill, order }); + } + } + return classFills; } -if (errors.length) { - console.error(errors.join('\n')); - process.exit(1); +function attrValue(tag, name) { + const match = tag.match(new RegExp(`${name}="([^"]*)"`)); + return match ? match[1] : null; } -console.log('SVG accessibility check passed.'); +// The `fill` an inline `style` attribute declares, or null if it declares none. +// This is a real inline style, so it outranks both a class rule and the `fill` +// presentation attribute. As in a stylesheet rule, the last declaration wins. +function inlineStyleFill(tag) { + const style = attrValue(tag, 'style'); + if (!style) return null; + let fill = null; + for (const declaration of style.matchAll(/(?:^|;)\s*fill:\s*([^;]+)/g)) { + fill = declaration[1].trim(); + } + return fill; +} + +// Resolves the effective fill color of every <text> and <tspan> element in an +// SVG, walking <g fill="..."> ancestry and class-based fills from a <style> +// block. Returns { colors, unresolved, reverseText }: `colors` are fills +// actually used to paint text (deduplicated), `unresolved` counts <text> +// elements whose fill could not be determined (no class rule, no fill +// attribute, no enclosing <g fill>), and `reverseText` holds the backdrop fill +// behind each <text> painted in the surface color, one entry per element, +// null when no shape precedes it. +export function extractTextFillColors(svgText) { + const classFills = parseClassFillMap(svgText); + // <style> regions are skipped during the walk rather than deleted from the + // text beforehand. Deleting them cannot be done safely with one pass: a + // removal can splice its neighbours into a fresh `<style` (`<sty<style>le>`), + // so the scanned string would still hold what the removal was meant to drop. + // Tracking the region here needs no rewriting and cannot resurrect a tag. + const tokenRe = + /<style\b[^>]*>|<\/style>|<g\b[^>]*>|<\/g>|<(?:text|tspan)\b[^>]*>|<(?:rect|circle|ellipse|polygon|path)\b[^>]*>/g; + const gFillStack = []; + const colors = new Set(); + const reverseText = []; + // The fill of the shape drawn immediately before the current element, and the + // backdrop the enclosing <text> claimed from it. Only the element directly + // after a shape may claim that shape: a single "last shape seen" carried + // forward let an unrelated earlier swatch stand in as the backdrop for a + // stray label anywhere later in the document. + let precedingShapeFill = null; + let textBackdrop = null; + let unresolved = 0; + let inStyle = false; + let token; + while ((token = tokenRe.exec(svgText))) { + const tag = token[0]; + if (tag.startsWith('<style')) { + inStyle = true; + continue; + } + if (tag === '</style>') { + inStyle = false; + continue; + } + // CSS declarations are not painted content; parseClassFillMap reads them. + if (inStyle) continue; + if (tag === '</g>') { + gFillStack.pop(); + continue; + } + if (tag.startsWith('<g')) { + const inherited = gFillStack[gFillStack.length - 1] ?? null; + gFillStack.push(attrValue(tag, 'fill') ?? inherited); + continue; + } + if (!tag.startsWith('<text') && !tag.startsWith('<tspan')) { + // A drawn shape. Remembered so reverse text can be scored against the + // chip it is painted on rather than against the canvas. A shape with no + // fill of its own still displaces the previous one: it is what the next + // element actually follows. + const shapeFill = attrValue(tag, 'fill'); + precedingShapeFill = shapeFill ? normalizeColor(shapeFill) : null; + continue; + } + // <text ...> or <tspan ...>. A tspan paints the glyphs it wraps, so a fill + // it declares is what the reader sees, not the parent text's fill. + const isTspan = tag.startsWith('<tspan'); + if (!isTspan) { + // A <text> claims the shape it directly follows, and consumes it, so a + // sibling label further along cannot claim the same shape. A <tspan> sits + // on whatever its enclosing <text> sits on, so it reuses that backdrop. + textBackdrop = precedingShapeFill; + precedingShapeFill = null; + } + const presentationFill = attrValue(tag, 'fill'); + const classAttr = attrValue(tag, 'class'); + // The winning class is the one whose rule appears last, not the one named + // first in the class attribute; `class="safe danger"` and `class="danger + // safe"` must both resolve to whichever rule the stylesheet declares later. + const classFill = classAttr + ? classAttr + .split(/\s+/) + .map((name) => classFills.get(name)) + .filter(Boolean) + .reduce((winner, candidate) => (winner && winner.order > candidate.order ? winner : candidate), null) + ?.color + : undefined; + const inheritedFill = gFillStack[gFillStack.length - 1] ?? null; + // Author-level precedence, strongest first: an inline `style` fill, then a + // class rule, then the `fill` presentation attribute, then a fill inherited + // from an enclosing <g>. `fill` on the element is a presentation attribute + // rather than an inline style, so reading it ahead of a class rule scored a + // color the reader never sees and let the rendered text through unmeasured. + // A tspan without a fill of its own inherits the fill its parent <text> + // already resolved and recorded, so it is skipped rather than rescored or + // counted unresolved. + const resolved = + inlineStyleFill(tag) ?? classFill ?? presentationFill ?? (isTspan ? null : inheritedFill); + if (resolved) { + const color = normalizeColor(resolved); + colors.add(color); + // Reverse text is only legible against the shape it is painted on, so + // the backdrop is captured per element rather than scored against the + // canvas. Document adjacency stands in for geometry: in these diagrams a + // chip is always drawn immediately before the label that sits on it. + if (color === DIAGRAM_SURFACE) reverseText.push(textBackdrop); + } else if (!isTspan) { + unresolved += 1; + } + } + return { colors: [...colors], unresolved, reverseText }; +} + +// Contrast errors for one SVG's text against the fixed diagram surface. +// Text painted in the surface color is reverse text: it is legible only +// against the chip it sits on (e.g. the "DCI-NATIVE" tag on a #000091 rect in +// registry-country-evidence-mesh.svg), so it is scored against that chip +// instead of against the canvas it would score 1:1 on. +// Reverse text is legible only against the shape drawn behind it. Document +// order stands in for geometry: in these diagrams the chip is always drawn +// immediately before the label that sits on it. That is a convention rather +// than a layout computation, so a label with no shape before it is reported +// instead of assumed safe, which is the direction that fails loudly. +function reverseTextErrors(fileLabel, backdrops) { + const errors = []; + for (const backdrop of backdrops) { + if (backdrop === null) { + errors.push( + `${fileLabel} paints reverse text in the surface color ${DIAGRAM_SURFACE} with no shape ` + + `drawn before it, so nothing establishes a backdrop it can be read against`, + ); + continue; + } + if (!isScoreableColor(backdrop)) { + errors.push( + `${fileLabel} paints reverse text on a backdrop ${backdrop} that cannot be scored: ` + + `only #rgb and #rrggbb are supported, so express it as an opaque hex color`, + ); + continue; + } + const ratio = contrastRatio(DIAGRAM_SURFACE, backdrop); + if (ratio < MIN_TEXT_CONTRAST) { + errors.push( + `${fileLabel} reverse text has ${ratio.toFixed(2)}:1 contrast against its backdrop ` + + `${backdrop} (needs >= ${MIN_TEXT_CONTRAST}:1)`, + ); + } + } + return errors; +} + +export function svgContrastErrors(fileLabel, svgText) { + const errors = []; + const { colors, unresolved, reverseText } = extractTextFillColors(svgText); + if (unresolved > 0) { + errors.push(`${fileLabel} has ${unresolved} <text> element(s) with no resolvable fill color`); + } + errors.push(...reverseTextErrors(fileLabel, reverseText)); + for (const color of colors) { + // Scored above, against its own backdrop rather than the canvas. + if (color === DIAGRAM_SURFACE) continue; + if (!isScoreableColor(color)) { + errors.push( + `${fileLabel} text fill ${color} cannot be scored: only #rgb and #rrggbb are supported, ` + + `so express it as an opaque hex color`, + ); + continue; + } + const ratio = contrastRatio(color, DIAGRAM_SURFACE); + if (ratio < MIN_TEXT_CONTRAST) { + errors.push( + `${fileLabel} text fill ${color} has ${ratio.toFixed(2)}:1 contrast against the fixed ` + + `diagram surface ${DIAGRAM_SURFACE} (needs >= ${MIN_TEXT_CONTRAST}:1)`, + ); + } + } + return errors; +} + +export async function svgAccessibilityErrors(imageDir = DEFAULT_IMAGE_DIR) { + const entries = await readdir(imageDir, { withFileTypes: true }); + const errors = []; + const seen = new Set(); + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.svg')) continue; + const file = join(imageDir, entry.name); + const text = await readFile(file, 'utf8'); + const label = relative('.', file); + seen.add(entry.name); + if (!/<title[>\s]/.test(text)) errors.push(`${label} missing <title>`); + if (!/<desc[>\s]/.test(text)) errors.push(`${label} missing <desc>`); + if (!/role="img"/.test(text)) errors.push(`${label} missing role="img"`); + errors.push(...svgContrastErrors(label, text)); + } + + for (const name of expected) { + if (!seen.has(name)) errors.push(`public/images/${name} is missing`); + } + + return errors; +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + const errors = await svgAccessibilityErrors(); + if (errors.length) { + console.error(errors.join('\n')); + process.exitCode = 1; + } else { + console.log('SVG accessibility check passed.'); + } +} diff --git a/docs/site/scripts/check-svg-a11y.test.mjs b/docs/site/scripts/check-svg-a11y.test.mjs new file mode 100644 index 000000000..7e5ca526e --- /dev/null +++ b/docs/site/scripts/check-svg-a11y.test.mjs @@ -0,0 +1,381 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { test } from 'node:test'; + +import { + contrastRatio, + DIAGRAM_SURFACE, + extractTextFillColors, + MIN_TEXT_CONTRAST, + svgAccessibilityErrors, + svgContrastErrors, +} from './check-svg-a11y.mjs'; + +test('contrastRatio matches known WCAG reference values', () => { + assert.equal(contrastRatio('#000000', '#ffffff'), 21); + assert.equal(contrastRatio('#ffffff', '#ffffff'), 1); + // Order of the two colors must not matter. + assert.equal(contrastRatio('#161616', '#ffffff'), contrastRatio('#ffffff', '#161616')); +}); + +test('extractTextFillColors resolves inline, class-based, and inherited <g> fills', () => { + const svg = `<svg xmlns="http://www.w3.org/2000/svg" role="img"> + <title>td + + inline + classed + + inherited + + `; + const { colors, unresolved } = extractTextFillColors(svg); + assert.deepEqual([...colors].sort(), ['#161616', '#3a3a3a', '#6a6a6a']); + assert.equal(unresolved, 0); +}); + +test('extractTextFillColors reports unresolved fills instead of guessing', () => { + const svg = ` + td + no fill anywhere + `; + const { colors, unresolved } = extractTextFillColors(svg); + assert.deepEqual(colors, []); + assert.equal(unresolved, 1); +}); + +test('svgContrastErrors excludes pure white text (reverse text on a colored chip)', () => { + const svg = ` + td + + DCI-NATIVE + `; + assert.deepEqual(svgContrastErrors('fixture.svg', svg), []); +}); + +test('white text with no chip behind it is reported, not exempted', () => { + // The blanket white exemption used to pass this at 1:1 against the canvas. + const svg = ` + td + stray label on the canvas + `; + const errors = svgContrastErrors('fixture.svg', svg); + assert.equal(errors.length, 1); + assert.match(errors[0], /reverse text/); +}); + +test('white text on a white shape is reported', () => { + const svg = ` + td + + invisible + `; + assert.equal(svgContrastErrors('fixture.svg', svg).length, 1); +}); + +test('white text is scored against the chip it sits on, not the canvas', () => { + // #767676 on white is 4.54:1, but white ON #767676 is the same ratio, so a + // chip that is too light for reverse text must still fail. + const svg = ` + td + + too light a chip + `; + const errors = svgContrastErrors('fixture.svg', svg); + assert.equal(errors.length, 1, '#ffffff on #bbbbbb is 1.98:1'); + assert.match(errors[0], /#bbbbbb/); +}); + +test('a non-hex declaration later in the rule wins over an earlier hex one', () => { + // Interaction of the last-declaration rule with the unscoreable-color rule: + // reading hex declarations only would retain #161616 and miss that the + // element actually renders white. + const svg = ` + td + + renders white + `; + const errors = svgContrastErrors('fixture.svg', svg); + assert.equal(errors.length, 1); + assert.match(errors[0], /cannot be scored/); + assert.match(errors[0], /white/); +}); + +test('multiple classes resolve by stylesheet order, not class-attribute order', () => { + // Every selector this parser handles is a single class, so specificity is + // equal and the later rule wins regardless of how the names are written. + const style = ''; + for (const classAttr of ['safe danger', 'danger safe']) { + const svg = ` + td + ${style} + two classes + `; + assert.deepEqual( + extractTextFillColors(svg).colors, + ['#cccccc'], + `class="${classAttr}" must resolve to the later rule`, + ); + assert.equal(svgContrastErrors('fixture.svg', svg).length, 1); + } +}); + +test('svgContrastErrors flags text that fails the 4.5:1 threshold against the fixed surface', () => { + const svg = ` + td + low contrast + `; + const errors = svgContrastErrors('fixture.svg', svg); + assert.equal(errors.length, 1); + assert.match(errors[0], /fixture\.svg text fill #cccccc has \d+\.\d\d:1 contrast/); + const ratio = contrastRatio('#cccccc', DIAGRAM_SURFACE); + assert.ok(ratio < MIN_TEXT_CONTRAST, 'fixture color must actually be below the threshold'); +}); + +test('svgAccessibilityErrors reports missing title/desc/role and missing files', async (t) => { + const root = mkdtempSync(resolve(tmpdir(), 'registry-svg-a11y-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(root, { recursive: true }); + writeFileSync( + resolve(root, 'registry-family-map.svg'), + 'no title, desc, or role', + ); + const errors = await svgAccessibilityErrors(root); + + assert.ok(errors.some((e) => e.endsWith('registry-family-map.svg missing '))); + assert.ok(errors.some((e) => e.endsWith('registry-family-map.svg missing <desc>'))); + assert.ok(errors.some((e) => e.endsWith('registry-family-map.svg missing role="img"'))); + assert.ok(errors.includes('public/images/registry-architecture-flow.svg is missing')); +}); + +test('the checked-in diagrams all clear the 4.5:1 text contrast floor', async () => { + assert.deepEqual(await svgAccessibilityErrors(), []); +}); + +test('class fills are resolved from every <style> block, not just the first', () => { + // Editors can emit more than one <style> element. Resolving only the first + // left later blocks' rules unresolved and their content unstripped. + const svg = `<svg xmlns="http://www.w3.org/2000/svg" role="img"> + <title>td + + + resolved by the first block + resolved by the second block + `; + + const { colors, unresolved } = extractTextFillColors(svg); + assert.equal(unresolved, 0, 'both class fills must resolve'); + assert.deepEqual(colors.sort(), ['#161616', '#cccccc']); + + const errors = svgContrastErrors('two-style.svg', svg); + assert.equal(errors.length, 1, 'only the low-contrast fill should fail'); + assert.match(errors[0], /#cccccc/); +}); + +test('style regions are skipped without rewriting the document', () => { + // Deleting + after the style block + `; + + const { colors, unresolved } = extractTextFillColors(svg); + assert.equal(unresolved, 0); + assert.deepEqual(colors, ['#161616'], 'CSS fill declarations must not count as painted text'); + assert.deepEqual(svgContrastErrors('skip.svg', svg), []); +}); + +test('fills that cannot be scored are reported, never treated as passing', () => { + // A non-finite ratio compares false against the threshold, so an unparsed + // fill used to slip through the gate silently. Each of these is a valid SVG + // paint value that the hex math cannot score. + const unscoreable = [ + ['white', 'named color'], + ['rgb(255,255,255)', 'functional notation'], + ['currentColor', 'keyword'], + ['none', 'invisible text'], + ['#0000', 'four-digit hex carrying alpha'], + ['#00000080', 'eight-digit hex carrying alpha'], + ]; + + for (const [fill, why] of unscoreable) { + const svg = ` + td + ${why} + `; + const errors = svgContrastErrors('fixture.svg', svg); + assert.equal(errors.length, 1, `${fill} (${why}) must produce exactly one error`); + assert.match(errors[0], /cannot be scored/, `${fill} must be reported as unscoreable`); + assert.ok(errors[0].includes(fill), `the error must name the offending value, got: ${errors[0]}`); + } +}); + +test('a translucent black fill is not scored as opaque black', () => { + // #00000080 truncated to its first six digits reads as pure black and would + // score 21:1, the maximum, despite being half transparent. + const svg = ` + td + half transparent + `; + assert.deepEqual(svgContrastErrors('fixture.svg', svg).length, 1); +}); + +test('the last fill declaration in a rule wins, as CSS applies it', () => { + // A rule may declare fill more than once; the browser paints the last one. + // Reading the first would score a color the reader never sees, and the + // failure direction that matters is a legible fill masking an illegible one. + const svg = ` + td + + overridden + `; + + const { colors } = extractTextFillColors(svg); + assert.deepEqual(colors, ['#cccccc'], 'the effective fill is the last declaration'); + + const errors = svgContrastErrors('fixture.svg', svg); + assert.equal(errors.length, 1, '#cccccc on white is 1.61:1 and must be reported'); + assert.match(errors[0], /#cccccc/); +}); + +test('a later rule still overrides an earlier one for the same class', () => { + // Guards the sibling precedence rule while the within-rule fix is made: + // equally specific selectors resolve in source order, last wins. + const svg = ` + td + + + overridden + `; + assert.deepEqual(extractTextFillColors(svg).colors, ['#161616']); + assert.deepEqual(svgContrastErrors('fixture.svg', svg), []); +}); + +test('contrastRatio throws rather than returning NaN for an unscoreable color', () => { + assert.throws(() => contrastRatio('white', DIAGRAM_SURFACE), /white/); + assert.throws(() => contrastRatio('#0000', DIAGRAM_SURFACE), /#0000/); + assert.ok(Number.isFinite(contrastRatio('#abc', DIAGRAM_SURFACE)), '#rgb shorthand stays supported'); +}); + +test('shorthand and long spellings of one color resolve to one entry', () => { + const svg = ` + td + short + long + `; + assert.deepEqual(extractTextFillColors(svg).colors, ['#000000']); +}); + +test('shorthand white on a dark chip is reverse text, not a 1:1 failure', () => { + // #fff and #ffffff paint the same pixels. Comparing the authored spelling + // against DIAGRAM_SURFACE by equality missed the shorthand, so a legible + // diagram was rejected at 1:1 against a canvas it never sits on. + const svg = ` + td + + DCI-NATIVE + `; + assert.deepEqual(svgContrastErrors('fixture.svg', svg), []); +}); + +test('a class rule beats a presentation fill attribute, as CSS applies it', () => { + // A `fill` attribute is a presentation attribute, not an inline style: any + // matching CSS rule outranks it. Preferring the attribute scored a color the + // reader never sees and let the rendered low-contrast text through. + const svg = ` + td + + rendered faint + `; + assert.deepEqual(extractTextFillColors(svg).colors, ['#cccccc']); + const errors = svgContrastErrors('fixture.svg', svg); + assert.equal(errors.length, 1); + assert.match(errors[0], /#cccccc/); +}); + +test('a presentation fill still beats a fill inherited from an enclosing group', () => { + const svg = ` + td + legible + `; + assert.deepEqual(svgContrastErrors('fixture.svg', svg), []); +}); + +test('a tspan fill override is scored, not masked by its parent text fill', () => { + const svg = ` + td + legible faint + `; + const errors = svgContrastErrors('fixture.svg', svg); + assert.equal(errors.length, 1, 'the tspan paints the glyphs the reader sees'); + assert.match(errors[0], /#cccccc/); +}); + +test('a tspan with no fill of its own is neither rescored nor counted unresolved', () => { + const svg = ` + td + legible same color + `; + const { colors, unresolved } = extractTextFillColors(svg); + assert.deepEqual(colors, ['#161616']); + assert.equal(unresolved, 0, 'the tspan inherits a fill its parent already resolved'); +}); + +test('a white tspan is scored against the chip its text sits on', () => { + const svg = ` + td + + reverse + `; + assert.deepEqual(svgContrastErrors('fixture.svg', svg), []); +}); + +test('an inline style fill beats both a class rule and a presentation attribute', () => { + // Inline style is the highest author-level declaration: it outranks a class + // rule, which in turn outranks the `fill` presentation attribute. + const svg = ` + td + + rendered faint + `; + assert.deepEqual(extractTextFillColors(svg).colors, ['#cccccc']); + const errors = svgContrastErrors('fixture.svg', svg); + assert.equal(errors.length, 1); + assert.match(errors[0], /#cccccc/); +}); + +test('an inline style declaring fill twice resolves to the last declaration', () => { + const svg = ` + td + overridden + `; + assert.deepEqual(extractTextFillColors(svg).colors, ['#cccccc']); +}); + +test('an inline style without a fill falls through to the class rule', () => { + const svg = ` + td + + legible + `; + assert.deepEqual(svgContrastErrors('fixture.svg', svg), []); +}); + +test('reverse text is not scored against a shape it does not immediately follow', () => { + // A single global "last shape seen" let an unrelated earlier swatch stand in + // as a backdrop for a stray white label anywhere later in the document. + const svg = ` + td + + on the chip + stray, on the canvas + `; + const errors = svgContrastErrors('fixture.svg', svg); + assert.equal(errors.length, 1, 'only the label adjacent to the chip has a backdrop'); + assert.match(errors[0], /no shape/); +}); diff --git a/docs/site/scripts/fetch-openapi.mjs b/docs/site/scripts/fetch-openapi.mjs index 39ebdabfe..b783de875 100644 --- a/docs/site/scripts/fetch-openapi.mjs +++ b/docs/site/scripts/fetch-openapi.mjs @@ -19,6 +19,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { access } from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { join, relative, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import YAML from 'yaml'; import { @@ -28,6 +29,7 @@ import { selectedDocsetId, usesCheckedOutCandidate, } from './docsets.mjs'; +import { retryGitFetch } from './git-fetch-retry.mjs'; const run = promisify(execFile); @@ -80,15 +82,27 @@ async function specFromLocal(localPath, ref, specPath) { } // Shallow-clone a single pinned commit, then read the spec from the worktree. -async function specFromClone(repoId, remote, ref, specPath) { - const dest = join(cacheRoot, `${repoId}-openapi`); +// Only the network `git fetch` is retried (via retryGitFetch): init and +// remote-add are local and effectively instantaneous, and checkout only runs +// once the fetch has actually populated FETCH_HEAD, so retrying the fetch +// alone is the smallest safe unit. `run`, `retryOptions`, and `dest` are test +// seams; production callers should leave them at their defaults. +export async function specFromClone(repoId, remote, ref, specPath, { + run: runGit = run, + retryOptions = {}, + dest = join(cacheRoot, `${repoId}-openapi`), +} = {}) { await rm(dest, { recursive: true, force: true }); await mkdir(dest, { recursive: true }); try { - await run('git', ['init', '--quiet'], { cwd: dest }); - await run('git', ['remote', 'add', 'origin', remote], { cwd: dest }); - await run('git', ['fetch', '--quiet', '--depth', '1', 'origin', ref], { cwd: dest }); - await run('git', ['checkout', '--quiet', 'FETCH_HEAD'], { cwd: dest }); + await runGit('git', ['init', '--quiet'], { cwd: dest }); + await runGit('git', ['remote', 'add', 'origin', remote], { cwd: dest }); + await retryGitFetch( + `${repoId}: fetch ${ref} from ${remote}`, + () => runGit('git', ['fetch', '--quiet', '--depth', '1', 'origin', ref], { cwd: dest }), + retryOptions, + ); + await runGit('git', ['checkout', '--quiet', 'FETCH_HEAD'], { cwd: dest }); } catch (error) { fail(`${repoId}: failed to clone ${remote} at ${ref} for the OpenAPI spec: ${error.message}`); } @@ -161,4 +175,8 @@ async function main() { console.log(`Fetched ${written} OpenAPI spec(s) at pinned refs.`); } -await main(); +// Run the pipeline only when invoked directly, so tests can import the pure +// helpers above without triggering a full clone-and-fetch run. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/docs/site/scripts/fetch-openapi.test.mjs b/docs/site/scripts/fetch-openapi.test.mjs new file mode 100644 index 000000000..e19b217d9 --- /dev/null +++ b/docs/site/scripts/fetch-openapi.test.mjs @@ -0,0 +1,55 @@ +// Unit tests for the OpenAPI fetch-at-ref pipeline (scripts/fetch-openapi.mjs). +// Run with `npm test` (node --test). specFromClone's git executor and retry +// backoff are injectable test seams, so this runs offline and instantly; the +// retry-and-stderr-bounding policy itself is covered exhaustively in +// git-fetch-retry.test.mjs. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { specFromClone } from './fetch-openapi.mjs'; + +test('specFromClone retries a transient fetch failure and reads the spec after success', async () => { + const dest = await mkdtemp(join(tmpdir(), 'fetch-openapi-test-')); + try { + let fetchCalls = 0; + const invoked = []; + const fakeRun = async (command, args) => { + invoked.push(args[0]); + if (args[0] === 'fetch') { + fetchCalls += 1; + if (fetchCalls < 2) { + const error = new Error('Command failed: git fetch --quiet --depth 1 origin deadbeef'); + error.stderr = 'fatal: the remote end hung up unexpectedly'; + throw error; + } + } + if (args[0] === 'checkout') { + // specFromClone reads the spec from the worktree only after checkout + // succeeds; since the git executor is faked, seed that file here, + // where a real checkout would have populated it. + await mkdir(join(dest, 'openapi'), { recursive: true }); + await writeFile(join(dest, 'openapi/demo.openapi.json'), '{"openapi":"3.0.0"}\n'); + } + return { stdout: '', stderr: '' }; + }; + + const raw = await specFromClone( + 'demo-repo', + 'https://example.test/demo.git', + 'deadbeef', + 'openapi/demo.openapi.json', + { run: fakeRun, retryOptions: { sleep: async () => {} }, dest }, + ); + + assert.equal(fetchCalls, 2); + // init and remote-add each ran once; only fetch (the network step) retried. + assert.deepEqual(invoked, ['init', 'remote', 'fetch', 'fetch', 'checkout']); + assert.equal(raw, '{"openapi":"3.0.0"}\n'); + } finally { + await rm(dest, { recursive: true, force: true }); + } +}); diff --git a/docs/site/scripts/git-fetch-retry.mjs b/docs/site/scripts/git-fetch-retry.mjs new file mode 100644 index 000000000..d514319bb --- /dev/null +++ b/docs/site/scripts/git-fetch-retry.mjs @@ -0,0 +1,66 @@ +// Bounded retry-with-backoff for the network git fetches performed while +// building the docs (scripts/sync-repo-docs.mjs, scripts/fetch-openapi.mjs). +// Both scripts shallow-clone dozens of pinned refs from product repos during +// `npm run generate`, which `npm run check` runs first. A single dropped +// connection among those clones otherwise fails the whole `check` pipeline, +// and the default execFile rejection swallows git's real stderr behind a bare +// "Command failed: git fetch ..." message. This wraps just the network fetch +// step (never the surrounding init/remote-add/checkout) with a small number +// of attempts and a linear backoff, and on exhausted attempts throws an error +// that carries git's real stderr, bounded so a runaway stream cannot flood +// the log. +// +// Retries are unconditional: git's stderr text for a permanent failure (an +// unknown ref) and a transient one (a dropped connection) is not reliably +// distinguishable across git versions and transports, so this does not try +// to classify errors before retrying. A permanent failure just costs one +// extra bounded backoff before it fails loudly with the real message. + +const DEFAULT_ATTEMPTS = 3; +const DEFAULT_BACKOFF_MS = 1000; +const DEFAULT_STDERR_LIMIT = 4000; + +function defaultSleep(ms) { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + +// Extract git's real stderr from a failed execFile-style error, bounded to +// `limit` characters so a pathological stream cannot flood the log. +function boundedStderr(error, limit) { + const raw = Buffer.isBuffer(error?.stderr) + ? error.stderr.toString('utf8') + : String(error?.stderr ?? ''); + const text = raw.trim() || error?.message || String(error); + if (text.length <= limit) return text; + const omitted = text.length - limit; + return `${text.slice(0, limit)}\n... [truncated ${omitted} more character(s)]`; +} + +// Run `operation` (a single network git command), retrying on failure up to +// `attempts` times total with a linear backoff between attempts (`sleep` is a +// test seam only; production callers should leave it at its default). Throws +// an Error labeled with `label` whose message carries the last failure's +// real, bounded stderr. +export async function retryGitFetch(label, operation, { + attempts = DEFAULT_ATTEMPTS, + backoffMs = DEFAULT_BACKOFF_MS, + stderrLimit = DEFAULT_STDERR_LIMIT, + sleep = defaultSleep, +} = {}) { + let lastError; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await operation(); + } catch (error) { + lastError = error; + if (attempt < attempts) { + console.warn( + `warning: ${label}: attempt ${attempt}/${attempts} failed, retrying: ` + + boundedStderr(error, stderrLimit), + ); + await sleep(backoffMs * attempt); + } + } + } + throw new Error(`${label}: failed after ${attempts} attempts: ${boundedStderr(lastError, stderrLimit)}`); +} diff --git a/docs/site/scripts/git-fetch-retry.test.mjs b/docs/site/scripts/git-fetch-retry.test.mjs new file mode 100644 index 000000000..6ce0b150f --- /dev/null +++ b/docs/site/scripts/git-fetch-retry.test.mjs @@ -0,0 +1,117 @@ +// Unit tests for the network-git retry helper (scripts/git-fetch-retry.mjs). +// Run with `npm test` (node --test). Everything here is offline: the +// "operation" passed to retryGitFetch is a fake that fails or succeeds on +// command, and `sleep` is stubbed so the backoff never actually waits. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { retryGitFetch } from './git-fetch-retry.mjs'; + +function gitError(stderr) { + const error = new Error('Command failed: git fetch --quiet --depth 1 origin deadbeef'); + error.stderr = stderr; + return error; +} + +test('retries a transient failure and returns on success within the retry budget', async () => { + let calls = 0; + const sleeps = []; + const result = await retryGitFetch( + 'demo-repo: fetch deadbeef', + async () => { + calls += 1; + if (calls < 3) throw gitError('fatal: the remote end hung up unexpectedly'); + return 'ok'; + }, + { sleep: async (ms) => sleeps.push(ms) }, + ); + + assert.equal(result, 'ok'); + assert.equal(calls, 3); + // Slept between attempt 1->2 and 2->3, but not after the final success. + assert.equal(sleeps.length, 2); +}); + +test('succeeds on the first attempt without sleeping', async () => { + let calls = 0; + let slept = false; + const result = await retryGitFetch( + 'demo-repo: fetch deadbeef', + async () => { + calls += 1; + return 'ok'; + }, + { sleep: async () => (slept = true) }, + ); + + assert.equal(result, 'ok'); + assert.equal(calls, 1); + assert.equal(slept, false); +}); + +test('surfaces the real git stderr after exhausting the retry budget', async () => { + let calls = 0; + await assert.rejects( + () => + retryGitFetch( + 'demo-repo: fetch deadbeef', + async () => { + calls += 1; + throw gitError('fatal: unable to access https://example.test/demo.git/: Could not resolve host'); + }, + { sleep: async () => {} }, + ), + /demo-repo: fetch deadbeef: failed after 3 attempts: fatal: unable to access .+ Could not resolve host/, + ); + assert.equal(calls, 3); +}); + +test('bounds a runaway stderr stream so a huge message cannot flood the log', async () => { + const hugeStderr = 'x'.repeat(10_000); + await assert.rejects( + () => + retryGitFetch( + 'demo-repo: fetch deadbeef', + async () => { + throw gitError(hugeStderr); + }, + { sleep: async () => {}, attempts: 1, stderrLimit: 200 }, + ), + (error) => { + assert.match(error.message, /truncated/); + assert.ok(error.message.length < hugeStderr.length); + return true; + }, + ); +}); + +test('does not retry when attempts is 1', async () => { + let calls = 0; + await assert.rejects( + () => + retryGitFetch( + 'demo-repo: fetch deadbeef', + async () => { + calls += 1; + throw gitError('fatal: nope'); + }, + { sleep: async () => {}, attempts: 1 }, + ), + ); + assert.equal(calls, 1); +}); + +test('falls back to the error message when git reports no stderr', async () => { + await assert.rejects( + () => + retryGitFetch( + 'demo-repo: fetch deadbeef', + async () => { + throw new Error('spawn git ENOENT'); + }, + { sleep: async () => {}, attempts: 1 }, + ), + /failed after 1 attempts: spawn git ENOENT/, + ); +}); diff --git a/docs/site/scripts/sync-repo-docs.mjs b/docs/site/scripts/sync-repo-docs.mjs index b249eb583..95b13c2cf 100644 --- a/docs/site/scripts/sync-repo-docs.mjs +++ b/docs/site/scripts/sync-repo-docs.mjs @@ -28,6 +28,7 @@ import { loadDocsets, selectedDocsetId, } from './docsets.mjs'; +import { retryGitFetch } from './git-fetch-retry.mjs'; const run = promisify(execFile); @@ -76,15 +77,24 @@ async function resolveSource(repoId, repo) { return { path: cachePath, mode: 'clone' }; } -// Shallow-clone a single pinned commit. Idempotent: re-clones into a fresh dir. -async function cloneAtRef(repoId, remote, ref, dest) { +// Shallow-clone a single pinned commit. Idempotent: re-clones into a fresh +// dir. Only the network `git fetch` is retried (via retryGitFetch): init and +// remote-add are local and effectively instantaneous, and checkout only runs +// once the fetch has actually populated FETCH_HEAD, so retrying the fetch +// alone is the smallest safe unit. `run` and `retryOptions` are test seams; +// production callers should leave them at their defaults. +export async function cloneAtRef(repoId, remote, ref, dest, { run: runGit = run, retryOptions = {} } = {}) { await rm(dest, { recursive: true, force: true }); await mkdir(dest, { recursive: true }); try { - await run('git', ['init', '--quiet'], { cwd: dest }); - await run('git', ['remote', 'add', 'origin', remote], { cwd: dest }); - await run('git', ['fetch', '--quiet', '--depth', '1', 'origin', ref], { cwd: dest }); - await run('git', ['checkout', '--quiet', 'FETCH_HEAD'], { cwd: dest }); + await runGit('git', ['init', '--quiet'], { cwd: dest }); + await runGit('git', ['remote', 'add', 'origin', remote], { cwd: dest }); + await retryGitFetch( + `${repoId}: fetch ${ref} from ${remote}`, + () => runGit('git', ['fetch', '--quiet', '--depth', '1', 'origin', ref], { cwd: dest }), + retryOptions, + ); + await runGit('git', ['checkout', '--quiet', 'FETCH_HEAD'], { cwd: dest }); } catch (error) { fail(`${repoId}: failed to clone ${remote} at ${ref}: ${error.message}`); } diff --git a/docs/site/scripts/sync-repo-docs.test.mjs b/docs/site/scripts/sync-repo-docs.test.mjs index ab7bc61d0..617be52ff 100644 --- a/docs/site/scripts/sync-repo-docs.test.mjs +++ b/docs/site/scripts/sync-repo-docs.test.mjs @@ -5,9 +5,13 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { applyDocsetMetadataOverrides, + cloneAtRef, frontmatterBlock, stripPageTypeBanner, validateLastReviewed, @@ -279,3 +283,39 @@ test('marks source-reviewed generated pages current', () => { assert.match(fm, /status: current/); }); + +// cloneAtRef retries only the network `git fetch` step (never the surrounding +// init/remote-add/checkout) via scripts/git-fetch-retry.mjs. These tests fake +// the git executor so they run offline and instantly; the retry-and-stderr- +// bounding policy itself is covered exhaustively in git-fetch-retry.test.mjs. + +test('cloneAtRef retries a transient fetch failure and succeeds within the retry budget', async () => { + const dest = await mkdtemp(join(tmpdir(), 'sync-repo-docs-test-')); + try { + let fetchCalls = 0; + const invoked = []; + const fakeRun = async (command, args) => { + invoked.push(args[0]); + if (args[0] === 'fetch') { + fetchCalls += 1; + if (fetchCalls < 2) { + const error = new Error('Command failed: git fetch --quiet --depth 1 origin deadbeef'); + error.stderr = 'fatal: the remote end hung up unexpectedly'; + throw error; + } + } + return { stdout: '', stderr: '' }; + }; + + await cloneAtRef('demo-repo', 'https://example.test/demo.git', 'deadbeef', dest, { + run: fakeRun, + retryOptions: { sleep: async () => {} }, + }); + + assert.equal(fetchCalls, 2); + // init and remote-add each ran once; only fetch (the network step) retried. + assert.deepEqual(invoked, ['init', 'remote', 'fetch', 'fetch', 'checkout']); + } finally { + await rm(dest, { recursive: true, force: true }); + } +}); diff --git a/docs/site/src/content/docs/explanation/known-limitations.mdx b/docs/site/src/content/docs/explanation/known-limitations.mdx index 8b571f8d5..e006164e1 100644 --- a/docs/site/src/content/docs/explanation/known-limitations.mdx +++ b/docs/site/src/content/docs/explanation/known-limitations.mdx @@ -137,6 +137,14 @@ Read the full context in the [Registry Notary protocol](../../spec/rs-pr-notary/ EdDSA/Ed25519 by default; ES256/P-256 is also supported per credential profile; with no W3C [Verifiable Credentials Data Model](../../reference/standards/) JSON-LD envelope). Relay no longer issues signed response credentials or hosts issuer DID documents. +- Notary bounds consultation age, not source-observation age: Source-observation age is not a + Notary-enforced quantity. Notary consultation and issuance provenance retain only the + consultation `acquired_at` timestamp, the federation profile's `max_claim_result_age_seconds` + bounds consultation-result age rather than the age of the observation behind it, and Registry + Relay owns snapshot freshness through `max_snapshot_age_ms`. Notary does enforce age bounds for + other purposes, including `subject_access.token_policy.max_auth_age_seconds` and + `max_evaluation_age_seconds` on subject-access flows and `oid4vci.proof.max_age_seconds` on + wallet proofs, but none of them bound source-observation age either. - Standalone admin reload is not implemented: The standalone Notary `/admin/v1/reload` route returns HTTP 501 with code `registry.admin.capability.not_supported` and performs no reload, and non-swappable changes require a service restart. diff --git a/docs/site/src/content/docs/spec/rs-arc-g.mdx b/docs/site/src/content/docs/spec/rs-arc-g.mdx index c6dfb8d52..731867294 100644 --- a/docs/site/src/content/docs/spec/rs-arc-g.mdx +++ b/docs/site/src/content/docs/spec/rs-arc-g.mdx @@ -57,6 +57,7 @@ For a narrative explanation with worked examples, see the [architecture overview | 0.4.0 | 2026-07-07 | draft | Clarified REQ-ARC-G-009's replay storage scope: shared within a deployment's own replicas, never across a federation trust boundary. Reframed Registry Lab as a non-normative demonstration topology. | | 0.4.1 | 2026-07-07 | draft | Stated plainly in REQ-ARC-G-009 that the per-peer replay storage isolation is deployment topology and that Registry Notary enforces no runtime gate for it. | | 0.5.0 | 2026-07-16 | draft | Defined four formal Registry Stack products and moved the non-normative demo boundary to the separate Solmara Lab adopter project. | +| 0.6.0 | 2026-08-01 | draft | Scoped static-peer federation to the inbound evaluation route that ships, and excluded audit from REQ-ARC-G-013's Notary-owned PostgreSQL correctness state: audit correctness is owned by the audit pipeline instead, so restoring the PostgreSQL database MUST NOT be treated as restoring audit history. | ## 1. Scope and audience @@ -164,7 +165,7 @@ The following ordered flow describes how a request moves through the stack from 5. **Claim evaluation.** Registry Notary evaluates claims from compiler-pinned Relay consultation outputs. Caller identity and authorization details may authorize or parameterize a consultation, but do not become evidence. Notary applies disclosure policy (value, predicate, or redacted) and returns evaluation results as claim-result JSON or CCCEV-shaped JSON-LD. It can materialize only stored evaluations with exact dependency-closure claim pins and normalized unique Relay execution records into SD-JWT VC credentials. -6. **Static-peer delegated evaluation.** A trusted Registry Notary instance can call another trusted Registry Notary instance through `POST /federation/v1/evaluations` for signed delegated evaluation. Registry Manifest can publish discovery metadata for that relationship, but local Notary peer policy grants access. Peer lists are loaded from configuration at startup. This federation path is distinct from delegated subject access in the Notary citizen/OIDC flow. +6. **Static-peer delegated evaluation.** Registry Notary accepts signed delegated evaluation from a trusted peer on `POST /federation/v1/evaluations`. Only this inbound route ships: the signed request is constructed and signed by a separate trusted caller. The `registry-notary-client` crate carries a feature-gated helper that posts an already-signed JWS to that route, but it cannot construct or sign one, the Notary server does not enable it, and no peer-result composition runtime is part of this version. Outbound calling and peer-result composition are planned and tracked in [GH#36](https://github.com/registrystack/registry-stack/issues/36). Registry Manifest can publish discovery metadata for that relationship, but local Notary peer policy grants access. Peer lists are loaded from configuration at startup. This federation path is distinct from delegated subject access in the Notary citizen/OIDC flow. ## 5. Architectural invariants @@ -188,7 +189,7 @@ REQ-ARC-G-008: Registry Notary credentials MUST use SD-JWT VC format (`applicati REQ-ARC-G-012: Registry Notary MUST issue credentials only from evaluations that retain exact compiler pins for every registry-backed claim in each selected root's dependency closure and one normalized execution record per unique Relay consultation ULID. Each pin MUST be deterministically cross-bound to its execution and claim provenance and checked before signing. Private Relay execution provenance MUST be retained only for credential-capable selections. Delegated evaluations without the configured representative ceremony and registry-backed evaluation-only selections MUST remain nonissuable without retaining those private execution identifiers. -REQ-ARC-G-013: One Registry Notary authority MUST pair with one Registry Relay authority. Registry Notary MUST own its transaction, replay, evaluation, audit, and credential-status correctness state. Production and multi-instance deployments MUST use the Notary-owned PostgreSQL schema; explicit in-memory state is local and single-instance only. +REQ-ARC-G-013: One Registry Notary authority MUST pair with one Registry Relay authority. Registry Notary MUST own its transaction, replay, evaluation, and credential-status correctness state. Production and multi-instance deployments MUST hold that state in the Notary-owned PostgreSQL schema; explicit in-memory state is local and single-instance only. Audit correctness is outside that schema: it is owned by the audit pipeline defined in [RS-PR-NOTARY](../rs-pr-notary/) instead, which hash-chains its records and MUST emit every evaluated request to the configured sink, failing the request when it cannot (REQ-PR-NOTARY-020, REQ-PR-NOTARY-021). Sink retention and off-host shipping are operator responsibilities surfaced through the deployment evidence gates. Restoring the Notary PostgreSQL database therefore MUST NOT be treated as restoring audit history. REQ-ARC-G-009: Registry Notary's federation implementation is static-peer delegated evaluation. Peer lists are loaded from configuration at startup. Within a single deployment, replay storage MAY be shared across that deployment's own replicas through the typed Notary-owned PostgreSQL state schema; that sharing does not extend across a federation trust boundary. Each federation peer MUST maintain its own replay scope and MUST NOT share replay storage with the peers it federates with; that isolation is deployment topology, and Registry Notary enforces no runtime gate that prevents two peers from sharing a replay storage backend. Dynamic trust-chain discovery, replay storage shared across federation peers, audit checkpoint exchange, and federated credential issuance are not part of this version and MUST NOT be implied by conformance claims against this specification. @@ -232,12 +233,17 @@ Conformance to this specification does not imply conformance to any external sta ## Evidence -This specification is `verified`: it is distilled from published artifacts a reader can inspect, per RS-DOC REQ-DOC-014. - -- The [boundary map](../../map/boundaries-and-map/) records each component's boundaries with their source citations. It is the primary distillation source for the components (Section 3) and the invariants (Section 5). -- The [architecture overview](../../explanation/architecture/) gives the narrative data and contract flow that Section 4 makes precise. -- The [Registry Relay](../../reference/apis/registry-relay/) and [Registry Notary](../../reference/apis/registry-notary/) API references show the runtime surfaces named in Section 3. -- The [standards register](../../reference/standards/) records the adoption mode for each standard listed in `standards_referenced`. +This specification is `verified`: its load-bearing invariants are anchored to workspace source, a test, or a generated machine contract a reader can open, per RS-DOC REQ-DOC-014. The ownership boundaries (REQ-ARC-G-006, REQ-ARC-G-007) and the descriptive-metadata rule (REQ-ARC-G-010) allocate authority between components rather than assert a runtime behavior, so they are substantiated by the anchors for the invariants they divide. + +- The metadata layer's purity and the runtime-binding exclusion are enforced by `RUNTIME_ONLY_KEYS` and `is_runtime_only_key` in `crates/registry-manifest-core/src/lib.rs`, exercised by `runtime_only_manifest_fields_are_rejected_before_unknown_fields_are_ignored` and `runtime_only_rejection_covers_representative_product_configs` in `crates/registry-manifest-core/tests/metadata_core.rs` (REQ-ARC-G-001, REQ-ARC-G-002). +- The generated `docs/site/openapi/registry-relay.openapi.json` document carries no `put`, `patch`, or `delete` operation on any path, which is the machine-contract form of the read-only consultation boundary (REQ-ARC-G-003). +- The audit envelope primitive is `AuditEnvelope` in `crates/registry-platform-audit/src/lib.rs`, with its hash-chained sink helpers; Registry Relay composes it in `crates/registry-relay/src/audit/mod.rs` (REQ-ARC-G-004, REQ-ARC-G-005). +- The credential format constant is `FORMAT_SD_JWT_VC` in `crates/registry-notary-core/src/model.rs`, fixed to `application/dc+sd-jwt` (REQ-ARC-G-008). +- Static-peer delegated evaluation is the router in `crates/registry-notary-server/src/federation/mod.rs`, which mounts only `POST /federation/v1/evaluations` and resolves callers through the configured `peers_by_issuer` map; `crates/registry-notary-server/tests/standalone_http/federation.rs` exercises the inbound path (REQ-ARC-G-009). +- The supported Evidence Gateway PDP profile `registry-evidence-gateway-pdp/v1` and its stable `pdp.*` denial codes are defined in `crates/registry-platform-pdp/src/lib.rs` and enforced from `crates/registry-relay/src/api/governed.rs` (REQ-ARC-G-011). +- The retained issuance provenance is `StoredIssuanceProvenance` in `crates/registry-notary-core/src/model.rs`: `StoredIssuanceClaimProvenance` holds the exact compiler pin per claim and its `execution_binding`, a deterministic SHA-256 commitment cross-binding that pin to its Relay execution record and claim result provenance, while `StoredIssuanceConsultationProvenance` keeps one record per unique consultation. Records missing those bindings are documented there as readable but not credential-issuable (REQ-ARC-G-012). +- The Notary-owned correctness state is the PostgreSQL state plane under `crates/registry-notary-server/src/state_plane/`, whose module documentation records that it owns installation and attestation for that schema (REQ-ARC-G-013). +- The [boundary map](../../map/boundaries-and-map/) and the [architecture overview](../../explanation/architecture/) corroborate the component boundaries (Section 3) and the narrative data and contract flow (Section 4); the [standards register](../../reference/standards/) records the adoption mode for each standard listed in `standards_referenced`. ## Next diff --git a/docs/site/src/content/docs/spec/rs-dm-manifest.mdx b/docs/site/src/content/docs/spec/rs-dm-manifest.mdx index 08fde03e5..a3ce6161f 100644 --- a/docs/site/src/content/docs/spec/rs-dm-manifest.mdx +++ b/docs/site/src/content/docs/spec/rs-dm-manifest.mdx @@ -43,6 +43,7 @@ The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section | 0.2.0 | 2026-06-20 | draft | Added governed evidence gateway metadata, policy id/hash binding, ODRL enforcement profile validation, and the runtime PDP boundary. | | 0.2.1 | 2026-06-21 | draft | Clarified federation discovery trust boundaries, manifest validation limits, and static publication provenance. | | 0.3.0 | 2026-07-07 | draft | Corrected REQ-DM-MANIFEST-007 to require a unique `(id, version)` pair rather than independently unique `id` and `version` values, corrected the REQ-DM-MANIFEST-002 runtime-only-key example, renamed the static discovery bundle consistently, and rewrote REQ-DM-MANIFEST-013 from the additive-evolution rule to strict unknown-key rejection at parse time (issue #249, issue #285). | +| 0.4.0 | 2026-08-01 | draft | Scoped REQ-DM-MANIFEST-013's new-schema-version obligation to `v1.0.0` and later, matching the pre-1.0 rule in the API stability reference, and recorded the two pre-1.0 schema breaks that shipped under it. | ## 1. Scope and references @@ -152,6 +153,8 @@ The manifest and its generated formats carry a versioned compatibility promise: REQ-DM-MANIFEST-013: The `registry-manifest/v1` manifest and its generated `*/v1` formats MUST reject a key that is not modeled at its nesting depth, at parse time, and MUST name the offending key by its dotted field path in the resulting error. A reader MUST NOT silently drop or ignore a field it does not recognize. Extending the schema, including adding an optional field to an existing object, requires a Registry Manifest code change; a producer MUST NOT rely on an unmodeled key being tolerated. Fields modeled to carry arbitrary JSON, such as the governed evidence pack's `source_basis`, `semantic_profile`, and `evidence_envelope`, or an ecosystem binding's `vocabulary` and related envelope fields, remain the modeled surface for producer-specific content. A breaking change, including removing or renaming a required field or changing the meaning or type of an existing field, MUST still carry a new schema version. +That schema-version obligation takes effect at `v1.0.0`, with the rest of the compatibility promise recorded in [API stability](../../reference/api-stability/). Before `v1.0.0`, a minor release MAY break the schema without a new schema version, and each such break MUST be announced as a `BREAKING:` entry with migration steps in the Registry Manifest changelog. Two have shipped under that rule: the unknown-key rejection this requirement describes, and the removal of the optional `evaluation_profiles[].max_source_observed_age_seconds` key, which configured nothing. + ## 11. Limitations These constraints are stated so a reader does not infer an invariant the reviewed implementation does not enforce. @@ -186,13 +189,15 @@ Conformance to this specification does not imply conformance to any external sta ## Evidence -This specification is `verified`: every requirement describes shipped behavior a reader can inspect, per RS-DOC REQ-DOC-014. +This specification is `verified`: every requirement is anchored to the Registry Manifest source and tests a reader can open, per RS-DOC REQ-DOC-014. -- The [Registry Manifest overview](../../products/registry-manifest/) describes the pure render pipeline, the multi-pass validator, the minimal manifest and its required core, grouped evidence, governed evidence ecosystem bindings, and the federation-is-discovery-not-grant rule that Sections 2 through 8 make precise. -- The [Registry Manifest reference](../../products/registry-manifest/reference/) lists the top-level keys, the catalog core, the runtime-only key list, the render formats, the schema-version markers, the publish bundle layout, the digest fields, and the extension policy that Sections 3 through 11 state normatively. -- The [validate and render guide](../../products/registry-manifest/validate-and-render/) walks the validation checks, the render and publish commands, and the `index.json` digest fields behind Sections 8 and 9. -- [RS-PR-RELAY](../rs-pr-relay/) carries the Relay-protocol form of the ownership boundary this document gives a data-model form (REQ-PR-RELAY-011). -- [RS-ARC-G](../rs-arc-g/) holds the architectural invariants both this document and RS-PR-RELAY refine: the metadata layer's purity (REQ-ARC-G-001), the runtime-binding exclusion (REQ-ARC-G-002), and the ownership split (REQ-ARC-G-006). +- The schema-version gate is the `manifest.schema_version != "registry-manifest/v1"` rejection in `crates/registry-manifest-core/src/lib.rs`, exercised by `validate_manifest_rejects_unsupported_core_schema_version` in `crates/registry-manifest-core/tests/metadata_core.rs` (REQ-DM-MANIFEST-001). +- The runtime-binding exclusion is the enumerated `RUNTIME_ONLY_KEYS` list and its `is_runtime_only_key` check in `crates/registry-manifest-core/src/lib.rs`, exercised by `runtime_only_manifest_fields_are_rejected_before_unknown_fields_are_ignored` and `runtime_only_rejection_covers_representative_product_configs` in `crates/registry-manifest-core/tests/metadata_core.rs`. Those tests also show the enumerated-list limitation Section 11 records (REQ-DM-MANIFEST-002). +- The catalog core, cross-reference integrity, identifier patterns, closed vocabularies, grouped evidence, governed evidence pack metadata, and federation discovery fields are the typed model and the `validate_manifest` multi-pass validator in `crates/registry-manifest-core/src/lib.rs`, covered by `crates/registry-manifest-core/tests/metadata_core.rs` and the golden fixtures under `crates/registry-manifest-core/tests/fixtures/` (REQ-DM-MANIFEST-003 through REQ-DM-MANIFEST-010). +- The rendered artifact set is the `render_catalog`, `render_base_dcat`, `render_breg_dcat_ap`, `render_cpsv_ap`, `render_shacl`, `render_entity_schema_draft_2020_12`, `render_policy_collection`, and `render_ogc_records_items` functions in `crates/registry-manifest-core/src/lib.rs`, each a pure function of the compiled manifest; `crates/registry-manifest-core/tests/cpsv_ap_contract.rs` pins the CPSV-AP JSON-LD shape (REQ-DM-MANIFEST-011). +- The publish bundle and its digests are `source_manifest_digest` in `crates/registry-manifest-core/src/lib.rs` and `publication_package_digest` plus the `"schema_version": "registry-manifest-index/v1"` index writer in `crates/registry-manifest-cli/src/main.rs`, asserted by `crates/registry-manifest-cli/tests/cli.rs`. `source_manifest_digest_is_stable_for_yaml_formatting_and_key_order` and `source_manifest_digest_moves_for_typed_changes_and_array_order` pin the canonical-digest behavior (REQ-DM-MANIFEST-012). +- Strict unknown-key rejection is the `#[serde(deny_unknown_fields)]` attribute carried by every modeled struct in `crates/registry-manifest-core/src/lib.rs`, exercised at top level, nested-section, and nested-item depth by `crates/registry-manifest-core/tests/metadata_unknown_fields.rs` (REQ-DM-MANIFEST-013). +- The [Registry Manifest overview](../../products/registry-manifest/), the [reference](../../products/registry-manifest/reference/), and the [validate and render guide](../../products/registry-manifest/validate-and-render/) corroborate the same pipeline, key set, and digest fields in narrative form. [RS-PR-RELAY](../rs-pr-relay/) carries the Relay-protocol form of the ownership boundary (REQ-PR-RELAY-011), and [RS-ARC-G](../rs-arc-g/) holds the architectural invariants both refine (REQ-ARC-G-001, REQ-ARC-G-002, REQ-ARC-G-006). - The [standards register](../../reference/standards/) records the adoption mode for DCAT, BRegDCAT-AP, CPSV-AP, CCCEV, SHACL, JSON Schema, JSON-LD, ODRL, OGC API Records, and SKOS named in `standards_referenced`. ## Next diff --git a/docs/site/src/content/docs/spec/rs-doc.mdx b/docs/site/src/content/docs/spec/rs-doc.mdx index 78aa7d9f7..bd714aa9d 100644 --- a/docs/site/src/content/docs/spec/rs-doc.mdx +++ b/docs/site/src/content/docs/spec/rs-doc.mdx @@ -30,6 +30,7 @@ It is the anchor the other specifications cite. Where another document says "the | --- | --- | --- | --- | | 0.1.0 | 2026-06-13 | draft | Initial framework: layers, identifiers, metadata axes, document structure, register, cross-link rule, lifecycle. | | 0.2.0 | 2026-07-07 | draft | Corrected REQ-DOC-012 to order version-history tables oldest first with the current version last, added layer and audience frontmatter definitions, documented the -G suffix convention, and replaced positional references with named sections. | +| 0.3.0 | 2026-08-01 | draft | Tightened REQ-DOC-014 so a `verified` specification anchors each load-bearing claim to code, a test, a fixture, a generated artifact, or a released machine contract, and site pages corroborate rather than stand alone as evidence. | ## 1. Scope and layers @@ -92,7 +93,7 @@ A specification's canonical metadata is its frontmatter. The register surfaces i **Evidence** is how true the document is against shipped code: -- `verified` is backed by code, tests, fixtures, or generated artifacts that a reader can inspect. A `verified` document carries evidence references to those artifacts (REQ-DOC-014); a claim a reader cannot trace to one is not `verified`. +- `verified` is backed by code, tests, fixtures, generated artifacts, or released machine contracts that a reader can inspect. A `verified` document carries evidence references to those artifacts (REQ-DOC-014); a claim a reader cannot trace to one, or can trace only to another documentation page, is not `verified`. - `partial` mixes shipped behavior with target behavior. The document marks which sections are which. - `aspirational` describes a target state that is not built yet. @@ -102,7 +103,7 @@ REQ-DOC-006: A specification MUST declare `doc_id`, `category`, and `evidence` i REQ-DOC-007: A document whose `evidence` is `aspirational` or `partial` MUST say so in its opening, and a `partial` document MUST mark which requirements are not yet met by shipped code. A reader MUST NOT have to read the code to learn that a requirement is unbuilt. -REQ-DOC-014: A specification whose `evidence` is `verified` MUST carry evidence references: an Evidence section, or per-requirement references, that link the inspectable artifacts substantiating its claims. Acceptable artifacts include published site pages, generated API references, the standards register, and repository sources a reader can open. This holds the formal layer to at least the evidence bar the standards register already meets with its per-entry evidence links. +REQ-DOC-014: A specification whose `evidence` is `verified` MUST carry evidence references: an Evidence section, or per-requirement references, that link the inspectable artifacts substantiating its claims. Each load-bearing claim, meaning one a conformance check, an audit, or an implementation decision rests on, MUST be anchored to at least one artifact a reader can open directly: source code, a test, a fixture, a generated artifact, or a released machine contract such as an OpenAPI document, a JSON Schema, or a configuration schema. A published site page, including a generated API reference page, the standards register, and another specification in this layer, MAY corroborate an anchor but MUST NOT be the only evidence a `verified` document offers; evidence that resolves solely to further documentation is a citation loop, not verification. An anchor MAY cover a section or a group of related requirements, and this requirement does not ask for one reference per sentence. A specification that cannot meet this bar MUST declare a lower `evidence` level. This is the formal layer's form of the documentation-wide rule that every factual claim about a source repository is anchored in code, tests, fixtures, OpenAPI, or an upstream standard. Two optional frontmatter keys declare further facets outside the three axes. **Layer** names the stack layer or layers a page documents: `metadata`, `consultation`, `evaluation`, `credential`, `federation`, `administration`, or `operations`. **Audience** names the reader role or roles a page serves: `integrator`, `operator`, `maintainer`, `specification editor`, or `tooling`. Both take a list, because a specification is frequently cross-cutting; a specification that spans every layer omits `layer` rather than list all of them. @@ -150,16 +151,16 @@ REQ-DOC-013: When a specification is superseded, its `status` becomes `historica ## Conformance -A document conforms to RS-DOC when it: is a distilled public contract, not copied internal evidence (REQ-DOC-001); carries a unique, well-formed `doc_id` and the `category` and `evidence` axes (REQ-DOC-004, REQ-DOC-006); states its evidence honestly, including the unbuilt case (REQ-DOC-007); backs a `verified` evidence level with evidence references (REQ-DOC-014); carries a version-history table that records normative changes (REQ-DOC-012); and interprets normative keywords per Section 2. The frontmatter check enforces the mechanical requirements; the rest is a review responsibility. +A document conforms to RS-DOC when it: is a distilled public contract, not copied internal evidence (REQ-DOC-001); carries a unique, well-formed `doc_id` and the `category` and `evidence` axes (REQ-DOC-004, REQ-DOC-006); states its evidence honestly, including the unbuilt case (REQ-DOC-007); backs a `verified` evidence level with inspectable anchors rather than documentation alone (REQ-DOC-014); carries a version-history table that records normative changes (REQ-DOC-012); and interprets normative keywords per Section 2. The frontmatter check enforces the mechanical requirements; the rest is a review responsibility. ## Evidence This specification is `verified`: the mechanisms it defines exist and run, and a reader can inspect them. -- The [register](../) renders from each specification's frontmatter at build time, demonstrating the generated-register rule (Section 6) and the three axes (Section 4). -- `scripts/check-doc-frontmatter.mjs` enforces the `doc_id`, `category`, and `evidence` fields for `doc_type: specification` (Sections 3 and 4). -- `styles/RegistryDocsSpec/` and the `[src/content/docs/spec/**]` Vale section enforce the honest-conformance discipline (Section 2). -- `src/content.config.ts` defines the specification frontmatter; `astro.config.mjs` defines the sidebar group. +- `docs/site/scripts/check-doc-frontmatter.mjs` enforces the `doc_id` pattern and the `category` and `evidence` values for `doc_type: specification` (Sections 3 and 4). +- `docs/site/src/content.config.ts` defines the specification frontmatter schema, including the `layer` and `audience` vocabularies (Section 4); `docs/site/astro.config.mjs` defines the sidebar group for this layer. +- `docs/site/styles/RegistryDocsSpec/HonestConformance.yml` and the `[src/content/docs/spec/**]` section of `docs/site/.vale.ini` enforce the honest-conformance discipline (Section 2). +- The [register](../) renders from each specification's frontmatter at build time, corroborating the generated-register rule (Section 6) and the three axes (Section 4). ## Next diff --git a/docs/site/src/content/docs/spec/rs-op-posture.mdx b/docs/site/src/content/docs/spec/rs-op-posture.mdx index de87e539a..4f3afbc57 100644 --- a/docs/site/src/content/docs/spec/rs-op-posture.mdx +++ b/docs/site/src/content/docs/spec/rs-op-posture.mdx @@ -32,12 +32,10 @@ source of secrets. The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section 2. Defined terms are used per [RS-TERMS](../rs-terms/). -{/* TODO[evidence]: Add a release-tagged Registry Ops Posture v1 JSON Schema source link after - the waiver definitions and shared portable metadata constraints merge. The earlier pinned - schema predates those definitions and is not evidence for this draft revision. */} -Portable structural validation is defined by the in-tree draft schema; release-tagged source -evidence for this revision is pending. This specification defines the operational meaning, -semantic producer validation, and evolution rules that JSON Schema alone does not express. +Portable structural validation is defined by the schema at +`crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json`, which is a draft +until the 1.0 schema freeze. This specification defines the operational meaning, semantic +producer validation, and evolution rules that JSON Schema alone does not express. ## Version history @@ -278,13 +276,25 @@ handles omissions as unreported, and does not elevate posture observations into This specification is `verified`: its document shape, examples, component pairing, and tier filter are shipped in Registry Platform and exercised by contract tests. -- Release-tagged source evidence for the v1 JSON Schema's waiver definitions and shared portable - metadata constraints is pending. The source pack will be linked after those changes merge. +- `crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json` is the portable + structural contract (Section 2). Its `deployment_waiver` and `deployment_finding_waiver` + definitions share the `waiver_reference` and `waiver_summary` constraints, and the schema + records that acceptance does not replace `validate_deployment_waiver_metadata` (Section 5). +- `validate_deployment_waiver_metadata` in `crates/registry-platform-ops/src/lib.rs` is the + semantic producer validation the schema cannot express, with unit tests for accepted + references, rejected or overlong references, rejected summaries, and explicit-null summaries. +- `crates/registry-platform-ops/tests/posture_profile_gates_contract.rs` holds the contract + tests that keep the schema and the runtime validator aligned: the shared structural metadata + contract across both waiver definitions, the reference-prefix and summary contracts, rejection + of unknown profiles or severities and of missing waiver expiry, and the default filter dropping + waiver metadata from profile-gate fields (Sections 5 and 6). - The [posture contract tests](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/tests/posture_contract.rs) validate both examples, the default allowlist projection, the sensitive fixture, and exclusion of restricted fields from default output (Sections 3, 4, and 6). -- The checked-in restricted Relay posture fixture and Registry Relay admin tests validate - per-resource last-good state, tier filtering, and the live producer shape (Sections 4 and 6). +- The checked-in fixtures under `crates/registry-platform-ops/fixtures/posture/`, including + `registry-relay.restricted-posture.valid.json` and `default-redacted.posture.valid.json`, and + the Registry Relay admin tests validate per-resource last-good state, tier filtering, and the + live producer shape (Sections 4 and 6). - The [Registry Relay admin handler](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-relay/src/api/admin.rs) and [Registry Notary admin handler](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-notary-server/src/api/admin.rs) select the requested tier and return the shared posture document (Sections 1, 4, and 6). diff --git a/docs/site/src/content/docs/spec/rs-pr-notary.mdx b/docs/site/src/content/docs/spec/rs-pr-notary.mdx index dfb9bcfcf..764323d07 100644 --- a/docs/site/src/content/docs/spec/rs-pr-notary.mdx +++ b/docs/site/src/content/docs/spec/rs-pr-notary.mdx @@ -47,6 +47,7 @@ The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section | 0.2.0 | 2026-07-13 | draft | Removed direct registry source connectors and sidecars. Registry-backed claims now consume compiler-pinned Relay consultations. | | 0.3.0 | 2026-07-19 | draft | Restricted OID4VCI to registry-backed issuer-initiated pre-authorized code, and defined algorithm, transaction-code, status, topology, and unsupported-profile boundaries. | | 0.4.0 | 2026-07-30 | draft | Required every claim and federation profile to use compiler-pinned Relay evidence and renamed subject-access modes. | +| 0.5.0 | 2026-08-01 | draft | Excluded audit from REQ-PR-NOTARY-041's Notary-owned PostgreSQL correctness state: audit correctness is owned by the audit pipeline instead, so restoring the PostgreSQL database MUST NOT be treated as restoring audit history and active-active replicas sharing that schema do not thereby share one ordered audit state. | ## 1. Scope and references @@ -288,8 +289,15 @@ registry-backed claims. REQ-PR-NOTARY-041: A deployment authority MUST pair one Registry Notary authority with one Registry Relay authority. Notary MUST own the PostgreSQL correctness state used for its transactions, pre-authorized codes, proof replay, -evaluations, audit, and credential status. Explicit in-memory state MAY be used -only for local single-process development. +evaluations, and credential status. Explicit in-memory state MAY be used +only for local single-process development. Audit records are not part of that +PostgreSQL state: audit correctness is owned by the audit pipeline instead. That +pipeline hash-chains its records, and it MUST emit every evaluated request to the +configured sink and fail the request when it cannot (REQ-PR-NOTARY-020, +REQ-PR-NOTARY-021). Sink retention and off-host shipping are operator +responsibilities surfaced through the deployment evidence gates. Restoring the Notary PostgreSQL database +therefore MUST NOT be treated as restoring audit history, and active-active +replicas sharing that schema do not thereby share one ordered audit state. ## 9. Delegated (federated) evaluation diff --git a/docs/site/src/content/docs/spec/rs-pr-relay.mdx b/docs/site/src/content/docs/spec/rs-pr-relay.mdx index 5b2e107c4..f8a0c63b3 100644 --- a/docs/site/src/content/docs/spec/rs-pr-relay.mdx +++ b/docs/site/src/content/docs/spec/rs-pr-relay.mdx @@ -207,13 +207,18 @@ Conformance to this specification does not imply conformance to any external sta ## Evidence -This specification is `verified`: every requirement describes shipped behavior a reader can inspect, per RS-DOC REQ-DOC-014. - -- The [Registry Relay API reference](../../reference/apis/registry-relay/) carries the narrative context and links the generated OpenAPI (Redoc) document, the authoritative route and schema reference for every route named here. -- The [boundary map](../../map/boundaries-and-map/) records Registry Relay's boundaries with source citations, including the read-only, manifest-not-owned, no-claim-evaluation, and storage-internals-hidden constraints that Sections 2, 6, and 7 make precise. -- The [architecture overview](../../explanation/architecture/) gives the narrative data and contract flow, including the scope-filtered runtime metadata views, that Sections 2 and 6 refine. +This specification is `verified`: its load-bearing requirements are anchored to the generated Relay machine contract, to Registry Relay source, or to a Relay test a reader can open, per RS-DOC REQ-DOC-014. REQ-PR-RELAY-011 allocates format ownership rather than assert a Relay runtime behavior; the schema and renderers it assigns to Registry Manifest are anchored in `crates/registry-manifest-core/src/lib.rs`. + +- The generated `docs/site/openapi/registry-relay.openapi.json` document is the released machine contract for the abstract route and schema surface. It carries no `put`, `patch`, or `delete` operation on any path, which is the machine-readable form of the read-only boundary (REQ-PR-RELAY-001, REQ-PR-RELAY-004, REQ-PR-RELAY-008, REQ-PR-RELAY-009). +- Fail-closed startup is the cross-check in `crates/registry-relay/src/config/validate.rs`, which emits the `runtime.binding.*` codes named here when a binding does not resolve (REQ-PR-RELAY-002, REQ-PR-RELAY-003). +- The single authentication mode is the `AuthMode` enum in `crates/registry-relay/src/auth/mod.rs`; per-dataset scope parsing and the reserved `registry:trust` scope namespace are in `crates/registry-relay/src/auth/scopes.rs` (`TRUST_CONTEXT_SCOPE_PREFIX`), and the purpose-header rejection is `AuthError::PurposeRequired`, mapped to `auth.purpose_required` in `crates/registry-relay/src/error.rs`. `crates/registry-relay/tests/auth_flow.rs` exercises the authenticated paths (REQ-PR-RELAY-005, REQ-PR-RELAY-006, REQ-PR-RELAY-007, REQ-PR-RELAY-020). +- Governed PDP enforcement is `crates/registry-relay/src/api/governed.rs` calling the shared decision point in `crates/registry-platform-pdp/src/lib.rs`, which defines the `registry-evidence-gateway-pdp/v1` profile and the stable `pdp.*` denial codes; `crates/registry-relay/tests/error_taxonomy.rs` pins the returned codes (REQ-PR-RELAY-019, REQ-PR-RELAY-021, REQ-PR-RELAY-022). +- Scope-filtered metadata publication and the evidence-offering routes are the router and handlers in `crates/registry-relay/src/api/metadata.rs`, which mount `/metadata/evidence-offerings` and `/metadata/evidence-offerings/{offering_id}` and build every response through `scoped_metadata` and `visible_metadata_scopes` so a caller sees only what its `metadata` scope permits; `crates/registry-relay/tests/config_metadata_bindings.rs` covers the binding side (REQ-PR-RELAY-010, REQ-PR-RELAY-012, REQ-PR-RELAY-023). +- The removed credential surfaces are asserted absent by `openapi_omits_relay_credential_issuance_routes_and_media_types` in `crates/registry-relay/src/api/openapi.rs`, which fails if `/schemas/{claim_type}/{version}`, `/contexts/{vocab}/{version}`, or `/.well-known/did.json` reappears in the generated document (REQ-PR-RELAY-013, REQ-PR-RELAY-014). +- Audit records, including the `scopes_used_for_audit` replacement of raw trust values with the field-bound `hmac-sha256` handle, are in `crates/registry-relay/src/audit/mod.rs`, exercised by `crates/registry-relay/tests/audit_record.rs` and `crates/registry-relay/tests/audit_redaction_chain.rs`. The problem-details content type is the `PROBLEM_JSON` constant used across `crates/registry-relay/src/api/` (REQ-PR-RELAY-015, REQ-PR-RELAY-016). +- Feature gating is the `[features]` table in `crates/registry-relay/Cargo.toml` (`ogcapi-features`, `ogcapi-records`, `ogcapi-edr`, `spdci-api-standards`) and the matching `#[cfg(feature = ...)]` mounts in `crates/registry-relay/src/server.rs`; the admin scopes are `ADMIN_SCOPE` and `OPS_READ_SCOPE` in `crates/registry-relay/src/api/admin.rs` and `METRICS_SCOPE` in `crates/registry-relay/src/observability.rs` (REQ-PR-RELAY-017, REQ-PR-RELAY-018). +- The [Registry Relay API reference](../../reference/apis/registry-relay/), the [boundary map](../../map/boundaries-and-map/), and the [architecture overview](../../explanation/architecture/) corroborate the same surface and boundaries in narrative form; [RS-ARC-G](../rs-arc-g/) Section 3 and Section 5 hold the architectural invariants (REQ-ARC-G-003/004/006/007) that this document refines. - The [standards register](../../reference/standards/) records the adoption mode for DCAT, BRegDCAT-AP, SHACL, ODRL, OGC API Records, OGC API Features, OGC API EDR, SDMX, SP DCI, and the W3C standards listed in `standards_referenced`. -- [RS-ARC-G](../rs-arc-g/) Section 3 and Section 5 hold the architectural invariants (REQ-ARC-G-003/004/006/007) that this document refines. ## Next diff --git a/docs/site/src/content/docs/spec/rs-terms.mdx b/docs/site/src/content/docs/spec/rs-terms.mdx index b6da73a60..e21a62efc 100644 --- a/docs/site/src/content/docs/spec/rs-terms.mdx +++ b/docs/site/src/content/docs/spec/rs-terms.mdx @@ -321,11 +321,19 @@ These terms classify a documentation page so a reader, human or AI agent, can te ## Evidence -This specification is `verified`: the definitions are distilled from published reference material a reader can inspect, per RS-DOC REQ-DOC-014. - -- The [glossary](../../reference/glossary/) is the friendlier reference form of this vocabulary and the source these definitions distill. RS-TERMS is authoritative where the two disagree. -- The [standards register](../../reference/standards/) records each external standard mapped in Section 3, with its adoption mode and the products that use it. -- The architecture specification [RS-ARC-G](../rs-arc-g/) is the referent for the layer vocabulary in Section 6: each layer names a component or surface defined there. +This specification is `verified` because its definitions are not only definitions: most entries carry a behavioral claim about a shipped surface, and each such claim is anchored to the code, schema, or configuration that fixes it, per RS-DOC REQ-DOC-014. + +- The four product names in Section 1 map to the crate directories in this workspace: `crates/registry-relay`, `crates/registry-notary*`, `crates/registry-manifest-core` and `crates/registry-manifest-cli`, and `crates/registry-platform-*`. +- The SD-JWT VC media type is the `FORMAT_SD_JWT_VC` constant in `crates/registry-notary-core/src/model.rs`, fixed to `application/dc+sd-jwt`. The OID4VCI profile boundary is the `"support": "not_full_issuer"` capability emitted from `crates/registry-notary-server/src/runtime/evaluation.rs` and asserted in `crates/registry-notary-server/src/runtime/tests/catalog.rs`. +- The JWKS term names a real unauthenticated route: `crates/registry-notary-server/src/api.rs` mounts `/.well-known/evidence/jwks.json`. +- Static-peer federation is the router in `crates/registry-notary-server/src/federation/mod.rs`, which mounts only `POST /federation/v1/evaluations` and resolves callers through the configured peer map. +- The Cargo feature names used by the crosswalk, OGC, and SP DCI entries are the `[features]` table in `crates/registry-relay/Cargo.toml`: `crosswalk-runtime`, `standards-cel-mapping`, `ogcapi-features`, `ogcapi-records`, `ogcapi-edr`, and `spdci-api-standards`. +- The SDMX media type is the `SDMX_JSON` constant in `crates/registry-relay/src/api/aggregates/format.rs`, and the aggregate response fields are built in `crates/registry-relay/src/api/aggregates/response.rs`. +- The Evidence Gateway, PDP, PEP, PDP gate, ODRL enforcement profile, stable PDP denial code, and fail-closed entries are defined by `crates/registry-platform-pdp/src/lib.rs`, which carries the `registry-evidence-gateway-pdp/v1` profile and the `pdp.*` codes, and enforced from `crates/registry-relay/src/api/governed.rs`. +- The metadata manifest, runtime binding, and static discovery bundle entries are fixed by `crates/registry-manifest-core/src/lib.rs`: the `registry-manifest/v1` schema-version gate, the `RUNTIME_ONLY_KEYS` exclusion list, and the `render_*` functions that emit the bundle. +- The Registry Stack project-authoring vocabulary (project, service, records service, evidence service, integration, entity, consultation, output, claim, environment) is the released authoring contract under `crates/registryctl/schemas/project-authoring/`, whose `project.schema.json` carries the `integrations`, `entities`, and `services` collections and the `service`, `recordsService`, `evidenceService`, `consultations`, and `claimValue` definitions. +- The documentation declaration terms in Section 6 are enforced by `docs/site/src/content.config.ts` and `docs/site/scripts/check-doc-frontmatter.mjs`, which accept only the listed `layer` and `audience` values. The claim-level and adoption-mode vocabularies are the `claim_level` and `adoption_mode` fields of `docs/site/src/data/standards.yaml`. +- The [glossary](../../reference/glossary/) and the [standards register](../../reference/standards/) corroborate the same vocabulary in reference form; RS-TERMS is authoritative where it and the glossary disagree. [RS-ARC-G](../rs-arc-g/) is the referent for the layer vocabulary in Section 6: each layer names a component or surface defined there. ## Next diff --git a/docs/site/src/styles/custom.css b/docs/site/src/styles/custom.css index 89871b653..ff20b6a98 100644 --- a/docs/site/src/styles/custom.css +++ b/docs/site/src/styles/custom.css @@ -127,6 +127,16 @@ body { background: var(--registry-surface); } +/* Diagram SVGs in public/images/ are authored with a fixed light palette + (ink/body/muted text on a white or transparent canvas), so they lose all + contrast if they inherit --registry-surface's dark-theme value. Pin them to + a literal white surface in both themes instead of theme-aware SVG internals + or dark variants; the border above already frames them the same way it + does in light mode, so no extra padding or radius is introduced here. */ +.sl-markdown-content img[src*='/images/'][src$='.svg'] { + background: #ffffff; +} + .registry-header { display: grid; grid-template-rows: 60px; diff --git a/products/manifest/CHANGELOG.md b/products/manifest/CHANGELOG.md index 402150598..2d03f2d9b 100644 --- a/products/manifest/CHANGELOG.md +++ b/products/manifest/CHANGELOG.md @@ -7,6 +7,18 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- BREAKING: `evaluation_profiles[].max_source_observed_age_seconds` is removed + from the metadata manifest schema. The field configured nothing: no runtime + read it, and nothing in Registry Notary or Registry Relay bounded + source-observation age from it. Because the schema rejects unknown keys, a + manifest that still carries the key now fails `validate`, `render`, and + `publish` with an error naming the key. Migration: delete the key. Nothing + replaces it. Notary bounds consultation-result age through the federation + profile's `max_claim_result_age_seconds`, and Registry Relay owns snapshot + freshness through `max_snapshot_age_ms`. The identically named key inside + `evidence_pack.policy.freshness` is a different, unvalidated field and is + unaffected. + ## [0.16.0] - 2026-08-01 - No user-visible Registry Manifest changes. diff --git a/products/manifest/docs/reference.md b/products/manifest/docs/reference.md index 1fe981819..3511b0644 100644 --- a/products/manifest/docs/reference.md +++ b/products/manifest/docs/reference.md @@ -168,7 +168,6 @@ Fields of `EvaluationProfileManifest`. | `ruleset` | Yes | Public ruleset id. Must be unique and referenced by `registry-notary` offerings. | | `claim_id` | Yes | Notary claim id evaluated for the profile. | | `subject_id_type` | Yes | Subject id type the profile accepts. | -| `max_source_observed_age_seconds` | No | Optional public freshness hint. Runtime enforcement is in Registry Notary config. | | `evidence_pack` | No | Optional `EvidencePackMetadata` object. Shares its shape with `ecosystem_bindings[].evidence_pack`. See [Ecosystem binding keys](#ecosystem-binding-keys). | For `EvidenceOfferingAccessManifest` with `kind: registry-notary`: @@ -324,6 +323,13 @@ field to an incompatible type, or making previously valid V1 manifests invalid e for validation bugs, security fixes, or the unknown-key rejection this section describes. +That rule takes effect at `v1.0.0`, with the rest of the compatibility promise in +[`api-stability`](../../../docs/site/src/content/docs/reference/api-stability.mdx). +Before `v1.0.0` a minor release may break the schema without a new schema version, +announced as a `BREAKING:` entry with migration steps in `CHANGELOG.md`. The +unknown-key rejection named above was one such change; removing an optional field +that configured nothing is another. Neither is a precedent for after `v1.0.0`. + ## Publish output artifacts Source: diff --git a/products/manifest/docs/release-notes.md b/products/manifest/docs/release-notes.md index 067f401a8..3c21134b0 100644 --- a/products/manifest/docs/release-notes.md +++ b/products/manifest/docs/release-notes.md @@ -2,6 +2,10 @@ ## Unreleased +- BREAKING: `evaluation_profiles[].max_source_observed_age_seconds` is removed. + It configured nothing, and the schema rejects unknown keys, so a manifest that + still carries it fails validation. Delete the key; nothing replaces it. + ## 0.16.0 - Registry Manifest has no user-visible changes in this release. diff --git a/products/notary/docs/notary-capability-matrix.md b/products/notary/docs/notary-capability-matrix.md index c054136da..44b653a30 100644 --- a/products/notary/docs/notary-capability-matrix.md +++ b/products/notary/docs/notary-capability-matrix.md @@ -81,6 +81,16 @@ does not recompute that decision as consumer policy. | 21 | Auditor verifies minimized evidence exchange | Governance | Partial | Signed results and audit exist, checkpoints are planned | | 22 | Peer audit checkpoint monitoring | Governance | Planned | No checkpoint publisher, Merkle builder, or peer monitor ships yet; you cannot independently verify peer audit checkpoints | +Source-observation age is not a Registry Notary enforced quantity. The +federation profile's `max_claim_result_age_seconds` bounds the age of a Relay +consultation result rather than the age of the observation behind it, and +Registry Relay owns snapshot freshness through `max_snapshot_age_ms`. Notary +does enforce age bounds for other purposes, including +`subject_access.token_policy.max_auth_age_seconds` and +`max_evaluation_age_seconds` on subject-access flows and +`oid4vci.proof.max_age_seconds` on wallet proofs, but none of them bound +source-observation age either. + Each Relay authority uses one Notary authority, with Notary-owned PostgreSQL correctness state for production and multi-instance deployment. Wallet-facing issuance supports only issuer-initiated pre-authorized code, EdDSA `did:jwk` diff --git a/release/notes/standards-claims-inventory.md b/release/notes/standards-claims-inventory.md index 206630ab6..88dcb7fb0 100644 --- a/release/notes/standards-claims-inventory.md +++ b/release/notes/standards-claims-inventory.md @@ -57,7 +57,7 @@ no saved evidence yet or are explicit non-claims and comparison boundaries. | OpenSSF OSPS baseline | "Registry Stack targets OpenSSF OSPS Baseline 2026-02-19, Level 1" | [OpenSSF and release trust](../../docs/site/src/content/docs/security/openssf-evidence.mdx), `OSPS baseline` | Root workflows and release process | none | The page records a baseline target map. It does not claim third-party OSPS certification. | | SLSA provenance | "release-level SLSA provenance"; "v0.8.3 is the first provenance-bearing root release" | [OpenSSF and release trust](../../docs/site/src/content/docs/security/openssf-evidence.mdx), `Release trust status`; [OpenSSF and release trust](../../docs/site/src/content/docs/security/openssf-evidence.mdx), `OSPS baseline` | Root release workflow | none | This is release evidence publication, not a SLSA level certification claim. | | ITB/GITB Notary runtime suite target | "target shape for testing Registry Notary as a system under test through the ISA ITB/GITB stack"; "must not claim Registry Notary GITB conformance until the runtime scenarios above are implemented, run, and reviewed" | [Notary GITB conformance suite](../../products/notary/specs/gitb-conformance-suite.md), `Evidence Boundary`; [ITB and SEMIC evidence](../../docs/site/src/content/docs/reference/itb-semic-evidence.mdx), `What is not covered` | `registry-notary-server` and future suite tooling | none | This is an active design note and explicit non-claim for runtime GITB coverage. | -| RFC 3339 timestamp fields | "timestamp -> timestamptz rendered as RFC 3339 UTC text"; "`source_observed_at_field` ... holding an RFC 3339 timestamp" | [Relay configuration guide](../../crates/registry-relay/docs/configuration.md), `Postgres type mapping`; [operator config reference](../../products/notary/docs/operator-config-reference.md), `Source freshness` | `registry-relay`, `registry-notary-core`, `registry-notary-server` | test vectors | Scope is documented timestamp parsing/rendering behavior, not a general RFC 3339 conformance suite. | +| RFC 3339 timestamp fields | "timestamp -> timestamptz rendered as RFC 3339 UTC text" | [Relay configuration guide](../../crates/registry-relay/docs/configuration.md), `Sources` | `registry-relay`, `registry-notary-core`, `registry-notary-server` | test vectors | Scope is documented timestamp parsing/rendering behavior, not a general RFC 3339 conformance suite. Notary renders RFC 3339 timestamps such as `issued_at` and the consultation `acquired_at`; it documents no source-observation freshness field. | | RFC 3987 IRI validity boundary | "URI checks are shallow"; "not for full RFC 3987 IRI validity" | [RS-DM-MANIFEST](../../docs/site/src/content/docs/spec/rs-dm-manifest.mdx), `Known limits` | `registry-manifest-core` | none | This is an explicit limitation, not an IRI conformance claim. | | RFC 2119 and RFC 8174 BCP 14 terms | "key words ... are to be interpreted as described in BCP 14" | [RS-DOC](../../docs/site/src/content/docs/spec/rs-doc.mdx), `3. Conventions` | Spec documents | none | Documentation convention only. | | X-Road and exchange-layer composition | "Examples: X-Road, GovStack-style reference exchanges"; "Registry Stack adds the registry-facing surface that sits behind the exchange layer" | [Integration patterns](../../docs/site/src/content/docs/explanation/integration-patterns.mdx), `Exchange layer or integration bus` | Stack-wide deployment composition | none | The page describes composition behind exchange layers. It does not claim X-Road implementation or conformance. |