Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
896370e
fix(docs): scope static-peer federation to the shipped inbound route
jeremi Aug 1, 2026
58fbcdf
fix(docs): exclude the audit pipeline from Notary PostgreSQL state cl…
jeremi Aug 1, 2026
52d3c30
fix(docs): retry pinned docs fetches and surface git stderr
jeremi Aug 1, 2026
12b74c5
fix(docs): keep diagram SVGs legible in dark mode
jeremi Aug 1, 2026
5924a73
fix(docs): anchor verified specs to inspectable artifacts
jeremi Aug 1, 2026
e41986a
fix(manifest): remove inert max_source_observed_age_seconds field
jeremi Aug 1, 2026
80e9b2f
fix(docs): resolve SVG class fills without rewriting the document
jeremi Aug 1, 2026
b7b3053
fix(docs): report SVG text fills the contrast gate cannot score
jeremi Aug 1, 2026
579179f
fix(docs): correct federation and audit-pipeline claims in the spec l…
jeremi Aug 1, 2026
159fc4c
fix(docs): resolve the effective fill when a CSS rule declares it twice
jeremi Aug 1, 2026
22c94f7
fix(docs): score reverse text against its chip and resolve the CSS ca…
jeremi Aug 1, 2026
ba27a38
fix(docs): stop claiming one Notary freshness control where several e…
jeremi Aug 1, 2026
284e041
docs(manifest): record the removed source-age key as a pre-1.0 break
jeremi Aug 1, 2026
03edb92
fix(docs): scope the manifest schema-version rule to 1.0 and later
jeremi Aug 1, 2026
360dc07
fix(docs): scope the capability matrix freshness claim to source obse…
jeremi Aug 1, 2026
aa8f3db
fix(docs): resolve CSS rules, shorthand hex, and tspan fills before s…
jeremi Aug 1, 2026
88d3a2d
fix(docs): honor inline style fills and require an adjacent backdrop
jeremi Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions crates/registry-manifest-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,8 +567,6 @@ pub struct EvaluationProfileManifest {
pub claim_id: String,
pub subject_id_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Split the change along owning-area boundaries

This commit combines a Manifest schema break under crates/, product-owned documentation under products/, docs-site scripts and specifications under docs/site/, and a release inventory edit under release/. Split these into owner-scoped changes so each owning area can be reviewed and verified independently, as the repository explicitly requires.

AGENTS.md reference: AGENTS.md:L77-L78

Useful? React with 👍 / 👎.

pub max_source_observed_age_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_pack: Option<EvidencePackMetadata>,
Comment on lines 569 to 570

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Version the manifest before removing this field

Any existing registry-manifest/v1 document containing the previously valid optional max_source_observed_age_seconds field now fails deserialization because this struct uses deny_unknown_fields. That contradicts the published V1 compatibility contract in products/manifest/docs/reference.md, which requires a new schema version when a change makes previously valid V1 manifests invalid; introduce a new manifest schema version or retain acceptance of the deprecated field during V1 parsing.

Useful? React with 👍 / 👎.

}

Expand Down
32 changes: 31 additions & 1 deletion crates/registry-manifest-core/tests/metadata_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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::<MetadataManifest>(
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#"
Expand Down
2 changes: 0 additions & 2 deletions crates/registry-relay/demo/config/all_demos.metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions crates/registry-relay/tests/entity_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
189 changes: 170 additions & 19 deletions docs/site/scripts/check-svg-a11y.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
#!/usr/bin/env node

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required DCO sign-off

Commit b81b42a6831172c9b9213fa07553b497f9b3e8e7 has no Signed-off-by: trailer, so it does not satisfy the repository's mandatory DCO policy and will be rejected by the corresponding contribution gate. Recreate this commit with git commit -s before merging.

AGENTS.md reference: AGENTS.md:L70-L70

Useful? React with 👍 / 👎.


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',
Expand All @@ -18,27 +25,171 @@
'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;

function expandHex(hex) {
const value = hex.slice(1);
if (value.length === 3) {
return `#${[...value].map((c) => c + c).join('')}`;
}
return `#${value.slice(0, 6)}`;
}

function srgbChannelToLinear(channel) {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}

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 (!/<title[>\s]/.test(text)) errors.push(`${relative('.', file)} missing <title>`);
if (!/<desc[>\s]/.test(text)) errors.push(`${relative('.', file)} missing <desc>`);
if (!/role="img"/.test(text)) errors.push(`${relative('.', file)} missing role="img"`);
function relativeLuminance(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);
Comment on lines +77 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unsupported SVG colors before comparing contrast

When SVG text uses a valid non-six-digit color syntax such as fill="white", fill="rgb(255,255,255)", fill="none", or fill="#0000", these parseInt calls produce NaN or ignore the alpha channel; the later ratio < 4.5 comparison is then false, so invisible or low-contrast text passes the accessibility gate. Normalize and composite every supported SVG color syntax, or report unsupported and translucent fills as unresolved, rather than treating a non-finite ratio as success.

Useful? React with 👍 / 👎.

return (
0.2126 * srgbChannelToLinear(r) +
0.7152 * srgbChannelToLinear(g) +
0.0722 * srgbChannelToLinear(b)
);
}

for (const name of expected) {
if (!seen.has(name)) errors.push(`public/images/${name} is missing`);
// 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);
}

if (errors.length) {
console.error(errors.join('\n'));
process.exit(1);
function parseClassFillMap(svgText) {
const styleMatch = svgText.match(/<style[^>]*>([\s\S]*?)<\/style>/);
const classFills = new Map();
if (!styleMatch) return classFills;
// 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.
const ruleRe = /\.([\w-]+)\s*\{([^}]*)\}/g;
let rule;
while ((rule = ruleRe.exec(styleMatch[1]))) {
const [, className, body] = rule;
const fillMatch = body.match(/fill:\s*(#[0-9a-fA-F]{3,8})/);
if (fillMatch) classFills.set(className, fillMatch[1]);
}
return classFills;
}

console.log('SVG accessibility check passed.');
function attrValue(tag, name) {
const match = tag.match(new RegExp(`${name}="([^"]*)"`));
return match ? match[1] : null;
}

// Resolves the effective fill color of every <text> element in an SVG,
// walking <g fill="..."> ancestry and class-based fills from a <style>
// block. Returns { colors, unresolved }: `colors` are hex fills actually
// used to paint text (deduplicated), `unresolved` counts <text> elements
// whose fill could not be determined (no inline fill, no matching class, no
// enclosing <g fill>).
export function extractTextFillColors(svgText) {
const withoutStyle = svgText.replace(/<style[^>]*>[\s\S]*?<\/style>/, '');

Check failure

Code scanning / CodeQL

Incomplete multi-character sanitization High documentation

This string may still contain
<style
, which may cause an HTML element injection vulnerability.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const classFills = parseClassFillMap(svgText);
const tokenRe = /<g\b[^>]*>|<\/g>|<text\b[^>]*>/g;
const gFillStack = [];
const colors = new Set();
let unresolved = 0;
let token;
while ((token = tokenRe.exec(withoutStyle))) {
const tag = token[0];
if (tag === '</g>') {
gFillStack.pop();
continue;
}
if (tag.startsWith('<g')) {
const inherited = gFillStack[gFillStack.length - 1] ?? null;
gFillStack.push(attrValue(tag, 'fill') ?? inherited);
continue;
}
// <text ...>
const inlineFill = attrValue(tag, 'fill');
const classAttr = attrValue(tag, 'class');
const classFill = classAttr
? classAttr.split(/\s+/).map((name) => classFills.get(name)).find(Boolean)
: undefined;
Comment on lines +225 to +232

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply stylesheet order across multiple text classes

When a <text> element has multiple classes that each define fill, CSS chooses by specificity and stylesheet source order, not by the order of names in the class attribute. This find(Boolean) instead always selects the first class, so <text class="safe danger"> can be scored with a high-contrast .safe fill even when a later .danger rule is the low-contrast color actually rendered. The fresh issue in the updated resolver is this cross-class cascade shortcut; resolve the winning CSS declaration before scoring.

Useful? React with 👍 / 👎.

const inheritedFill = gFillStack[gFillStack.length - 1] ?? null;
const resolved = inlineFill ?? classFill ?? inheritedFill;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply CSS rules before presentation attributes

When a <text> has both fill="#161616" and a class whose stylesheet rule sets fill: #cccccc, CSS gives the class rule precedence over the SVG presentation attribute, but this expression selects inlineFill first. The checker therefore scores the unused high-contrast color and lets the rendered low-contrast text pass; resolve the effective CSS cascade rather than treating the fill attribute as an inline style.

Useful? React with 👍 / 👎.

if (resolved) {
colors.add(resolved.toLowerCase());
} else {
unresolved += 1;
}
}
return { colors: [...colors], unresolved };
}

// Contrast errors for one SVG's text against the fixed diagram surface.
// Pure white (#ffffff) text is excluded: in the shipped diagrams it is only
// ever used as reverse text on a small colored chip (e.g. the "DCI-NATIVE"
// tag on a #000091 rect in registry-country-evidence-mesh.svg), never
// directly on the diagram's own white/transparent canvas, so it is not
// actually read against DIAGRAM_SURFACE.
export function svgContrastErrors(fileLabel, svgText) {
const errors = [];
const { colors, unresolved } = extractTextFillColors(svgText);
if (unresolved > 0) {
errors.push(`${fileLabel} has ${unresolved} <text> element(s) with no resolvable fill color`);
}
for (const color of colors) {
// White text in these diagrams is always reverse text on a colored chip
// (for example a #000091 rect), never painted on the diagram canvas, so
// measuring it against DIAGRAM_SURFACE would report a false failure.
// Resolving the actual painted rect behind each label would mean
// geometric analysis; the tradeoff is that white-on-white text, which no
// diagram would intend, goes undetected here.
if (color === '#ffffff') continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate white text against its actual background

When white text is added anywhere other than the currently known colored chips, this unconditional exemption skips contrast validation even though the new CSS pins the diagram surface to white. A misplaced label such as <text fill="#ffffff"> on the canvas therefore passes the accessibility gate at 1:1 contrast; associate reverse text with a verified non-white background instead of exempting the color globally.

Useful? React with 👍 / 👎.

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.');
}
}
Loading
Loading