From 896370e1f0472d97fe7192a9ae022f7af5832b55 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 12:41:22 +0700 Subject: [PATCH 01/17] fix(docs): scope static-peer federation to the shipped inbound route Only the inbound POST /federation/v1/evaluations route ships; no outbound Notary-to-Notary client or peer-result composition exists in the workspace. Point outbound composition at GH#36. Refs #586 Signed-off-by: Jeremi Joslin --- docs/site/src/content/docs/spec/rs-arc-g.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..c5cb334e8 100644 --- a/docs/site/src/content/docs/spec/rs-arc-g.mdx +++ b/docs/site/src/content/docs/spec/rs-arc-g.mdx @@ -164,7 +164,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 by a separate trusted caller, because no outbound Notary-to-Notary client and no peer-result composition runtime are 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 From 58fbcdfc39cbb991c6208b7feb87fad139b26e4e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 12:41:58 +0700 Subject: [PATCH 02/17] fix(docs): exclude the audit pipeline from Notary PostgreSQL state claims postgres_state_plane_v1.sql holds no audit table: audit correctness lives in the hash-chained pipeline with operator-owned sink retention and off-host shipping. Restoring the database does not restore audit history. Refs #584 Signed-off-by: Jeremi Joslin --- docs/site/src/content/docs/spec/rs-arc-g.mdx | 2 +- docs/site/src/content/docs/spec/rs-pr-notary.mdx | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) 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 c5cb334e8..638b3d6b5 100644 --- a/docs/site/src/content/docs/spec/rs-arc-g.mdx +++ b/docs/site/src/content/docs/spec/rs-arc-g.mdx @@ -188,7 +188,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 hash-chained audit pipeline defined in [RS-PR-NOTARY](../rs-pr-notary/) (REQ-PR-NOTARY-020, REQ-PR-NOTARY-021), whose 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. 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..b102419f9 100644 --- a/docs/site/src/content/docs/spec/rs-pr-notary.mdx +++ b/docs/site/src/content/docs/spec/rs-pr-notary.mdx @@ -288,8 +288,14 @@ 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 hash-chained audit pipeline +that writes to the configured sink (REQ-PR-NOTARY-020, REQ-PR-NOTARY-021), and +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 From 52d3c30808e941f776e23220a5e6b29a9191b7df Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 12:51:31 +0700 Subject: [PATCH 03/17] fix(docs): retry pinned docs fetches and surface git stderr The docs gate shallow-clones dozens of pinned refs; one dropped connection failed the whole `npm run check` and the real git error was discarded behind "Command failed: git fetch ...". Wrap only the network fetch in a bounded 3-attempt linear backoff and carry git stderr, bounded, into the failure. Retries are unconditional: permanent and transient git stderr are not reliably distinguishable across versions and transports, and a wrong heuristic costs more than a redundant retry. Also guards fetch-openapi.mjs behind a direct-invocation check, matching sync-repo-docs.mjs, so tests can import its helpers without running a real fetch. No skip flag and no second verification mode. Refs #294 Signed-off-by: Jeremi Joslin --- docs/site/scripts/fetch-openapi.mjs | 32 ++++-- docs/site/scripts/fetch-openapi.test.mjs | 55 ++++++++++ docs/site/scripts/git-fetch-retry.mjs | 66 ++++++++++++ docs/site/scripts/git-fetch-retry.test.mjs | 117 +++++++++++++++++++++ docs/site/scripts/sync-repo-docs.mjs | 22 ++-- docs/site/scripts/sync-repo-docs.test.mjs | 40 +++++++ 6 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 docs/site/scripts/fetch-openapi.test.mjs create mode 100644 docs/site/scripts/git-fetch-retry.mjs create mode 100644 docs/site/scripts/git-fetch-retry.test.mjs 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 }); + } +}); From 12b74c5ca9e35f6834294704ae7c5794af2c59e6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 12:57:39 +0700 Subject: [PATCH 04/17] fix(docs): keep diagram SVGs legible in dark mode The 13 content diagrams are authored with a fixed light palette on a transparent canvas, so inheriting --registry-surface (#1b1e24 in dark theme) left dark text on a near-black background. Pin content diagram SVGs to a literal white surface in both themes rather than shipping dark variants. Add a WCAG contrast regression to check-svg-a11y.mjs, which npm run check already runs, so a future palette drift fails the gate instead of shipping. All 13 assets clear 4.5:1 today (worst is #6a6a6a at 5.41:1). Refs #291 Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-svg-a11y.mjs | 189 +++++++++++++++++++--- docs/site/scripts/check-svg-a11y.test.mjs | 87 ++++++++++ docs/site/src/styles/custom.css | 10 ++ 3 files changed, 267 insertions(+), 19 deletions(-) create mode 100644 docs/site/scripts/check-svg-a11y.test.mjs diff --git a/docs/site/scripts/check-svg-a11y.mjs b/docs/site/scripts/check-svg-a11y.mjs index 69a4d7577..775172b40 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,171 @@ 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; + +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 (!/\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 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); + 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>/, ''); + 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; + const inheritedFill = gFillStack[gFillStack.length - 1] ?? null; + const resolved = inlineFill ?? classFill ?? inheritedFill; + 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; + 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..7008d391d --- /dev/null +++ b/docs/site/scripts/check-svg-a11y.test.mjs @@ -0,0 +1,87 @@ +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('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(), []); +}); 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; From 5924a73f01e138f2680677e442aa880bf2aacaf5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Sat, 1 Aug 2026 13:07:04 +0700 Subject: [PATCH 05/17] fix(docs): anchor verified specs to inspectable artifacts RS-DOC gains REQ-DOC-014: a specification marked evidence: verified must anchor each load-bearing claim to an artifact a reader can open directly (source, test, fixture, generated artifact, or released machine contract). A site page may corroborate an anchor but must not be the only evidence, since evidence resolving solely to further documentation is a citation loop rather than verification. Rework the Evidence sections of the specs that leaned on site pages so they cite workspace source, tests, and generated machine contracts. rs-op-posture previously shipped as verified while admitting in its own Evidence section that source evidence was pending; it now cites the posture JSON Schema, the waiver-metadata validator, and the profile-gates contract test. Refs #237 Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- docs/site/src/content/docs/spec/rs-arc-g.mdx | 17 +++++++---- .../src/content/docs/spec/rs-dm-manifest.mdx | 16 +++++----- docs/site/src/content/docs/spec/rs-doc.mdx | 15 +++++----- .../src/content/docs/spec/rs-op-posture.mdx | 30 ++++++++++++------- .../src/content/docs/spec/rs-pr-relay.mdx | 17 +++++++---- docs/site/src/content/docs/spec/rs-terms.mdx | 18 +++++++---- 6 files changed, 72 insertions(+), 41 deletions(-) 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 638b3d6b5..869b0bac7 100644 --- a/docs/site/src/content/docs/spec/rs-arc-g.mdx +++ b/docs/site/src/content/docs/spec/rs-arc-g.mdx @@ -232,12 +232,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..4d320f06d 100644 --- a/docs/site/src/content/docs/spec/rs-dm-manifest.mdx +++ b/docs/site/src/content/docs/spec/rs-dm-manifest.mdx @@ -186,13 +186,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. - -- 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). +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 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-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 From e41986a8824ecf6a78655da0fcae1bdc0125e37b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Sat, 1 Aug 2026 13:09:07 +0700 Subject: [PATCH 06/17] fix(manifest): remove inert max_source_observed_age_seconds field EvaluationProfileManifest carried max_source_observed_age_seconds as an optional public freshness hint whose documentation promised that "runtime enforcement is in Registry Notary config". No such enforcement exists. Notary derives freshness from verified Relay consultation provenance and retains only the consultation acquired_at timestamp; its one freshness control is the federation profile max_claim_result_age_seconds, which bounds consultation-result age. Snapshot freshness is owned by Registry Relay through max_snapshot_age_ms. The field configured nothing. Because the struct carries #[serde(deny_unknown_fields)], removing it makes the key a hard parse error rather than a silently ignored one. This is a registry-manifest/v1 format break, deliberate over leaving a field that tells adopters an enforcement story the runtime does not implement. Drop the key from the five hand-maintained demo manifests and the Relay entity-route tests, and correct the prose that claimed the enforcement. Occurrences of max_source_observed_age_seconds under an evidence pack policy are a different key in a different namespace: EvidencePackMetadata policy is opaque unvalidated JSON, so those are left as authored. The change spans four owning areas rather than one. crates, products, and docs/site are forced by the generated-output rule, since the manifest reference mirror regenerates from products. The release entry is a stale claim of the same kind: it quoted a source_observed_at_field section of the Notary operator config reference that no longer exists, and cited a Relay configuration heading that does not exist either. Refs #583 Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- crates/registry-manifest-core/src/lib.rs | 2 -- .../tests/metadata_core.rs | 32 ++++++++++++++++++- .../demo/config/all_demos.metadata.yaml | 2 -- .../demo/config/all_standards.metadata.yaml | 3 -- .../config/benefits_casework.metadata.yaml | 1 - .../config/disability_registry.metadata.yaml | 3 -- .../config/education_registry.metadata.yaml | 1 - crates/registry-relay/tests/entity_routes.rs | 3 -- .../docs/explanation/known-limitations.mdx | 5 +++ products/manifest/docs/reference.md | 1 - .../notary/docs/notary-capability-matrix.md | 6 ++++ release/notes/standards-claims-inventory.md | 2 +- 12 files changed, 43 insertions(+), 18 deletions(-) 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<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] pub evidence_pack: Option<EvidencePackMetadata>, } 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::<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#" 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/src/content/docs/explanation/known-limitations.mdx b/docs/site/src/content/docs/explanation/known-limitations.mdx index 8b571f8d5..f2bea4de7 100644 --- a/docs/site/src/content/docs/explanation/known-limitations.mdx +++ b/docs/site/src/content/docs/explanation/known-limitations.mdx @@ -137,6 +137,11 @@ 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 only Notary freshness control is the federation + profile's `max_claim_result_age_seconds` (which bounds consultation-result age), and Registry + Relay owns snapshot freshness through `max_snapshot_age_ms`. - 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/products/manifest/docs/reference.md b/products/manifest/docs/reference.md index 1fe981819..296d5ed6f 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`: diff --git a/products/notary/docs/notary-capability-matrix.md b/products/notary/docs/notary-capability-matrix.md index c054136da..1c1f9fb3d 100644 --- a/products/notary/docs/notary-capability-matrix.md +++ b/products/notary/docs/notary-capability-matrix.md @@ -81,6 +81,12 @@ 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 only +Notary freshness control is the federation profile's +`max_claim_result_age_seconds`, which bounds the age of a Relay consultation +result, while Registry Relay owns snapshot freshness through +`max_snapshot_age_ms`. + 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. | From 80e9b2ff121c446217d53744bae4a44becab00cf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin <jeremi@joslin.fr> Date: Sat, 1 Aug 2026 13:15:39 +0700 Subject: [PATCH 07/17] fix(docs): resolve SVG class fills without rewriting the document The SVG accessibility checker had two defects in how it handled <style>. It resolved class-based fills from only the first <style> element, so an SVG carrying a second one lost those rules: its labels resolved to no fill and were reported as unresolvable, failing CI on a valid diagram. Every block is now read, later rules winning as CSS source order does. It also deleted <style> blocks from the text before scanning for painted content. Removing a substring cannot be done safely in one pass, because a removal can splice its neighbours into a fresh `<style` and leave behind exactly what the removal was meant to drop. Style regions are now skipped during the tokenizer walk instead, which needs no rewriting and cannot resurrect a tag. CodeQL reported the deletion as incomplete multi-character sanitization. The injection framing does not apply: this is a build-time linter over trusted in-tree diagrams with no rendering sink. The incompleteness was real, so the sanitize-shaped code is gone rather than patched. Belongs to the #291 commit; kept separate to avoid rewriting a pushed branch under review. Refs #291 Signed-off-by: Jeremi Joslin <jeremi@joslin.fr> --- docs/site/scripts/check-svg-a11y.mjs | 41 +++++++++++++++++------ docs/site/scripts/check-svg-a11y.test.mjs | 37 ++++++++++++++++++++ 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/docs/site/scripts/check-svg-a11y.mjs b/docs/site/scripts/check-svg-a11y.mjs index 775172b40..559e32ce7 100644 --- a/docs/site/scripts/check-svg-a11y.mjs +++ b/docs/site/scripts/check-svg-a11y.mjs @@ -65,19 +65,23 @@ export function contrastRatio(hexA, hexB) { 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) { - 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]); + for (const styleMatch of svgText.matchAll(STYLE_BLOCK_RE)) { + 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; } @@ -94,15 +98,30 @@ function attrValue(tag, name) { // 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>/, ''); const classFills = parseClassFillMap(svgText); - const tokenRe = /<g\b[^>]*>|<\/g>|<text\b[^>]*>/g; + // <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\b[^>]*>/g; const gFillStack = []; const colors = new Set(); let unresolved = 0; + let inStyle = false; let token; - while ((token = tokenRe.exec(withoutStyle))) { + 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; diff --git a/docs/site/scripts/check-svg-a11y.test.mjs b/docs/site/scripts/check-svg-a11y.test.mjs index 7008d391d..c07ba4dc1 100644 --- a/docs/site/scripts/check-svg-a11y.test.mjs +++ b/docs/site/scripts/check-svg-a11y.test.mjs @@ -85,3 +85,40 @@ test('svgAccessibilityErrors reports missing title/desc/role and missing files', 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), []); +}); From b7b3053d50a92c2bb14a9332713d20c684bc91b0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 14:48:51 +0700 Subject: [PATCH 08/17] fix(docs): report SVG text fills the contrast gate cannot score The contrast checker scored fills by parsing hex digits. A valid SVG paint value it could not parse (`white`, `rgb(255,255,255)`, `currentColor`, `none`) produced NaN, and `NaN < 4.5` is false, so the gate passed the file in silence. `#rrggbbaa` was worse than silent: the alpha channel was sliced off, so half-transparent black scored 21:1, the maximum. Only #rgb and #rrggbb are scoreable now, and anything else is reported as an error naming the offending value. Scoring the rest would mean shipping a named-color database and alpha compositing rules for a build-time linter. No shipped diagram currently resolves a to an unscoreable fill, so this closes a latent hole rather than fixing an active miss. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-svg-a11y.mjs | 33 ++++++++++++++++-- docs/site/scripts/check-svg-a11y.test.mjs | 41 +++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/site/scripts/check-svg-a11y.mjs b/docs/site/scripts/check-svg-a11y.mjs index 559e32ce7..331b486fe 100644 --- a/docs/site/scripts/check-svg-a11y.mjs +++ b/docs/site/scripts/check-svg-a11y.mjs @@ -31,12 +31,31 @@ const expected = new Set([ 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); +} + +// Hex spellings are lowercased so `#FFF` and `#fff` dedupe to one entry. +// 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) ? value.toLowerCase() : value; +} + function expandHex(hex) { const value = hex.slice(1); if (value.length === 3) { return `#${[...value].map((c) => c + c).join('')}`; } - return `#${value.slice(0, 6)}`; + return `#${value}`; } function srgbChannelToLinear(channel) { @@ -45,6 +64,9 @@ function srgbChannelToLinear(channel) { } 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); @@ -140,7 +162,7 @@ export function extractTextFillColors(svgText) { const inheritedFill = gFillStack[gFillStack.length - 1] ?? null; const resolved = inlineFill ?? classFill ?? inheritedFill; if (resolved) { - colors.add(resolved.toLowerCase()); + colors.add(normalizeColor(resolved)); } else { unresolved += 1; } @@ -161,6 +183,13 @@ export function svgContrastErrors(fileLabel, svgText) { errors.push(`${fileLabel} has ${unresolved} element(s) with no resolvable fill color`); } for (const color of colors) { + 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; + } // 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. diff --git a/docs/site/scripts/check-svg-a11y.test.mjs b/docs/site/scripts/check-svg-a11y.test.mjs index c07ba4dc1..e36b2143f 100644 --- a/docs/site/scripts/check-svg-a11y.test.mjs +++ b/docs/site/scripts/check-svg-a11y.test.mjs @@ -122,3 +122,44 @@ test('style regions are skipped without rewriting the document', () => { 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('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'); +}); From 579179f224ce5f079b405ebafa1886d23fb66a49 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 14:51:54 +0700 Subject: [PATCH 09/17] fix(docs): correct federation and audit-pipeline claims in the spec layer Three review findings, all cases of a sentence claiming more than the code or the cited requirement supports. RS-ARC-G said no outbound Notary-to-Notary client is part of this version. `registry-notary-client` ships a feature-gated `federation_evaluate_jws` that posts to that route, so the denial was false. The true and narrower facts stand: the helper transports an already-signed JWS and cannot construct or sign one, the Notary server does not enable the feature, and no peer-result composition runtime exists. REQ-ARC-G-013 and REQ-PR-NOTARY-041 attributed hash chaining to REQ-PR-NOTARY-020 and REQ-PR-NOTARY-021. Those two require emission to the configured sink and failing the request on a write error; neither mentions chaining, and no requirement anywhere makes it contractual. The pipeline does hash-chain, so that stays as a statement of fact, and the two IDs are now cited only for what they establish. Both requirements changed obligations without a version-history row, which REQ-DOC-012 requires unconditionally. Signed-off-by: Jeremi Joslin --- docs/site/src/content/docs/spec/rs-arc-g.mdx | 5 +++-- docs/site/src/content/docs/spec/rs-pr-notary.mdx | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) 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 869b0bac7..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.** 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 by a separate trusted caller, because no outbound Notary-to-Notary client and no peer-result composition runtime are 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. +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, 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 hash-chained audit pipeline defined in [RS-PR-NOTARY](../rs-pr-notary/) (REQ-PR-NOTARY-020, REQ-PR-NOTARY-021), whose 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-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. 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 b102419f9..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 @@ -290,10 +291,11 @@ authority with one Registry Relay authority. Notary MUST own the PostgreSQL correctness state used for its transactions, pre-authorized codes, proof replay, 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 hash-chained audit pipeline -that writes to the configured sink (REQ-PR-NOTARY-020, REQ-PR-NOTARY-021), and -sink retention and off-host shipping are operator responsibilities surfaced -through the deployment evidence gates. Restoring the Notary PostgreSQL database +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. From 159fc4c75a4694097ef80adb687bfc678c92dd9f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 15:04:35 +0700 Subject: [PATCH 10/17] fix(docs): resolve the effective fill when a CSS rule declares it twice A class rule may declare fill more than once. CSS paints the last declaration; the checker read the first, so `.label { fill: #161616; fill: #cccccc; }` scored the legible color while the diagram rendered the illegible one. The failure direction is the dangerous one: a legible fill written ahead of an illegible one hid the illegible one from the gate. Cross-rule precedence was already correct, and a test now pins it so the two halves of CSS source order cannot drift apart. No shipped diagram declares fill twice in one rule, so this closes a latent hole rather than fixing an active miss. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-svg-a11y.mjs | 11 ++++++-- docs/site/scripts/check-svg-a11y.test.mjs | 31 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/site/scripts/check-svg-a11y.mjs b/docs/site/scripts/check-svg-a11y.mjs index 331b486fe..8b4fed9de 100644 --- a/docs/site/scripts/check-svg-a11y.mjs +++ b/docs/site/scripts/check-svg-a11y.mjs @@ -101,8 +101,15 @@ function parseClassFillMap(svgText) { 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]); + // 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. + let effectiveFill = null; + for (const declaration of body.matchAll(/fill:\s*(#[0-9a-fA-F]{3,8})/g)) { + effectiveFill = declaration[1]; + } + if (effectiveFill) classFills.set(className, effectiveFill); } } return classFills; diff --git a/docs/site/scripts/check-svg-a11y.test.mjs b/docs/site/scripts/check-svg-a11y.test.mjs index e36b2143f..59213186d 100644 --- a/docs/site/scripts/check-svg-a11y.test.mjs +++ b/docs/site/scripts/check-svg-a11y.test.mjs @@ -158,6 +158,37 @@ test('a translucent black fill is not scored as opaque black', () => { 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/); From 22c94f7a65760fb3745e3323aa0f00a1a66b636e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 15:19:42 +0700 Subject: [PATCH 11/17] fix(docs): score reverse text against its chip and resolve the CSS cascade Three remaining ways the contrast gate could pass unreadable text. A rule's last fill declaration wins even when it is not hex, so `.label { fill: #161616; fill: white; }` renders white. Reading hex declarations only kept #161616 and hid it; any value is captured now and an unscoreable one is reported. A carrying several classes was scored with whichever class was named first in the attribute, but CSS picks by specificity then source order. Every selector this parser handles is a single class, so specificity ties and the later rule wins; rule order is now tracked and used. White text was exempted outright, which passed a stray label on the canvas at 1:1. It is now scored against the shape drawn before it, so the exemption becomes a measurement. Document order stands in for geometry, which is a convention rather than a layout computation, so a label with no shape before it is reported rather than assumed safe. None of the three occurs in a shipped diagram today. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-svg-a11y.mjs | 110 +++++++++++++++++----- docs/site/scripts/check-svg-a11y.test.mjs | 67 +++++++++++++ 2 files changed, 153 insertions(+), 24 deletions(-) diff --git a/docs/site/scripts/check-svg-a11y.mjs b/docs/site/scripts/check-svg-a11y.mjs index 8b4fed9de..b6846b047 100644 --- a/docs/site/scripts/check-svg-a11y.mjs +++ b/docs/site/scripts/check-svg-a11y.mjs @@ -92,7 +92,12 @@ export function contrastRatio(hexA, hexB) { const STYLE_BLOCK_RE = /]*>([\s\S]*?)<\/style>/g; function parseClassFillMap(svgText) { + // className -> { color, order }. `order` is the rule's position across all + // + 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 From ba27a38a0034b0a29184ba12bd5835e08081175f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 15:19:42 +0700 Subject: [PATCH 12/17] fix(docs): stop claiming one Notary freshness control where several exist The page said `max_claim_result_age_seconds` is the only Notary freshness control. Notary also enforces `max_auth_age_seconds` and `max_evaluation_age_seconds` on subject-access flows and `oid4vci.proof.max_age_seconds` on wallet proofs, each with a test, so on a deployment using those flows the page denied controls that were running. The bullet's actual point is unchanged and still holds: nothing in Notary bounds the age of the observation behind a claim. Signed-off-by: Jeremi Joslin --- .../src/content/docs/explanation/known-limitations.mdx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/site/src/content/docs/explanation/known-limitations.mdx b/docs/site/src/content/docs/explanation/known-limitations.mdx index f2bea4de7..e006164e1 100644 --- a/docs/site/src/content/docs/explanation/known-limitations.mdx +++ b/docs/site/src/content/docs/explanation/known-limitations.mdx @@ -139,9 +139,12 @@ Read the full context in the [Registry Notary protocol](../../spec/rs-pr-notary/ 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 only Notary freshness control is the federation - profile's `max_claim_result_age_seconds` (which bounds consultation-result age), and Registry - Relay owns snapshot freshness through `max_snapshot_age_ms`. + 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. From 284e0415ee65cef143f57341738bccb0d956e183 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 15:44:36 +0700 Subject: [PATCH 13/17] docs(manifest): record the removed source-age key as a pre-1.0 break The manifest reference promised a new schema version for any change that invalidates a valid V1 manifest, without noting that the promise starts at v1.0.0. Removing an inert optional key is such a change, and so was the unknown-key rejection that shipped in 0.9.0, so the paragraph described a rule the project had already set aside once on purpose. State the pre-1.0 rule the API stability reference already carries, and record the removal as a BREAKING changelog entry with its one migration step, which is what that rule requires and what the earlier break also shipped with. Signed-off-by: Jeremi Joslin --- products/manifest/CHANGELOG.md | 12 ++++++++++++ products/manifest/docs/reference.md | 7 +++++++ products/manifest/docs/release-notes.md | 4 ++++ 3 files changed, 23 insertions(+) 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 296d5ed6f..3511b0644 100644 --- a/products/manifest/docs/reference.md +++ b/products/manifest/docs/reference.md @@ -323,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. From 03edb92199e355c8d7133ef1ad3e2cdcd3f95310 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 15:44:53 +0700 Subject: [PATCH 14/17] fix(docs): scope the manifest schema-version rule to 1.0 and later REQ-DM-MANIFEST-013 ends with an unqualified MUST: a breaking change carries a new schema version. Two pre-1.0 releases have broken the schema without one, deliberately, so the requirement stated an invariant the implementation does not hold to. Bound the obligation to v1.0.0, point at the compatibility promise that already defines the pre-1.0 rule, and name the two breaks that shipped under it so a reader can tell a deliberate exception from an unrecorded violation. Signed-off-by: Jeremi Joslin --- docs/site/src/content/docs/spec/rs-dm-manifest.mdx | 3 +++ 1 file changed, 3 insertions(+) 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 4d320f06d..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. From 360dc074e5aa607bae8265a3327a316f49513aa6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 15:44:53 +0700 Subject: [PATCH 15/17] fix(docs): scope the capability matrix freshness claim to source observations The matrix called max_claim_result_age_seconds the only Notary freshness control. Subject-access flows enforce max_auth_age_seconds and max_evaluation_age_seconds, and wallet proof validation enforces oid4vci.proof.max_age_seconds, so the sentence denied controls that run. Same correction already applied to the known-limitations page; the paragraph's actual claim is unchanged, since none of those controls bound the age of the observation behind a claim. Signed-off-by: Jeremi Joslin --- products/notary/docs/notary-capability-matrix.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/products/notary/docs/notary-capability-matrix.md b/products/notary/docs/notary-capability-matrix.md index 1c1f9fb3d..44b653a30 100644 --- a/products/notary/docs/notary-capability-matrix.md +++ b/products/notary/docs/notary-capability-matrix.md @@ -81,11 +81,15 @@ 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 only -Notary freshness control is the federation profile's -`max_claim_result_age_seconds`, which bounds the age of a Relay consultation -result, while Registry Relay owns snapshot freshness through -`max_snapshot_age_ms`. +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 From aa8f3db473b37fe9bceb3a763613462c5b3ee413 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 15:44:53 +0700 Subject: [PATCH 16/17] fix(docs): resolve CSS rules, shorthand hex, and tspan fills before scoring Three ways the contrast gate scored a color the reader never sees. A fill attribute is a presentation attribute, not an inline style, so a class rule outranks it; reading the attribute first let rendered low-contrast text pass. A tspan paints the glyphs it wraps, so a fill it declares is what the reader sees, and the tokenizer never visited one. Shorthand hex was left unexpanded, so fill="#fff" missed the reverse-text test and was scored 1:1 against a canvas it never sits on, which would reject a legible diagram. A tspan without its own fill inherits the fill its parent text already resolved, so it is skipped rather than rescored or counted unresolved. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-svg-a11y.mjs | 38 ++++++++---- docs/site/scripts/check-svg-a11y.test.mjs | 73 +++++++++++++++++++++++ 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/docs/site/scripts/check-svg-a11y.mjs b/docs/site/scripts/check-svg-a11y.mjs index b6846b047..b5e9eb2e3 100644 --- a/docs/site/scripts/check-svg-a11y.mjs +++ b/docs/site/scripts/check-svg-a11y.mjs @@ -42,12 +42,18 @@ export function isScoreableColor(value) { return SCOREABLE_HEX_RE.test(value); } -// Hex spellings are lowercased so `#FFF` and `#fff` dedupe to one entry. +// 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) ? value.toLowerCase() : value; + return isScoreableColor(value) ? expandHex(value.toLowerCase()) : value; } function expandHex(hex) { @@ -128,12 +134,12 @@ function attrValue(tag, name) { return match ? match[1] : null; } -// Resolves the effective fill color of every element in an SVG, -// walking ancestry and class-based fills from a `; + 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), []); +}); From 88d3a2df852cfbcf01c911622af8f25d108f6899 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 1 Aug 2026 16:02:08 +0700 Subject: [PATCH 17/17] fix(docs): honor inline style fills and require an adjacent backdrop An inline style attribute is the strongest author-level declaration, so a low-contrast style="fill:..." could hide behind a legible class rule or fill attribute. It now leads the precedence chain: inline style, class rule, presentation attribute, inherited group fill. Reverse text tracked a single last-shape-seen carried forward for the whole document, so an unrelated earlier swatch could stand in as the backdrop for a stray white label anywhere later. A shape is now claimed only by the element that directly follows it, and consumed when claimed. A tspan reuses the backdrop its enclosing text claimed, since it sits on the same shape. Both diagram labels that use reverse text directly follow their chip, so the corpus passes unchanged; a label that does not will now be reported. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-svg-a11y.mjs | 47 +++++++++++++++++++---- docs/site/scripts/check-svg-a11y.test.mjs | 45 ++++++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/docs/site/scripts/check-svg-a11y.mjs b/docs/site/scripts/check-svg-a11y.mjs index b5e9eb2e3..476e49c26 100644 --- a/docs/site/scripts/check-svg-a11y.mjs +++ b/docs/site/scripts/check-svg-a11y.mjs @@ -134,6 +134,19 @@ function attrValue(tag, name) { return match ? match[1] : null; } +// 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 and element in an // SVG, walking ancestry and class-based fills from a + 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/); +});