diff --git a/.changeset/fix-governance-phase-intent.md b/.changeset/fix-governance-phase-intent.md new file mode 100644 index 0000000000..c525617c57 --- /dev/null +++ b/.changeset/fix-governance-phase-intent.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +Add `intent` to the `governance-phase` enum so a buyer-side `check_governance` call can express the intent-phase check the campaign-governance spec already mandates (the produced token MUST carry `phase: "intent"`). Previously the enum was `[purchase, modification, delivery]`, so an intent check silently defaulted to `purchase` and the intent/execution token separation was unenforceable at the schema layer. Fixes audit finding T1-4 (NS-GOV-001); guarded by `tests/governance-phase-enum.test.cjs`. diff --git a/.github/workflows/normative-guarantees.yml b/.github/workflows/normative-guarantees.yml new file mode 100644 index 0000000000..9b64af4a63 --- /dev/null +++ b/.github/workflows/normative-guarantees.yml @@ -0,0 +1,54 @@ +name: Normative Guarantees + +# Anti-drift gate for security-relevant normative guarantees. +# Unlike check-testable-snippets (informational, diff-only), this workflow is +# BLOCKING and runs whole-corpus. See specs/spec-anti-drift.md. +# +# SECURITY: the doctest lane executes PR-authored code (see scripts/run-doctests.cjs). +# This job MUST NOT be given secrets and MUST NOT be converted to +# `pull_request_target` — it runs under `pull_request`, so a fork PR gets a +# read-only, secret-less token. run-doctests.cjs additionally scrubs the child +# environment and refuses to run shell blocks; the residual exposure is +# compute-only, the same envelope as the existing snippet lane. + +on: + pull_request: + branches: [main] + paths: + # Prose the doctest lane scans: + - 'docs/**/*.md' + - 'docs/**/*.mdx' + - 'specs/**/*.md' + # The registry and its linter: + - 'static/registry/normative-statements/**' + - 'scripts/check-normative-coverage.cjs' + - 'scripts/run-doctests.cjs' + # Enforcement targets referenced by enforced_by (so deleting one re-runs the gate): + - 'static/schemas/source/enums/**' + - 'tests/governance-phase-enum.test.cjs' + - '.github/workflows/normative-guarantees.yml' + +permissions: + contents: read + +jobs: + normative-guarantees: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Doctest lane (deterministic, offline) + run: npm run test:doctests + + - name: Schema-invariant guards + run: npm run test:governance-phase-enum + + - name: Normative-statement coverage (registry well-formed; no enforced statement lost) + # One-way floor: counts ENFORCED statements, so adding a `gap` never fails. + # Bump the floor in the same PR that flips a registry row gap -> enforced. + run: node scripts/check-normative-coverage.cjs --min-enforced 3 diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 89f98bc16d..d0a440f523 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -1681,7 +1681,7 @@ export async function verifyAdcpRequestSignature(req: Request, ctx: { const { jwk } = await ctx.resolveJwk(parsed.keyid!); // throws _key_unknown // 8: key purpose const j = jwk as any; - if (j.use !== "sig" || !Array.isArray(j.key_ops) || !j.key_ops.includes("verify") || j.example_use !== "request-signing") { + if (j.use !== "sig" || !Array.isArray(j.key_ops) || !j.key_ops.includes("verify") || j.adcp_use !== "request-signing") { throw new RequestSignatureError("request_signature_key_purpose_invalid"); } // 9: revocation (BEFORE crypto verify) diff --git a/package.json b/package.json index 4b473e5d97..b9819c0a1b 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "test:extensions": "node tests/extension-fields.test.cjs", "test:extension-schemas": "node tests/extension-schemas.test.cjs", "test:snippets": "node tests/snippet-validation.test.cjs", + "test:doctests": "node scripts/run-doctests.cjs", + "test:governance-phase-enum": "node tests/governance-phase-enum.test.cjs", + "check:normative-coverage": "node scripts/check-normative-coverage.cjs", "test:json-schema": "node tests/json-schema-validation.test.cjs", "test:adagents-catalog-only": "node --test --test-force-exit --test-timeout=30000 tests/adagents-catalog-only.test.cjs", "test:canonical-fixtures": "node tests/canonical-fixture-validation.test.cjs", diff --git a/scripts/check-normative-coverage.cjs b/scripts/check-normative-coverage.cjs new file mode 100644 index 0000000000..ba84cdd519 --- /dev/null +++ b/scripts/check-normative-coverage.cjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * Normative-statement registry linter + coverage dashboard. + * + * The registry (static/registry/normative-statements/index.json) catalogs every + * security-relevant MUST/SHOULD and every security claim in the spec, tagged with + * where it is enforced. This script is the enforcement point that keeps the + * registry honest — schema validation alone can't tell "claims to be enforced" + * from "actually has a passing check behind it". + * + * Fails CI (exit 1) when: + * - an entry is malformed (missing required fields, bad id/source shape) + * - an entry has status=enforced but an empty enforced_by/demonstrated_by + * - an enforced_by/demonstrated_by path does not exist on disk + * - the enforced-fraction regresses below the ratchet floor (see --ratchet) + * + * Does NOT fail on status=gap with no enforcement — that is the honest starting + * state the registry exists to make visible. + * + * See specs/spec-anti-drift.md. + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const REGISTRY = path.join(ROOT, 'static/registry/normative-statements/index.json'); + +const args = process.argv.slice(2); +const asJson = args.includes('--json'); +const ratchetIdx = args.indexOf('--ratchet'); +const ratchet = ratchetIdx !== -1 ? Number.parseFloat(args[ratchetIdx + 1]) : null; +// --min-enforced is the one-way ratchet: it counts ENFORCED statements, not a +// fraction, so honestly cataloguing a new `gap` (which grows the denominator) +// never fails the gate — only losing an enforced statement does. +const minIdx = args.indexOf('--min-enforced'); +const minEnforced = minIdx !== -1 ? Number.parseInt(args[minIdx + 1], 10) : null; + +const ID_RE = /^NS-[A-Z]+-[0-9]{3}$/; +const SOURCE_RE = /^(docs|static)\/.+:[0-9]+$/; +const LINK_RE = /^(docs|static|scripts|tests|server|specs)\/.+/; +const LEVELS = new Set(['MUST', 'MUST NOT', 'SHOULD', 'SHOULD NOT', 'MAY']); +const LAYERS = new Set([ + 'schema', 'lint', 'conformance-vector', 'executable-snippet', + 'operator-responsibility', 'unenforceable-by-design', 'UNASSIGNED', +]); +const STATUSES = new Set(['enforced', 'gap', 'operator', 'by-design']); +const CLASSES = new Set([ + 'signing', 'governance', 'identity', 'isolation', 'idempotency', + 'privacy', 'creative', 'signals', 'transport', +]); + +function fail(msg) { + console.error(`✗ ${msg}`); + process.exitCode = 1; +} + +function linkExists(rel) { + // A link may point at file:line or a plain path; strip a trailing :NN if present. + const filePart = rel.replace(/:[0-9]+$/, ''); + const resolved = path.resolve(ROOT, filePart); + // Defense in depth: refuse to probe outside the repo even if a link slips a + // `..` past LINK_RE (the prefix anchor already blocks leading `..`). + if (resolved !== ROOT && !resolved.startsWith(ROOT + path.sep)) return false; + return fs.existsSync(resolved); +} + +if (!fs.existsSync(REGISTRY)) { + fail(`registry not found at ${path.relative(ROOT, REGISTRY)}`); + process.exit(1); +} + +let entries; +try { + entries = JSON.parse(fs.readFileSync(REGISTRY, 'utf8')); +} catch (e) { + fail(`registry is not valid JSON: ${e.message}`); + process.exit(1); +} +if (!Array.isArray(entries)) { + fail('registry must be a JSON array of entries'); + process.exit(1); +} + +const seenIds = new Set(); +let hardErrors = 0; + +for (const [i, e] of entries.entries()) { + const where = e && e.id ? e.id : `entry[${i}]`; + const bad = (m) => { fail(`${where}: ${m}`); hardErrors++; }; + + if (!e || typeof e !== 'object') { bad('not an object'); continue; } + if (!ID_RE.test(e.id || '')) bad(`id must match ${ID_RE}`); + if (seenIds.has(e.id)) bad('duplicate id'); else seenIds.add(e.id); + if (e.type !== 'normative-statement' && e.type !== 'claim') bad('type must be normative-statement|claim'); + if (!CLASSES.has(e.class)) bad(`class invalid (got ${JSON.stringify(e.class)})`); + if (typeof e.statement !== 'string' || e.statement.length < 10) bad('statement too short/missing'); + if (!SOURCE_RE.test(e.source || '')) bad(`source must be file:line (got ${JSON.stringify(e.source)})`); + if (!LEVELS.has(e.level)) bad('level invalid'); + if (!LAYERS.has(e.enforcement_layer)) bad('enforcement_layer invalid'); + if (!STATUSES.has(e.status)) bad('status invalid'); + + const links = e.type === 'claim' ? e.demonstrated_by : e.enforced_by; + const linkField = e.type === 'claim' ? 'demonstrated_by' : 'enforced_by'; + + if (e.status === 'enforced') { + if (!Array.isArray(links) || links.length === 0) { + bad(`status=enforced requires a non-empty ${linkField}`); + } else { + for (const l of links) { + if (!LINK_RE.test(l)) bad(`${linkField} path has bad shape: ${l}`); + else if (!linkExists(l)) bad(`${linkField} points at a missing file: ${l}`); + } + } + } +} + +// Coverage dashboard +const byStatus = {}; +const byClass = {}; +for (const e of entries) { + byStatus[e.status] = (byStatus[e.status] || 0) + 1; + byClass[e.class] = byClass[e.class] || { total: 0, enforced: 0 }; + byClass[e.class].total++; + if (e.status === 'enforced') byClass[e.class].enforced++; +} +const total = entries.length; +const enforced = byStatus.enforced || 0; +const gaps = byStatus.gap || 0; +const enforcedFraction = total ? enforced / total : 1; + +if (asJson) { + console.log(JSON.stringify({ total, byStatus, byClass, enforcedFraction, hardErrors }, null, 2)); +} else { + console.log('\nNormative-statement coverage'); + console.log('============================'); + console.log(`total statements : ${total}`); + console.log(`enforced : ${enforced} (${(enforcedFraction * 100).toFixed(1)}%)`); + console.log(`gaps (drift) : ${gaps}`); + console.log(`operator/by-design: ${(byStatus.operator || 0) + (byStatus['by-design'] || 0)}`); + console.log('\nby class (enforced/total):'); + for (const [cls, c] of Object.entries(byClass).sort()) { + console.log(` ${cls.padEnd(12)} ${c.enforced}/${c.total}`); + } + if (gaps > 0) { + console.log('\nopen gaps:'); + for (const e of entries.filter((x) => x.status === 'gap')) { + console.log(` ${e.id} ${e.source} ${e.finding_ref || ''}`); + } + } +} + +if (ratchet !== null && enforcedFraction < ratchet) { + fail(`enforced fraction ${(enforcedFraction * 100).toFixed(1)}% is below ratchet floor ${(ratchet * 100).toFixed(1)}%`); +} +if (minEnforced !== null && enforced < minEnforced) { + fail(`enforced count ${enforced} is below the one-way floor of ${minEnforced} — an enforced statement was lost`); +} + +if (process.exitCode === 1) { + console.error(`\n✗ normative-coverage check failed (${hardErrors} entry error(s))`); +} else if (!asJson) { + console.log('\n✓ registry is well-formed; enforced entries all have live links'); +} diff --git a/scripts/run-doctests.cjs b/scripts/run-doctests.cjs new file mode 100644 index 0000000000..e3bbacd7ae --- /dev/null +++ b/scripts/run-doctests.cjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +/** + * Deterministic unit-doctest lane. + * + * The existing snippet lane (tests/snippet-validation.test.cjs) routes marked + * blocks to a LIVE agent — the right tool for wire examples, the wrong tool for + * pure-logic normative snippets (verifier functions, canonicalization, constant + * lists) where the failure the audit found (a verifier testing a field that does + * not exist) is offline and deterministic. + * + * This lane introduces a distinct marker: a fenced block tagged `doctest` is a + * SELF-CONTAINED, OFFLINE program that MUST run to exit 0. It asserts its own + * invariant and throws on violation. No network, no auth token, no live agent. + * + * ```javascript doctest + * function keyPurposeOk(jwk) { return jwk.adcp_use === 'request-signing'; } + * if (!keyPurposeOk({ adcp_use: 'request-signing' })) throw new Error('...'); + * console.log('ok'); + * ``` + * + * Fails CI (exit 1) if any doctest block exits non-zero. This is the lane that + * would have caught the `example_use` reference-verifier bug the moment the page + * was scanned. See specs/spec-anti-drift.md. + * + * Usage: + * node scripts/run-doctests.cjs # scan docs/ + specs/ + * node scripts/run-doctests.cjs --list # list doctest blocks, do not run + * node scripts/run-doctests.cjs --json + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { spawnSync } = require('child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const SCAN_DIRS = ['docs', 'specs']; +const args = process.argv.slice(2); +const listOnly = args.includes('--list'); +const asJson = args.includes('--json'); +const TIMEOUT_MS = 15000; + +// language -> how to run a temp file. Return null to skip (interpreter absent +// or language not permitted). `bash`/`sh` are intentionally NOT runnable: a +// doctest is an offline logic assertion, a shell block is the easiest arbitrary- +// command vector, and this lane runs PR-authored code in CI. +function runner(lang) { + const l = lang.toLowerCase(); + if (l === 'javascript' || l === 'js') return { ext: '.mjs', cmd: (f) => ['node', [f]] }; + if (l === 'typescript' || l === 'ts') return { ext: '.ts', cmd: (f) => ['npx', ['--no-install', 'tsx', f]] }; + if (l === 'python' || l === 'py') return { ext: '.py', cmd: (f) => ['python3', [f]] }; + return null; +} + +function walk(dir, out) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, out); + else if (/\.(md|mdx)$/.test(entry.name)) out.push(full); + } +} + +function extractDoctests(file) { + const content = fs.readFileSync(file, 'utf8'); + const re = /```(\w+)([^\n]*)\n([\s\S]*?)```/g; + const blocks = []; + let m; + while ((m = re.exec(content)) !== null) { + const [, language, metadata, code] = m; + if (!/\bdoctest\b/.test(metadata)) continue; + blocks.push({ + file: path.relative(ROOT, file), + language, + code, + line: content.slice(0, m.index).split('\n').length, + }); + } + return blocks; +} + +const files = []; +for (const d of SCAN_DIRS) walk(path.join(ROOT, d), files); + +const doctests = []; +for (const f of files) doctests.push(...extractDoctests(f)); + +if (listOnly) { + for (const d of doctests) console.log(`${d.file}:${d.line} (${d.language})`); + console.log(`\n${doctests.length} doctest block(s)`); + process.exit(0); +} + +const results = []; +const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'adcp-doctest-')); + +// Scrubbed environment: a doctest is offline logic, so it never needs inherited +// secrets. Passing only PATH/HOME means that even if this CI job is later given +// secrets, an executed block cannot read them. See normative-guarantees.yml. +const CHILD_ENV = { PATH: process.env.PATH, HOME: process.env.HOME }; + +try { + for (const d of doctests) { + const where = `${d.file}:${d.line}`; + const r = runner(d.language); + if (!r) { + results.push({ where, status: 'skip', reason: `no runner for ${d.language}` }); + continue; + } + const tmp = path.join(tmpBase, `dt-${results.length}${r.ext}`); + fs.writeFileSync(tmp, d.code); + const [cmd, cmdArgs] = r.cmd(tmp); + const proc = spawnSync(cmd, cmdArgs, { encoding: 'utf8', timeout: TIMEOUT_MS, env: CHILD_ENV }); + if (proc.error && proc.error.code === 'ENOENT') { + results.push({ where, status: 'skip', reason: `interpreter '${cmd}' not found` }); + continue; + } + if (proc.status === 0) { + results.push({ where, status: 'pass' }); + } else { + const timedOut = proc.signal === 'SIGTERM' || (proc.error && proc.error.code === 'ETIMEDOUT'); + results.push({ + where, + status: 'fail', + code: timedOut ? `timeout after ${TIMEOUT_MS}ms` : proc.status, + stderr: (proc.stderr || '').trim().split('\n').slice(-6).join('\n'), + }); + } + } +} finally { + fs.rmSync(tmpBase, { recursive: true, force: true }); +} + +const pass = results.filter((r) => r.status === 'pass').length; +const fail = results.filter((r) => r.status === 'fail'); +const skip = results.filter((r) => r.status === 'skip').length; + +if (asJson) { + console.log(JSON.stringify({ total: results.length, pass, fail: fail.length, skip, failures: fail }, null, 2)); +} else { + console.log(`\nDoctest lane: ${pass} passed, ${fail.length} failed, ${skip} skipped (of ${results.length})`); + for (const f of fail) { + console.error(`\n✗ ${f.where} exited ${f.code}`); + if (f.stderr) console.error(f.stderr.split('\n').map((l) => ` ${l}`).join('\n')); + } + if (skip > 0) { + for (const s of results.filter((r) => r.status === 'skip')) { + console.log(` ⊘ ${s.where} — ${s.reason}`); + } + } +} + +if (fail.length > 0) { + console.error(`\n✗ doctest lane failed (${fail.length} block(s))`); + process.exit(1); +} +if (!asJson) console.log('\n✓ all doctest blocks passed'); diff --git a/specs/spec-anti-drift.md b/specs/spec-anti-drift.md new file mode 100644 index 0000000000..052763bb3c --- /dev/null +++ b/specs/spec-anti-drift.md @@ -0,0 +1,221 @@ +# AdCP RFC: Anti-drift architecture for normative guarantees + +## Summary + +The security audit of AdCP-as-a-protocol surfaced ~25 findings. Almost none were +crypto-design failures. The recurring shape was: **a guarantee stated in prose, +diluted at the layer that actually enforces it** — a `MUST` with no schema +backing, a reference snippet that was never executed, a fact hand-copied into +four documents that drifted apart. + +This RFC proposes the systemic fix. The organizing principle is one sentence: + +> Make the enforcement layer the source of truth, and generate or lint the prose +> *from* it. Push every check to the cheapest deterministic layer that can hold +> it, and reserve the governing agent for the judgment calls that genuinely +> require reading intent. + +It adds two primitives that do not exist today — a **normative-statement +registry** and a **claims ledger** — turns the existing (but non-blocking) +snippet/coverage tooling into gates, and defines the security agent's role as a +*test-factory and red-teamer*, not a runtime gate. + +## Motivation: why the audit findings slipped through + +The repo already has substantial anti-drift tooling. The audit findings survived +because of specific, fixable gaps in how that tooling is wired — not because it +is absent. + +| Audit finding | Why existing tooling missed it | +|---|---| +| Reference verifier tests `j.example_use` (a field that does not exist) instead of `j.adcp_use` — dead code that silently disables key-purpose separation (`L1/security.mdx:1684`) | `L1/security.mdx` is **not** marked `testable: true`, so its snippets are never extracted or executed. `check-testable-snippets.cjs` is **non-blocking** ("Always exit 0 — informational"), **diff-only** (changed files in a PR, never the whole corpus), and only checks whether a block is *marked* testable — it never verifies a marked block is correct. | +| `EXCLUDED_FROM_HASH` reference constant omits a member of its own normative closed list (`L1/security.mdx:498`) | Same page-not-testable gap. Also: the snippet test lane is **integration-gated** (needs a live agent + auth token) — the wrong tool for a pure-logic constant/verifier bug, which needs a deterministic unit-doctest. | +| Governance `phase` enum cannot express `intent`, yet the JWS profile treats `intent` as load-bearing (`enums/governance-phase.json` vs `L1/security.mdx:611`) | No linter asserts that every phase/claim value named in prose appears in its enum. Prose and schema are maintained by hand, independently. | +| "Three categories" (`known-limitations.mdx:49`, `trust.mdx:119`) vs **four** in `sync-plans-request.json` and `registry/policies/eu_ai_act_annex_iii.json` (adds `pharmaceutical_advertising`); prose also describes an `authority_level` field that no longer exists | The category list is authored by hand in 4+ places. No single source of truth; no lint that the doc tables match the schema/registry. | +| `signing_keys` pin is `MUST` in prose but optional in schema; `relationship`↔`delegation_type` equality is prose-only | No registry of normative statements tagged by enforcement layer, so "prose `MUST` with no schema/vector backing" is invisible and uncountable. | +| verify_brand_claim / rights_constraint / content-digest verifier bindings missing | Conformance vectors test that valid inputs pass; there is no requirement that every verification control ship a paired *negative* vector (`expected: reject`) for the forged input. | +| TMP "buyer cannot tie a user to a page" is falsified by the intended impression-pixel flow | No claims ledger requiring each "defends against X" claim to link a demonstration. Writing that demonstration is where the gap is found. | + +The through-line: **truth lives in prose; enforcement lives elsewhere; nothing +keeps them in sync.** + +## Architecture: cheapest deterministic layer holds each check + +Every check wants the lowest, most deterministic layer that can hold it. The +governing agent is the *last* resort, not the first — a non-deterministic +reviewer is itself a drift source. + +| Drift class | Enforcement layer | Owner | +|---|---|---| +| Prose ↔ reference-code | **Executable snippets** — extract & run every normative block in CI (unit-doctest lane for pure logic; integration lane for wire calls) | `check-testable-snippets` (made blocking + whole-corpus) | +| Prose ↔ schema | **Schema is source of truth** + linter that every prose `MUST` has a schema/enum backing | new `check-normative-coverage.cjs` | +| Cross-document fact drift | **Single source per fact; generate the doc tables** (or lint that they match) | schema/registry + remark generator | +| Missing verifier-side binding | **Negative/attack test vectors** — `expected: reject` on the forged input, paired with every verification control | conformance suite (storyboards + test-vectors) | +| Prose-`MUST` not conformance-tested | **Conformance suite run against SDKs + training agent** as fixtures | `training-agent-storyboards` extended to SDKs + negative vectors | +| Claim overstatement | **Claims ledger** + periodic adversarial pass (agent + human) | security agent + release red-team | + +Only the last row genuinely needs judgment. Everything above it is mechanically +checkable and must never reach the agent's desk twice. + +## New primitive 1: the normative-statement registry + +Model: `scripts/check-registry-completeness.cjs`, whose own comment states the +pattern — *"Schema validation can't tell X from Y. CI is the enforcement point."* + +Every `MUST` / `MUST NOT` / `SHOULD` in the security-relevant spec gets a stable +ID and an entry declaring **where it is enforced**. This makes the invisible +countable: + +- *N* normative statements, *M* automatically enforced, *K* explicitly punted to + operators, *P* that **claim enforcement but have no linked check.** + +That last bucket is the drift surface. The `example_use`, phase-enum, and +`check_id` findings are each exactly one row in it. + +Files (this RFC ships the seed): + +- `static/registry/normative-statements/schema.json` — entry schema (draft-07, + matching repo convention). +- `static/registry/normative-statements/index.json` — seeded with the audit + findings as real entries, each tagged `status: gap` with a `finding_ref`. +- `scripts/check-normative-coverage.cjs` — validates entries, computes the + coverage dashboard, and **fails CI** on any entry with `status: enforced` whose + `enforced_by` link is empty or points at a nonexistent file. (A `gap` entry is + allowed to have no enforcement — that is the honest starting state.) + +Entry shape (see the schema for the full contract): + +```json +{ + "id": "NS-SIG-001", + "type": "normative-statement", + "class": "signing", + "level": "MUST", + "statement": "A request-verifier MUST reject a JWK whose adcp_use is not 'request-signing'.", + "source": "docs/building/by-layer/L1/security.mdx:1237", + "enforcement_layer": "executable-snippet", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T1-1", + "notes": "Reference verifier at :1684 tests non-existent field 'example_use'." +} +``` + +The coverage number becomes the honest answer to *"how do we know we aren't +drifting?"* — you point at it, and the invariant is that it only moves up. + +### Worked example: the doctest that would have caught `example_use` + +The `example_use` bug (finding T1-1) survived because the reference verifier was +never executed. A `doctest`-marked block is self-contained and offline — the +doctest lane runs it on every scan and fails CI if it throws. This block asserts +the exact invariant the bug violated (key-purpose separation must test +`adcp_use`, not a field that does not exist): + +```javascript doctest +// Regression guard for audit finding T1-1 (NS-SIG-001). +// A request verifier must accept a request-signing key and reject a key +// published for a different purpose. The bug tested `example_use` (undefined), +// so the good key failed and the check was quietly disabled. +function keyPurposeOk(jwk) { + return jwk.use === 'sig' + && Array.isArray(jwk.key_ops) && jwk.key_ops.includes('verify') + && jwk.adcp_use === 'request-signing'; // NOT jwk.example_use +} +const requestKey = { use: 'sig', key_ops: ['verify'], adcp_use: 'request-signing' }; +const governanceKey = { use: 'sig', key_ops: ['verify'], adcp_use: 'governance-signing' }; +if (!keyPurposeOk(requestKey)) throw new Error('valid request-signing key must pass'); +if (keyPurposeOk(governanceKey)) throw new Error('governance key must NOT verify a request'); +console.log('T1-1 guard ok'); +``` + +Had the shipped reference used `example_use` here, `keyPurposeOk(requestKey)` +would return `false`, the first assertion would throw, and this block would fail +CI — turning a silently-disabled control into a red build. + +## New primitive 2: the claims ledger + +The hardest class (TMP structural-privacy overstatement) is about whether a prose +claim exceeds what the mechanism delivers — not mechanically checkable, but +*forceable at authoring time*. + +Every security-facing claim — "defends against X", "prevents Y", "structurally +separate" — gets an ID and a **linked demonstration**: either a test vector that +exhibits the defense, or a threat-model entry that scopes it. A claim with no +linked artifact is a lint failure. + +This is what would have caught the TMP finding: forcing the "buyer cannot tie a +user to a page" claim to point at a passing test means *writing* that test — +and the test fails on the impression-pixel re-correlation. The gap is found at +authoring time, not in an audit a year later. + +Claims share the registry file (`type: "claim"`) with a `demonstrated_by` field +in place of `enforced_by`. + +## The security agent's role: factory, not gate + +The governing agent (Spec Guardian / security specialism) should **not** be the +enforcement for anything mechanically checkable — that is drift with extra steps, +because the agent is non-deterministic and would itself vary run to run. Its two +jobs: + +1. **Red-team on a cadence** — run the adversarial passes that *find* new gaps + (this audit, repeated per release and per new surface). +2. **Route every finding to the cheapest deterministic layer** — its output is + mostly PRs that add schema constraints, lint rules, and test vectors, plus new + registry rows. The standing invariant it holds about itself: *anything I find + that is mechanically checkable, I encode as a check so I never find it again.* + +This fits the class-based routing in the Spec Guardian secretariat proposal: +security becomes a routing class whose decision-records land as registry entries +and CI checks. + +## Sequencing + +Fix and enforcement ship together — never fix a finding without leaving behind +the check that would have caught it. + +- **Phase 0 — instrument first.** Land the normative-statement registry (seeded), + the coverage linter, and the unit-doctest lane. Mark `L1/security.mdx` and the + other security-normative pages `testable: true`. This establishes an honest + baseline coverage number *before* any fix, so every fix shows up as a gain. +- **Phase 1 — fix Tier 1 with enforcement attached.** e.g. fix `example_use` + + the doctest that would have caught it; add `intent` to the enum + a lint that + every JWS phase value appears in the enum; make `check_id` schema-required + a + negative vector; add the verifier bindings + paired attack vectors. Each PR + moves a registry row from `gap` to `enforced`. +- **Phase 2 — backfill Tier 2** by promoting prose `MUST`s into schema where + possible and into conformance vectors where not, graded against SDKs + the + training agent (already at 32/55 storyboards; extend to SDKs and negative + vectors). +- **Phase 3 — claims ledger + standing adversarial pass** for the classes that + stay judgment-bound. + +## Changes to existing tooling + +- `check-testable-snippets.cjs`: make blocking; add a whole-corpus mode + (not diff-only) for security-normative pages; split into a **unit-doctest lane** + (deterministic, no live agent — for verifier logic, canonicalization, constant + lists) and the existing integration lane. +- `docs-example-coverage.cjs`: promote from "publish trends" to a **gate with a + ratchet** on the security-normative page set (coverage may not decrease). +- Generate the regulated-category doc tables from the schema/`registry/policies` + so "three vs four categories" cannot drift; delete prose references to removed + fields (`authority_level`) as part of the same generator. + +## Success metric + +**The same finding must never appear in two consecutive audits.** If it does, the +fix went into prose again instead of into a layer. The registry coverage number +is the leading indicator; the repeat-finding count is the lagging one. + +## References + +- Security audit (2026-07): `specs/` companion / issue #3925 (Trust, Identity, + and Governance master issue). +- `scripts/check-registry-completeness.cjs` — the registry-linter pattern this + RFC generalizes. +- `scripts/check-testable-snippets.cjs`, `scripts/docs-example-coverage.cjs` — + the snippet/coverage tooling this RFC makes gating. +- Related principle already in the codebase: conformance assertions must have a + normative basis (assert a spec `MUST` or schema-required, never scenario prose). diff --git a/specs/spec-guardian.md b/specs/spec-guardian.md index cb4d2d0b5b..268341dde0 100644 --- a/specs/spec-guardian.md +++ b/specs/spec-guardian.md @@ -262,7 +262,7 @@ outcome: - **Overturn rate per class** = decisions later reversed by a human / total. Editorial autonomy is already de facto (Argus approves ~85% of PRs). Normative lazy-consensus starts *shadow-mode*: memos posted, but Brian ratifies manually for 4 weeks; if - overturn <5%, flip to true lazy consensus. + overturn <5%, flip to true lazy consensus. - **KPIs on the weekly report**: median time-to-decision, Brian-touches per week (target: trending to ~3/week), info-request response rate, decision-record coverage (% of merged Normative+ changes with a record). diff --git a/static/registry/normative-statements/index.json b/static/registry/normative-statements/index.json new file mode 100644 index 0000000000..3f6236a545 --- /dev/null +++ b/static/registry/normative-statements/index.json @@ -0,0 +1,197 @@ +[ + { + "id": "NS-SIG-001", + "type": "normative-statement", + "class": "signing", + "level": "MUST", + "statement": "A request verifier MUST reject a JWK whose adcp_use is not 'request-signing' (key-purpose separation).", + "source": "docs/building/by-layer/L1/security.mdx:1237", + "enforcement_layer": "executable-snippet", + "status": "enforced", + "enforced_by": ["specs/spec-anti-drift.md", "scripts/run-doctests.cjs"], + "finding_ref": "audit-2026-07/T1-1", + "notes": "FIXED. Reference verifier now checks the correct JWK field. Regression guarded by a doctest in specs/spec-anti-drift.md run by the offline doctest lane." + }, + { + "id": "NS-SIG-002", + "type": "normative-statement", + "class": "signing", + "level": "MUST", + "statement": "A verify_brand_claim response verifier MUST bind the signing key to the claimed brand_domain via that domain's brand.json before trusting the payload.", + "source": "docs/building/by-layer/L1/security.mdx:895", + "enforcement_layer": "conformance-vector", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T1-2", + "notes": "Tracked in GitHub #5826. Fix: add a verifier step mirroring governance checklist step 13 and a negative conformance vector." + }, + { + "id": "NS-SIG-003", + "type": "normative-statement", + "class": "signing", + "level": "MUST", + "statement": "Signed spend-committing calls MUST cover content-digest so the request body cannot be altered under a valid signature.", + "source": "docs/building/by-layer/L1/security.mdx:1057", + "enforcement_layer": "schema", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T1-7", + "notes": "Tracked in GitHub #5829. Fix: promote content-digest coverage from SHOULD to required for mutating tasks (versioning-sensitive)." + }, + { + "id": "NS-GOV-001", + "type": "normative-statement", + "class": "governance", + "level": "MUST", + "statement": "The governance phase enum MUST include 'intent' distinctly from 'purchase' so intent and execution checks are non-interchangeable at the schema layer.", + "source": "docs/building/by-layer/L1/security.mdx:611", + "enforcement_layer": "schema", + "status": "enforced", + "enforced_by": ["static/schemas/source/enums/governance-phase.json", "tests/governance-phase-enum.test.cjs"], + "finding_ref": "audit-2026-07/T1-4", + "notes": "FIXED. 'intent' added to enums/governance-phase.json; check-governance-request.phase description updated. Guarded by tests/governance-phase-enum.test.cjs asserting all four JWS-profile phases are present. Note: the wire-behaviour MUST that a produced token carries phase 'intent' is a separate statement — see NS-GOV-004." + }, + { + "id": "NS-GOV-002", + "type": "normative-statement", + "class": "governance", + "level": "MUST", + "statement": "report_plan_outcome committed_budget MUST be attested rather than buyer-self-reported, and check_id MUST be schema-required for completed/failed outcomes.", + "source": "docs/governance/campaign/specification.mdx:432", + "enforcement_layer": "schema", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T1-5", + "notes": "Tracked in GitHub #5827. Fix: bind the committed amount to the purchase-phase attestation or require reconciliation; make check_id schema-required." + }, + { + "id": "NS-GOV-003", + "type": "normative-statement", + "class": "governance", + "level": "MUST", + "statement": "Once the revocation list is stale past grace, sellers MUST re-evaluate revocation on every execution-token use, not grandfather previously-verified tokens.", + "source": "docs/building/by-layer/L1/security.mdx:744", + "enforcement_layer": "conformance-vector", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T2-16", + "notes": "Fix: remove the grandfather carve-out for already-verified execution tokens; add a conformance vector for the stale-list path." + }, + { + "id": "NS-GOV-004", + "type": "normative-statement", + "class": "governance", + "level": "MUST", + "statement": "A governance token produced by an intent-phase check MUST carry phase 'intent' and MUST omit media_buy_id; sellers MUST reject an intent token presented for a purchase operation.", + "source": "docs/governance/campaign/specification.mdx:160", + "enforcement_layer": "conformance-vector", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T1-4", + "notes": "Wire-behaviour companion to NS-GOV-001. The enum-presence test does not verify the token actually carries phase 'intent' at runtime; that needs a conformance vector over check_governance output and seller verification." + }, + { + "id": "NS-ID-001", + "type": "normative-statement", + "class": "identity", + "level": "MUST", + "statement": "A brand.json properties[].relationship MUST equal the publisher's adagents.json delegation_type; a mismatch or a stronger-than-granted claim MUST be rejected.", + "source": "docs/trust.mdx:56", + "enforcement_layer": "schema", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T2-8", + "notes": "Fix: elevate the equality from prose to a normative MUST with a defined mismatch/one-sided outcome." + }, + { + "id": "NS-ID-002", + "type": "normative-statement", + "class": "identity", + "level": "MUST", + "statement": "Publishers MUST populate signing_keys for any authorized agent whose delegated scopes include mutating operations.", + "source": "docs/governance/property/adagents.mdx:734", + "enforcement_layer": "schema", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T2-9", + "notes": "Fix: promote signing_keys to schema-required for mutating-scope authorizations (a follow-up is already tracked in the doc). Add a rate-limit/negative-cache carve-out to the unknown-keyid force-refresh rule." + }, + { + "id": "NS-ISO-001", + "type": "normative-statement", + "class": "isolation", + "level": "MUST", + "statement": "Sellers MUST reject unknown top-level request fields on mutating tasks before field-scope evaluation, so FIELD_NOT_PERMITTED is not bypassable.", + "source": "docs/building/by-layer/L1/security.mdx:393", + "enforcement_layer": "lint", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T2-11", + "notes": "Request schemas default additionalProperties:true for envelope portability, so this cannot be flipped at the schema layer. Fix: enforce at the seller-behaviour/conformance layer." + }, + { + "id": "NS-ISO-002", + "type": "normative-statement", + "class": "isolation", + "level": "MUST", + "statement": "Cross-account reads MUST return a generic not-found rather than leak existence across the boundary.", + "source": "docs/building/by-layer/L1/security.mdx:179", + "enforcement_layer": "conformance-vector", + "status": "enforced", + "enforced_by": ["docs/building/by-layer/L1/security.mdx:196"], + "finding_ref": "audit-2026-07/clean", + "notes": "Well-specified in the audit (MUST, two-step pattern, RLS, timing note). Recorded as an enforced baseline; replace enforced_by with the storyboard/vector path once linked." + }, + { + "id": "NS-IDEM-001", + "type": "normative-statement", + "class": "idempotency", + "level": "MUST", + "statement": "Idempotency keys MUST be unguessable so the per-(agent,account) cache cannot be enumerated.", + "source": "docs/building/by-layer/L1/security.mdx:486", + "enforcement_layer": "schema", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T2-17", + "notes": "The unguessability rule is prose-MUST but not backed at the schema layer and is softened by a trusted-agent carve-out. Fix: back it with a schema/entropy constraint and narrow the carve-out." + }, + { + "id": "NS-CRE-001", + "type": "normative-statement", + "class": "creative", + "level": "MUST", + "statement": "A serving party MUST resolve rights_constraint revocation via the rights_agent's authoritative identity, not a buyer-supplied URL alone.", + "source": "docs/media-buy/buyer-attached-inputs.mdx:59", + "enforcement_layer": "conformance-vector", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T1-6", + "notes": "Tracked in GitHub #5828. Fix: require authoritative rights_agent resolution, or sign the revocation-check response." + }, + { + "id": "NS-CRE-002", + "type": "normative-statement", + "class": "creative", + "level": "MUST", + "statement": "Sales agents MUST encode every macro value derived from request-time attacker-influenceable inputs before URL substitution, not only buyer-controlled catalog macros.", + "source": "docs/creative/universal-macros.mdx:269", + "enforcement_layer": "conformance-vector", + "status": "gap", + "enforced_by": [], + "finding_ref": "audit-2026-07/T3-macros", + "notes": "The encoding MUST is scoped only to the catalog-macro class; context macros reach the same URL sinks. Fix: extend the encoding MUST to request-derived macro values." + }, + { + "id": "NS-PRIV-001", + "type": "claim", + "class": "privacy", + "level": "MUST", + "statement": "TMP guarantees a buyer cannot determine that a specific user visited a specific page (structural context/identity separation).", + "source": "docs/trusted-match/privacy-architecture.mdx:64", + "enforcement_layer": "conformance-vector", + "status": "gap", + "demonstrated_by": [], + "finding_ref": "audit-2026-07/T1-3", + "notes": "Tracked in GitHub #5830. The claim needs a linked demonstration vector or a scoped threat-model entry; also decide on a user_token rotation requirement." + } +] diff --git a/static/registry/normative-statements/schema.json b/static/registry/normative-statements/schema.json new file mode 100644 index 0000000000..0e3975ca66 --- /dev/null +++ b/static/registry/normative-statements/schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/registry/normative-statements/schema.json", + "title": "Normative Statement Registry Entry", + "description": "One normative guarantee (a MUST/MUST NOT/SHOULD) or one security claim in the AdCP spec, tagged with where it is enforced. The registry makes 'prose MUST with no enforcement backing' countable. See specs/spec-anti-drift.md.", + "type": "object", + "required": ["id", "type", "class", "level", "statement", "source", "enforcement_layer", "status"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^NS-[A-Z]+-[0-9]{3}$", + "description": "Stable ID, NS--, e.g. NS-SIG-001. Never reused." + }, + "type": { + "type": "string", + "enum": ["normative-statement", "claim"], + "description": "normative-statement = a MUST/SHOULD rule; claim = a 'defends against X' assertion in security-facing docs." + }, + "class": { + "type": "string", + "enum": ["signing", "governance", "identity", "isolation", "idempotency", "privacy", "creative", "signals", "transport"], + "description": "The domain the statement belongs to. Drives the ID prefix and coverage grouping." + }, + "level": { + "type": "string", + "enum": ["MUST", "MUST NOT", "SHOULD", "SHOULD NOT", "MAY"], + "description": "RFC 2119 level as written in the source prose." + }, + "statement": { + "type": "string", + "minLength": 10, + "description": "The normative rule or claim, in one sentence, quoted or closely paraphrased from source." + }, + "source": { + "type": "string", + "pattern": "^(docs|static)/.+:[0-9]+$", + "description": "file:line where the prose lives. Anchors the statement to its authoritative text." + }, + "enforcement_layer": { + "type": "string", + "enum": [ + "schema", + "lint", + "conformance-vector", + "executable-snippet", + "operator-responsibility", + "unenforceable-by-design", + "UNASSIGNED" + ], + "description": "The cheapest deterministic layer that should hold this check. UNASSIGNED = triage pending." + }, + "status": { + "type": "string", + "enum": ["enforced", "gap", "operator", "by-design"], + "description": "enforced = a linked check exists and passes; gap = known-unenforced (drift surface); operator = punted to operators by design; by-design = unenforceable, documented as such." + }, + "enforced_by": { + "type": "array", + "description": "For normative-statements with status=enforced: repo-relative paths to the schema / test / vector that enforces it. Each path is checked to exist by the coverage linter.", + "items": { + "type": "string", + "pattern": "^(docs|static|scripts|tests|server|specs)/.+" + } + }, + "demonstrated_by": { + "type": "array", + "description": "For claims with status=enforced: repo-relative paths to the test vector or threat-model entry that demonstrates the claim holds.", + "items": { + "type": "string", + "pattern": "^(docs|static|scripts|tests|server|specs)/.+" + } + }, + "finding_ref": { + "type": "string", + "description": "Optional pointer to the audit finding that created this entry, e.g. audit-2026-07/T1-1." + }, + "notes": { + "type": "string", + "description": "Optional context: why it is a gap, what the fix is, dependencies." + } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "normative-statement" } } }, + "then": { "not": { "required": ["demonstrated_by"] } } + }, + { + "if": { "properties": { "type": { "const": "claim" } } }, + "then": { "not": { "required": ["enforced_by"] } } + } + ] +} diff --git a/static/schemas/source/enums/governance-phase.json b/static/schemas/source/enums/governance-phase.json index c5a154d06b..4d812696e6 100644 --- a/static/schemas/source/enums/governance-phase.json +++ b/static/schemas/source/enums/governance-phase.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/governance-phase.json", "title": "Governance Phase", - "description": "The phase of the governed action's lifecycle that triggered the governance check.", + "description": "The phase of the governed action's lifecycle that triggered the governance check. 'intent': buyer-side pre-seller check that produces the intent-phase governance_context token attached to a spend-commit request. 'purchase'/'modification'/'delivery': seller-side execution-phase checks over the media buy lifecycle.", "type": "string", - "enum": ["purchase", "modification", "delivery"] + "enum": ["intent", "purchase", "modification", "delivery"] } diff --git a/static/schemas/source/governance/check-governance-request.json b/static/schemas/source/governance/check-governance-request.json index c8e4e36b59..5f0e2a3b1c 100644 --- a/static/schemas/source/governance/check-governance-request.json +++ b/static/schemas/source/governance/check-governance-request.json @@ -45,7 +45,7 @@ }, "phase": { "$ref": "/schemas/enums/governance-phase.json", - "description": "The phase of the governed action's lifecycle. 'purchase': initial commitment (create_media_buy, acquire_rights, activate_signal). 'modification': update to existing commitment. 'delivery': periodic delivery or usage reporting. Defaults to 'purchase' if omitted.", + "description": "The phase of the governed action's lifecycle. 'intent': buyer-side pre-seller check producing the intent-phase token attached to a spend-commit request (the token MUST carry phase 'intent'). 'purchase': initial commitment (create_media_buy, acquire_rights, activate_signal). 'modification': update to existing commitment. 'delivery': periodic delivery or usage reporting. Defaults to 'purchase' if omitted.", "default": "purchase" }, "planned_delivery": { diff --git a/tests/governance-phase-enum.test.cjs b/tests/governance-phase-enum.test.cjs new file mode 100644 index 0000000000..4b6b539eb4 --- /dev/null +++ b/tests/governance-phase-enum.test.cjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * Schema-invariant guard for audit finding T1-4 (NS-GOV-001). + * + * The JWS governance profile (docs/building/by-layer/L1/security.mdx) treats + * `phase` as a load-bearing claim with four values: intent, purchase, + * modification, delivery. The `governance-phase` enum — $ref'd by + * check-governance-request.phase — must carry all four, or the buyer cannot + * express an intent-phase check and the intent/execution token separation + * collapses at the schema layer. + * + * This is the deterministic enforcement that keeps the enum from drifting back + * to a subset of what the prose mandates. See specs/spec-anti-drift.md. + */ + +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ENUM_PATH = path.join(__dirname, '../static/schemas/source/enums/governance-phase.json'); +const REQUIRED_PHASES = ['intent', 'purchase', 'modification', 'delivery']; + +const schema = JSON.parse(fs.readFileSync(ENUM_PATH, 'utf8')); + +for (const phase of REQUIRED_PHASES) { + assert.ok( + Array.isArray(schema.enum) && schema.enum.includes(phase), + `governance-phase enum MUST include "${phase}" (JWS profile phase claim). Got: ${JSON.stringify(schema.enum)}`, + ); +} + +console.log(`✓ governance-phase enum carries all ${REQUIRED_PHASES.length} JWS-profile phases`);