From d38cefdc5b15a11a44e0c0a0517a54993aa6de36 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Thu, 3 Sep 2026 20:04:55 -0400 Subject: [PATCH 1/3] feat(ci): operation-level published-contract drift gate + diff artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published contract at https://api.wave.online/openapi.json is 1.0.0 with 54 paths / 75 operations. This repo's openapi.yaml is 1.1.0 with 209 paths / 230 operations. Nothing measured that gap, and neither existing gate could: the byte-level pin watcher in the serving repo compares repo bytes to a pin and says nothing about what is actually SERVED, and skills-index-coverage.mjs is product-granular and one-directional. Adds a comparator that diffs the two documents at (path, METHOD) granularity in three directions — undocumented-live (served but undocumented, the security-relevant one), unpublished-repo (declared but not served), and shared-drift — plus a scheduled workflow, an allowlist that revalidates itself, and the committed diff artifact. The comparator normalizes the serve-time enrichment first. Without that, all 72 shared operations report a difference for enrichment reasons alone; with it, 4 do. Every rule strips by EXACT SHAPE, so a hand-written 404 that merely happens to be missing upstream still surfaces as drift. Two suppression rules replace what would otherwise be 158 allowlist entries: an operation carrying `x-schema-status: draft` is the spec's own statement that it is not yet a promise, so its absence is reported and not failed on — and promoting it out of draft immediately requires the published contract to carry it. The allowlist is reserved for the three root operations the service adds of its own accord, each with an `expectAbsent: [security]` guard so the exemption lapses the moment that route moves behind auth. Tests: 20 offline, deterministic, zero network. The load-bearing one asserts the enrichment reads as drift WITHOUT normalization and clean WITH it, so a normalizer that silently stopped working could not pass. No change to openapi.yaml, foundation-gate.yml, or any existing file. Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/published-drift-allowlist.json | 35 + .github/scripts/published-drift-compare.mjs | 229 +++ .github/scripts/published-drift-normalize.mjs | 152 ++ .github/scripts/published-drift.mjs | 212 +++ .github/scripts/published-drift.test.mjs | 284 ++++ .../workflows/published-contract-drift.yml | 161 ++ contract-drift.json | 1471 +++++++++++++++++ 7 files changed, 2544 insertions(+) create mode 100644 .github/scripts/published-drift-allowlist.json create mode 100644 .github/scripts/published-drift-compare.mjs create mode 100644 .github/scripts/published-drift-normalize.mjs create mode 100644 .github/scripts/published-drift.mjs create mode 100644 .github/scripts/published-drift.test.mjs create mode 100644 .github/workflows/published-contract-drift.yml create mode 100644 contract-drift.json diff --git a/.github/scripts/published-drift-allowlist.json b/.github/scripts/published-drift-allowlist.json new file mode 100644 index 0000000..b21c78b --- /dev/null +++ b/.github/scripts/published-drift-allowlist.json @@ -0,0 +1,35 @@ +[ + { + "path": "/leaderboard", + "method": "GET", + "direction": "undocumented-live", + "justification": "Gateway-NATIVE root surface, not a /v1 operation this spec describes. The published contract injects it at serve time with an explicit per-operation server override of https://api.wave.online (no /v1 prefix) because it is served pre-auth at the host root. Documenting it here as a /v1 path would state a URL that does not exist. Exempt only while it stays the unauthenticated, public-tagged, read-only surface it is today: the expectAbsent guard below drops this exemption the moment the operation gains a security requirement, which is exactly what the in-flight work to move these three behind operator auth will do.", + "expect": { + "tags.0": "public", + "summary": "Public dispatch-model eval leaderboard (measured rows only: model/task/score/date)" + }, + "expectAbsent": ["security", "requestBody"] + }, + { + "path": "/platform", + "method": "GET", + "direction": "undocumented-live", + "justification": "Gateway-NATIVE root surface, same shape and same reasoning as GET /leaderboard: injected at serve time with a per-operation server override of https://api.wave.online, served pre-auth at the host root rather than under /v1. It reports platform-wide aggregate usage and is one of the three operations being moved behind operator auth; when that lands, the expectAbsent guard below lapses this exemption and the gate demands the operation be described or removed rather than silently re-exempted in its new shape.", + "expect": { + "tags.0": "public", + "summary": "Platform-wide live usage (inference + voice + codec + storage + realtime + clips + captions)" + }, + "expectAbsent": ["security", "requestBody"] + }, + { + "path": "/usage", + "method": "GET", + "direction": "undocumented-live", + "justification": "Gateway-NATIVE root surface injected at serve time, served pre-auth at the host root. NOTE THE COLLISION, which is why this entry is the narrowest of the three: openapi.yaml separately declares POST /usage as an x402-priced draft operation under /v1, so the segment `usage` means two different things in the two documents — a free public GET at the root and a priced POST under /v1. This entry exempts ONLY the published GET; it says nothing about the draft POST, and the two must be reconciled before that draft is promoted. Like its siblings this operation is being moved behind operator auth, and the expectAbsent guard lapses the exemption when it is.", + "expect": { + "tags.0": "public", + "summary": "LIVE inference funnel usage (registry-grounded, GROUP BY model, spend to 8 decimals)" + }, + "expectAbsent": ["security", "requestBody"] + } +] diff --git a/.github/scripts/published-drift-compare.mjs b/.github/scripts/published-drift-compare.mjs new file mode 100644 index 0000000..75aff0a --- /dev/null +++ b/.github/scripts/published-drift-compare.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * published-drift-compare.mjs — the pure comparison. No network, no filesystem, no process.exit. + * Everything the CLI and the tests both need is decided here; `published-drift.mjs` is the shell + * that feeds it documents and turns its verdict into an exit code. + * + * THE THREE DIRECTIONS + * undocumented-live Served by the gateway, absent from openapi.yaml. THE SECURITY-RELEVANT + * DIRECTION: an operation the gateway serves that the published contract does + * not describe is public API nobody reviewed as public API. Always a finding + * unless explicitly allowlisted with a justification AND a live predicate. + * unpublished-repo Declared here, not served. Suppressed — and ONLY suppressed — when the + * operation carries `x-schema-status: draft`, the spec's own statement that + * the shape is a placeholder and not yet a promise to consumers. Promote an + * operation out of draft and this gate immediately requires the published + * contract to carry it. Draft is a lane to publication, not a parking space. + * shared-drift In both, different once the gateway's serve-time enrichment is normalized + * away (see published-drift-normalize.mjs). + * + * WHY `draft` SUPPRESSES RATHER THAN AN ALLOWLIST ENTRY PER OPERATION. There are 158 such + * operations today. Enumerating them in a JSON file would mean every new draft stub carries an + * allowlist edit, the file rots, and the exemptions decay into noise nobody reads. `draft` is a + * property the spec already states about itself, in the operation, next to the schema it + * qualifies — so the gate reads it there. The allowlist is reserved for what no rule covers. + */ +import { isDeepStrictEqual } from 'node:util'; +import { NORMALIZATION_RULES, NO_OBSERVATIONS, normalizePair } from './published-drift-normalize.mjs'; + +export const DIRECTIONS = ['undocumented-live', 'unpublished-repo', 'shared-drift']; + +/** The HTTP verbs an OpenAPI path item may carry; anything else there is metadata, not an operation. */ +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** `{ "GET /render": { path, method, op } }` — one flat index per document, method upper-cased. */ +export function indexOperations(doc) { + const out = new Map(); + for (const [path, item] of Object.entries(doc?.paths ?? {})) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue; + for (const [method, op] of Object.entries(item)) { + if (!HTTP_METHODS.includes(method.toLowerCase())) continue; + if (!op || typeof op !== 'object' || Array.isArray(op)) continue; + out.set(`${method.toUpperCase()} ${path}`, { path, method: method.toLowerCase(), op }); + } + } + return out; +} + +/** Top-level field-by-field difference between two normalized operations. */ +export function diffOperation(repoOp, liveOp) { + const fields = new Set([...Object.keys(repoOp ?? {}), ...Object.keys(liveOp ?? {})]); + const diffs = []; + for (const field of [...fields].sort()) { + if (!isDeepStrictEqual(repoOp?.[field], liveOp?.[field])) { + diffs.push({ field, repo: repoOp?.[field] ?? null, published: liveOp?.[field] ?? null }); + } + } + return diffs; +} + +/** Dotted-path lookup, the same shape skills-index-coverage.mjs uses for its `expect` predicates. */ +export function getPath(obj, path) { + return String(path) + .split('.') + .reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj); +} + +/** + * An allowlist entry is honored ONLY while the live operation still matches every `expect` field + * and carries none of the `expectAbsent` keys — the same idea as skills-index-coverage.mjs's + * allowlistStillApplies(): an exemption that survives on a path match alone outlives its own + * justification. + * + * Two deliberate refinements over that function, both required for OpenAPI operation objects: + * - a `null` expectation matches an ABSENT key as well as a literal null, because for an + * operation "no `security` key" and "`security: null`" make the same claim; + * - `expectAbsent` names keys that must NOT appear. This is what makes an exemption granted for + * an UNAUTHENTICATED public route lapse the moment that route gains a `security` requirement: + * the exemption was reasoned about the unauthenticated shape and must not silently carry over + * to the authenticated one. + */ +export function allowlistStillApplies(entry, liveOp) { + if (!liveOp) return false; + for (const [path, expected] of Object.entries(entry.expect ?? {})) { + const actual = getPath(liveOp, path); + if (expected === null ? actual !== null && actual !== undefined : !isDeepStrictEqual(actual, expected)) return false; + } + for (const key of entry.expectAbsent ?? []) { + if (getPath(liveOp, key) !== undefined) return false; + } + return true; +} + +/** Returns an error string, or null when the allowlist is well-formed. */ +export function validateAllowlist(allowlist) { + if (!Array.isArray(allowlist)) return `allowlist is not an array (got ${typeof allowlist})`; + const seen = new Set(); + for (const e of allowlist) { + if (!e || typeof e.path !== 'string' || typeof e.method !== 'string') + return `allowlist entry needs string path and method: ${JSON.stringify(e)}`; + if (typeof e.justification !== 'string' || e.justification.trim().length < 20) + return `allowlist entry ${e.method} ${e.path} needs a real justification (>=20 chars)`; + if (!DIRECTIONS.includes(e.direction)) + return `allowlist entry ${e.method} ${e.path} has an unknown direction ${JSON.stringify(e.direction)}`; + const key = `${e.direction} ${e.method.toUpperCase()} ${e.path}`; + if (seen.has(key)) return `duplicate allowlist entry for ${key}`; + seen.add(key); + } + return null; +} + +export function compare({ repoDoc, liveDoc, allowlist = [], normalize = true }) { + const repoOps = indexOperations(repoDoc); + const liveOps = indexOperations(liveDoc); + const allowByKey = new Map(allowlist.map((e) => [`${e.direction} ${e.method.toUpperCase()} ${e.path}`, e])); + + const findings = []; + const allowlisted = []; + const lapsedAllowlist = []; + const draftNotYetPublished = []; + const enrichmentObservations = { descriptionsOverwritten: [], operationIdsSynthesized: 0, errorResponsesInjected: 0 }; + + const record = (direction, path, method, entry, detail, liveOp) => { + const allow = allowByKey.get(`${direction} ${method.toUpperCase()} ${path}`); + if (allow) { + if (allowlistStillApplies(allow, liveOp)) { + allowlisted.push({ ...entry, direction, justification: allow.justification }); + return; + } + lapsedAllowlist.push({ + ...entry, + direction, + justification: allow.justification, + reason: "the live operation no longer matches the entry's expect/expectAbsent predicate", + }); + } + findings.push({ ...entry, direction, ...detail }); + }; + + // ── undocumented-live: served, but this repo never described it. ─────────────────────────── + for (const [key, { path, method, op }] of liveOps) { + if (repoOps.has(key)) continue; + record( + 'undocumented-live', + path, + method, + { path, method: method.toUpperCase() }, + { + severity: 'security-relevant', + note: 'served by the gateway but absent from openapi.yaml — public API the published contract does not describe', + publishedSummary: op.summary ?? null, + publishedTags: op.tags ?? null, + publishedSecurity: op.security ?? null, + }, + op, + ); + } + + // ── unpublished-repo: declared here, not served. ─────────────────────────────────────────── + for (const [key, { path, method, op }] of repoOps) { + if (liveOps.has(key)) continue; + const entry = { + path, + method: method.toUpperCase(), + operationId: op.operationId ?? null, + xSchemaStatus: op['x-schema-status'] ?? null, + xPriceModel: op['x-price']?.model ?? null, + xSkillUrl: op['x-skill-url'] ?? null, + }; + if (op['x-schema-status'] === 'draft') { + draftNotYetPublished.push(entry); + continue; + } + record( + 'unpublished-repo', + path, + method, + entry, + { + severity: 'contract-ahead', + note: 'declared in openapi.yaml without x-schema-status: draft, but the published contract does not serve it — either the gateway pin is behind or the operation was promoted before it shipped', + }, + null, + ); + } + + // ── shared-drift: in both, different after normalization. ────────────────────────────────── + for (const [key, { path, method, op: repoOp }] of repoOps) { + const liveEntry = liveOps.get(key); + if (!liveEntry) continue; + const { repo, live, observations } = normalize + ? normalizePair(repoOp, liveEntry.op, path, method) + : { repo: repoOp, live: liveEntry.op, observations: NO_OBSERVATIONS }; + if (observations.descriptionOverwritten) enrichmentObservations.descriptionsOverwritten.push(`${method.toUpperCase()} ${path}`); + if (observations.strippedOperationId) enrichmentObservations.operationIdsSynthesized++; + enrichmentObservations.errorResponsesInjected += observations.strippedErrorCodes.length; + + const differences = diffOperation(repo, live); + if (differences.length === 0) continue; + record('shared-drift', path, method, { path, method: method.toUpperCase() }, { severity: 'contract-mismatch', differences }, liveEntry.op); + } + + findings.sort((a, b) => `${a.direction} ${a.path} ${a.method}`.localeCompare(`${b.direction} ${b.path} ${b.method}`)); + draftNotYetPublished.sort((a, b) => `${a.path} ${a.method}`.localeCompare(`${b.path} ${b.method}`)); + + const count = (d) => findings.filter((f) => f.direction === d).length; + return { + headline: { + repoVersion: repoDoc?.info?.version ?? null, + repoPaths: Object.keys(repoDoc?.paths ?? {}).length, + repoOperations: repoOps.size, + publishedVersion: liveDoc?.info?.version ?? null, + publishedPaths: Object.keys(liveDoc?.paths ?? {}).length, + publishedOperations: liveOps.size, + sharedOperations: [...repoOps.keys()].filter((k) => liveOps.has(k)).length, + undocumentedLive: count('undocumented-live'), + unpublishedRepo: count('unpublished-repo'), + sharedDrift: count('shared-drift'), + draftNotYetPublished: draftNotYetPublished.length, + allowlisted: allowlisted.length, + lapsedAllowlistEntries: lapsedAllowlist.length, + }, + findings, + allowlisted, + lapsedAllowlist, + draftNotYetPublished, + enrichmentObservations, + normalizationRules: normalize ? NORMALIZATION_RULES : ['NORMALIZATION DISABLED (--no-normalize)'], + }; +} diff --git a/.github/scripts/published-drift-normalize.mjs b/.github/scripts/published-drift-normalize.mjs new file mode 100644 index 0000000..7954873 --- /dev/null +++ b/.github/scripts/published-drift-normalize.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * published-drift-normalize.mjs — everything this repo knows about what the GATEWAY SERVICE does + * to this spec on the way out. + * + * WHY THIS IS ITS OWN FILE. The service does not serve the spec verbatim: it rewrites every + * operation as it publishes it. That behaviour belongs to a different deployable and changes on + * its schedule, not this repo's, so it gets one small module — instead of being scattered through + * the comparator — and there is exactly one place to look when the published shape changes. + * + * EVERY LITERAL BELOW WAS TRANSCRIBED FROM THE PUBLISHED DOCUMENT ITSELF + * (https://api.wave.online/openapi.json), not from any service source. That is the honest source + * for a public spec repo: the published contract is the only thing this repo can actually observe, + * and it is the thing consumers get. + * + * WHY A NAIVE DIFFER IS USELESS WITHOUT THIS. Measured against the published document on + * 2026-09-03: all 72 shared operations report a difference for enrichment reasons alone, 71 of + * them in the response-code set. A comparator that skipped normalization would open with 72 + * findings, every one false, and be switched off within a day. `published-drift.test.mjs` pins + * that property so the normalizer keeps earning its place. + * + * THE ONE INVARIANT: every rule strips by EXACT SHAPE, never by key name. The injected 404 is + * removed only when it deep-equals the exact object the service publishes; a hand-written 404 that + * merely happens to be missing upstream still surfaces as drift. Normalizing by key name would + * blind the gate in precisely the fields the service touches. + * + * FAILURE MODE BY DESIGN: if the service changes its enrichment literals, these shapes stop + * matching and the differences resurface as findings. The gate goes LOUD, not quiet. + */ +import { isDeepStrictEqual } from 'node:util'; + +/** The versioning block the service assigns onto every operation it publishes. */ +export const VERSIONING = { + description: + 'WAVE API v1. Versioned by URL path (/v1/). Deprecations announced via Sunset headers and the changelog.', + 'x-version': '1', + 'x-deprecation-policy': 'https://wave.online/changelog', +}; + +/** The shared error envelope the service injects on 4xx/429. */ +export const ERROR_SCHEMA = { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + code: { type: 'string', description: 'Machine-readable error code' }, + message: { type: 'string', description: 'Human-readable error message' }, + param: { type: 'string', description: 'The parameter that caused the error', nullable: true }, + }, + required: ['code', 'message'], + }, + }, + required: ['error'], +}; + +/** Exactly these codes are injected when the published operation would otherwise lack them. */ +export const INJECTED_ERROR_CODES = ['400', '401', '403', '404', '429']; + +/** Only these verbs are enriched; anything else is published untouched. */ +export const ENRICHED_METHODS = ['get', 'post', 'put', 'delete', 'patch']; + +/** + * The service's operationId synthesis, reproduced so a SYNTHESIZED id can be told apart from a + * hand-set one. Stripping `operationId` whenever this repo lacked one would also hide a real, + * hand-written id the service had begun publishing. + */ +export function synthesizeOperationId(path, method) { + const segs = String(path) + .split('/') + .filter(Boolean) + .map((s) => s.replace(/[^a-zA-Z0-9]/g, '')); + const name = segs.map((s, i) => (i === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1))).join(''); + return `${method}${name.charAt(0).toUpperCase()}${name.slice(1)}`; +} + +const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v))); + +/** + * Strip the serve-time enrichment from a matched (repo, published) operation pair so the two are + * comparable. Returns normalized copies plus the observations worth reporting upward. + */ +export function normalizePair(repoOp, liveOp, path, method) { + const repo = clone(repoOp) ?? {}; + const live = clone(liveOp) ?? {}; + const observations = { descriptionOverwritten: false, strippedErrorCodes: [], strippedOperationId: false }; + if (!ENRICHED_METHODS.includes(method)) return { repo, live, observations }; + + // RULE 1 — the two versioning extensions. + for (const key of ['x-version', 'x-deprecation-policy']) { + if (live[key] === VERSIONING[key]) delete live[key]; + } + + // RULE 2 — the same assignment OVERWRITES `description` unconditionally, so when the published + // description is the boilerplate it carries no information about this repo's and comparing them + // is meaningless: drop both sides. But a repo description that was real and different has been + // DESTROYED in the published contract. That is a defect in the publishing service, not drift in + // this spec, and its remedy lives there (assign only the two x- keys; set description only when + // absent). Record it so it is reported rather than silently absorbed; it does not fail this gate. + if (live.description === VERSIONING.description) { + if (typeof repo.description === 'string' && repo.description !== VERSIONING.description) { + observations.descriptionOverwritten = true; + } + delete live.description; + delete repo.description; + } + + // RULE 3 — a synthesized operationId, and only a synthesized one. + if (repo.operationId === undefined && live.operationId === synthesizeOperationId(path, method)) { + delete live.operationId; + observations.strippedOperationId = true; + } + + // RULE 4 — injected error responses, matched against the exact injected object. + for (const code of INJECTED_ERROR_CODES) { + const injected = { description: `${code} error`, content: { 'application/json': { schema: ERROR_SCHEMA } } }; + if (repo.responses?.[code] === undefined && isDeepStrictEqual(live.responses?.[code], injected)) { + delete live.responses[code]; + observations.strippedErrorCodes.push(code); + } + } + + // RULE 5 — the auto-wrapped 200: a contentless 200 here is published as + // `{ description: , content: { application/json: { schema: {type:object} } } }`. + const repo200 = repo.responses?.['200']; + if (repo200 && typeof repo200 === 'object' && repo200.content === undefined) { + const wrapped = { + description: repo200.description || 'Success', + content: { 'application/json': { schema: { type: 'object' } } }, + }; + if (isDeepStrictEqual(live.responses?.['200'], wrapped)) live.responses['200'] = clone(repo200); + } + + // An operation whose only responses were injected leaves `{}` on one side and an absent key on + // the other. Not a difference worth reporting. + if (live.responses && Object.keys(live.responses).length === 0 && repo.responses === undefined) delete live.responses; + + return { repo, live, observations }; +} + +/** The identity observations, for `--no-normalize`. */ +export const NO_OBSERVATIONS = { descriptionOverwritten: false, strippedErrorCodes: [], strippedOperationId: false }; + +export const NORMALIZATION_RULES = [ + "strip op['x-version'] and op['x-deprecation-policy'], which the service assigns onto every operation", + "drop op['description'] on BOTH sides when the published value is the versioning boilerplate, and count the repo descriptions it destroyed", + 'strip op.operationId only when it equals the service synthesis of (path, method) and this repo has none', + `strip responses ${INJECTED_ERROR_CODES.join('/')} only when they deep-equal the injected error envelope and this repo lacks the code`, + 'unwrap the auto-wrapped 200 only when it deep-equals the wrapping of this repo\'s 200', + 'components.securitySchemes: NOT APPLICABLE — this comparator is operation-scoped and never reads components', + 'root-level operations the service adds of its own accord are deliberately NOT normalized away: they surface as undocumented-live findings and must be allowlisted by path+method with a justification, so they stay visible', +]; diff --git a/.github/scripts/published-drift.mjs b/.github/scripts/published-drift.mjs new file mode 100644 index 0000000..c1c142e --- /dev/null +++ b/.github/scripts/published-drift.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * published-drift.mjs — CLI. Does the contract the gateway PUBLISHES at + * https://api.wave.online/openapi.json match the contract THIS REPO declares in `openapi.yaml`, + * operation by operation? + * + * The comparison itself lives in `published-drift-compare.mjs` (pure) and what the gateway does to + * the spec at serve time lives in `published-drift-normalize.mjs`. This file is the shell: read the + * documents, hand them over, turn the verdict into an exit code. + * + * THREE QUESTIONS, THREE TOOLS — named here so nobody ships a fourth: + * 1. "Is the service's PIN of this spec stale?" — a byte-level watcher that already lives in the + * serving repo, which vendors a pinned copy of openapi.yaml. It resolves this repo's HEAD, + * hashes openapi.yaml there, and compares it to the pin. It stays where it is: only that repo + * can act on its answer, which is to bump its own pin. + * 2. "Is a live PRICED CAPABILITY undocumented?" — skills-index-coverage.mjs, next to this file. + * Reads the published capability index at PRODUCT granularity. + * 3. "Does the PUBLISHED CONTRACT match the declared one?" — this script. Neither of the others + * answers it. (1) compares repo bytes to a pin and says nothing about what is SERVED — a pin + * can be current while the served document still differs, because the service enriches and + * overlays the spec at serve time. (2) is product-granular and one-directional, so it cannot + * see a method-level difference, nor an operation served live that this repo never documented. + * + * WHICH REPO OWNS THIS GATE: this one. The published contract is this repo's OUTPUT — every SDK + * and the CLI are generated from openapi.yaml — so "the published contract disagrees with the + * spec" is a defect in this repo's product, and the remedy (document the operation, promote it out + * of draft, or drop the claim) edits a file that lives here. The serving repo keeps the pin + * watcher because the remedy THERE is a pin bump. Each gate lives where its fix lives. + * + * EXIT CODES — the fleet's standing contract for scheduled upstream watchers, the same one the pin + * watcher uses. A FAILED READ IS NEVER REPORTED AS "NO DRIFT". + * 0 no drift — every difference is normalized enrichment, a draft operation, or a live + * allowlist entry whose predicate still holds. + * 1 UNKNOWN — could not read the published spec or the local spec, or the allowlist is + * malformed. A TOOLING failure: it says nothing about drift and must go red WITHOUT filing + * the routine drift issue. + * 2 DRIFT — at least one unexplained operation-level difference. + * There is deliberately no exit 3: the pin watcher reserves 3 for PROVENANCE, a question about a + * pin this repo does not have. + * + * USAGE + * node .github/scripts/published-drift.mjs [openapi.yaml] + * node .github/scripts/published-drift.mjs openapi.yaml --live fixtures/live.json # offline + * node .github/scripts/published-drift.mjs openapi.yaml --out contract-drift.json + * node .github/scripts/published-drift.mjs openapi.yaml --no-normalize # see the 71 + * + * NETWORK: one GET to the hardcoded public URL below, unauthenticated, bounded by an + * AbortController timeout. Nothing else. `--live ` makes the run fully offline. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; +import { compare, indexOperations, validateAllowlist } from './published-drift-compare.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const PUBLISHED_SPEC_URL = 'https://api.wave.online/openapi.json'; +export const ALLOWLIST_PATH = join(__dirname, 'published-drift-allowlist.json'); +export const FETCH_TIMEOUT_MS = 20_000; + +export const EXIT_OK = 0; +export const EXIT_UNKNOWN = 1; +export const EXIT_DRIFT = 2; + +/** Fetch the published contract. Returns a result, never throws, never defaults to "no drift". */ +export async function fetchPublished(url = PUBLISHED_SPEC_URL, doFetch = fetch) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await doFetch(url, { signal: controller.signal, redirect: 'follow' }); + if (!res.ok) return { ok: false, error: `HTTP ${res.status} from ${url}` }; + return { ok: true, doc: await res.json() }; + } catch (err) { + const reason = err?.name === 'AbortError' ? `timed out after ${FETCH_TIMEOUT_MS}ms` : (err?.message ?? String(err)); + return { ok: false, error: `${url}: ${reason}` }; + } finally { + clearTimeout(timer); + } +} + +export function parseArgs(argv) { + const args = { spec: null, live: null, out: null, normalize: true, json: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--live') args.live = argv[++i]; + else if (a === '--out') args.out = argv[++i]; + else if (a === '--no-normalize') args.normalize = false; + else if (a === '--json') args.json = true; + else if (!a.startsWith('--') && args.spec === null) args.spec = a; + } + args.spec ??= 'openapi.yaml'; + return args; +} + +function report(r) { + const h = r.headline; + console.log( + `published-drift: repo ${h.repoVersion} ${h.repoPaths} paths / ${h.repoOperations} ops vs published ` + + `${h.publishedVersion} ${h.publishedPaths} paths / ${h.publishedOperations} ops — shared ${h.sharedOperations}`, + ); + console.log( + `published-drift: findings — undocumented-live ${h.undocumentedLive}, unpublished-repo ${h.unpublishedRepo}, ` + + `shared-drift ${h.sharedDrift}; suppressed — draft ${h.draftNotYetPublished}, allowlisted ${h.allowlisted}`, + ); + console.log( + `published-drift: gateway enrichment normalized — ${r.enrichmentObservations.errorResponsesInjected} injected error ` + + `responses, ${r.enrichmentObservations.operationIdsSynthesized} synthesized operationIds`, + ); + if (r.enrichmentObservations.descriptionsOverwritten.length) { + console.log( + `::warning::${r.enrichmentObservations.descriptionsOverwritten.length} operations have a real description in ` + + "openapi.yaml that the published contract replaced with its versioning boilerplate. " + + 'Not drift in this spec — a defect in the publishing service, tracked separately.', + ); + } + for (const e of r.lapsedAllowlist) { + console.error( + `::error::allowlist entry ${e.method} ${e.path} no longer matches its predicate — treating it as a finding instead ` + + `of honoring a stale exemption. Original justification: ${e.justification}`, + ); + } + for (const f of r.findings) { + const extra = f.differences ? ` fields: ${f.differences.map((d) => d.field).join(', ')}` : ''; + console.error(`::error::[${f.direction}] ${f.method} ${f.path} — ${f.note ?? 'differs from the published contract'}${extra}`); + } +} + +export async function main(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + + let repoDoc; + try { + const yaml = await import('js-yaml'); + repoDoc = (yaml.default ?? yaml).load(readFileSync(args.spec, 'utf8')); + } catch (err) { + console.error(`published-drift: could not read/parse ${args.spec}: ${err.message}`); + return EXIT_UNKNOWN; + } + if (!repoDoc?.paths || typeof repoDoc.paths !== 'object') { + console.error(`published-drift: ${args.spec} has no usable "paths" object`); + return EXIT_UNKNOWN; + } + + let allowlist; + try { + allowlist = JSON.parse(readFileSync(ALLOWLIST_PATH, 'utf8')); + } catch (err) { + console.error(`published-drift: could not read/parse ${ALLOWLIST_PATH}: ${err.message}`); + return EXIT_UNKNOWN; + } + const allowlistError = validateAllowlist(allowlist); + if (allowlistError) { + console.error(`published-drift: ${allowlistError}`); + return EXIT_UNKNOWN; + } + + let liveDoc; + let source; + if (args.live) { + try { + liveDoc = JSON.parse(readFileSync(args.live, 'utf8')); + source = `snapshot ${args.live}`; + } catch (err) { + console.error(`published-drift: could not read/parse snapshot ${args.live}: ${err.message}`); + return EXIT_UNKNOWN; + } + } else { + const fetched = await fetchPublished(); + if (!fetched.ok) { + // FAIL LOUD. An unreachable gateway is not "the contract matches". + console.error(`published-drift: could not read the published contract: ${fetched.error}`); + return EXIT_UNKNOWN; + } + liveDoc = fetched.doc; + source = PUBLISHED_SPEC_URL; + } + if (!liveDoc?.paths || typeof liveDoc.paths !== 'object') { + console.error(`published-drift: the published contract at ${source} has no usable "paths" object`); + return EXIT_UNKNOWN; + } + if (indexOperations(liveDoc).size === 0) { + console.error(`published-drift: the published contract at ${source} declares zero operations — refusing to call that "no drift"`); + return EXIT_UNKNOWN; + } + + const result = compare({ repoDoc, liveDoc, allowlist, normalize: args.normalize }); + const artifact = { + about: + 'Point-in-time operation-level diff between this repo\'s openapi.yaml and the contract the gateway publishes. ' + + 'It is a dated receipt, not a live view: regenerate with ' + + '`node .github/scripts/published-drift.mjs openapi.yaml --out contract-drift.json`. ' + + 'The published-contract-drift workflow uploads a fresh copy on every scheduled run.', + generatedAt: new Date().toISOString(), + criterion: ['CONTRACT-001', 'COMPAT-001', 'API-001'], + sources: { repoSpec: args.spec, repoCommit: process.env.GITHUB_SHA ?? null, publishedSpec: source }, + ...result, + }; + if (args.out) writeFileSync(args.out, `${JSON.stringify(artifact, null, 2)}\n`); + if (args.json) process.stdout.write(`${JSON.stringify(artifact)}\n`); + report(result); + + if (result.findings.length) { + console.error(`published-drift: DRIFT — ${result.findings.length} unexplained operation-level difference(s).`); + return EXIT_DRIFT; + } + console.log('published-drift: OK — the published contract matches openapi.yaml at operation granularity.'); + return EXIT_OK; +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + process.exitCode = await main(); +} diff --git a/.github/scripts/published-drift.test.mjs b/.github/scripts/published-drift.test.mjs new file mode 100644 index 0000000..d047cb2 --- /dev/null +++ b/.github/scripts/published-drift.test.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +/** + * published-drift.test.mjs — offline, deterministic, zero network. + * + * Every fixture here is hand-built rather than a checked-in copy of the two real documents: the + * live contract is 242 KB and openapi.yaml is 447 KB, and a snapshot of either would be stale the + * day it landed while telling us nothing a small fixture cannot. What the fixtures DO encode is the + * exact enrichment observed in the published document, so the normalizer is tested against the + * shape it actually has to undo. + * + * Run: node --test .github/scripts/ + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { + ERROR_SCHEMA, + INJECTED_ERROR_CODES, + VERSIONING, + normalizePair, + synthesizeOperationId, +} from './published-drift-normalize.mjs'; +import { allowlistStillApplies, compare, diffOperation, indexOperations, validateAllowlist } from './published-drift-compare.mjs'; +import { EXIT_DRIFT, EXIT_OK, EXIT_UNKNOWN, fetchPublished, main } from './published-drift.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..', '..'); + +/** Apply the serve-time enrichment to an operation exactly as the published document shows it. */ +function enrich(op, path, method) { + const out = structuredClone(op); + if (!out.operationId) out.operationId = synthesizeOperationId(path, method); + out.responses ??= {}; + if (out.responses['200'] && !out.responses['200'].content) { + out.responses['200'] = { + description: out.responses['200'].description || 'Success', + content: { 'application/json': { schema: { type: 'object' } } }, + }; + } + for (const code of INJECTED_ERROR_CODES) { + out.responses[code] ??= { description: `${code} error`, content: { 'application/json': { schema: ERROR_SCHEMA } } }; + } + Object.assign(out, VERSIONING); + return out; +} + +const doc = (paths, version = '1.0.0') => ({ openapi: '3.1.0', info: { title: 't', version }, paths }); + +// ── The load-bearing test: the normalizer has to earn its place. ──────────────────────────────── +test('an enriched-but-otherwise-identical operation is drift WITHOUT normalization and clean WITH it', () => { + const repoOp = { + summary: 'Render a brief', + description: 'POST a typed Brief. Unpaid requests answer 402 with an x402 challenge.', + responses: { 200: { description: 'Accepted' } }, + }; + const repoDoc = doc({ '/render': { post: repoOp } }); + const liveDoc = doc({ '/render': { post: enrich(repoOp, '/render', 'post') } }); + + const naive = compare({ repoDoc, liveDoc, normalize: false }); + assert.equal(naive.headline.sharedDrift, 1, 'without normalization the gateway enrichment reads as drift'); + assert.deepEqual( + naive.findings[0].differences.map((d) => d.field).sort(), + ['description', 'operationId', 'responses', 'x-deprecation-policy', 'x-version'], + 'all five enrichments read as differences when nothing is normalized', + ); + + const normalized = compare({ repoDoc, liveDoc }); + assert.equal(normalized.headline.sharedDrift, 0, 'with normalization the same pair is clean'); + assert.equal(normalized.enrichmentObservations.errorResponsesInjected, INJECTED_ERROR_CODES.length); + assert.equal(normalized.enrichmentObservations.operationIdsSynthesized, 1); + assert.deepEqual(normalized.enrichmentObservations.descriptionsOverwritten, ['POST /render']); +}); + +test('normalization scales: every enriched operation is stripped, none reported', () => { + const paths = {}; + for (let i = 0; i < 40; i++) paths[`/p${i}`] = { post: { summary: `op ${i}`, responses: { 200: { description: 'ok' } } } }; + const repoDoc = doc(paths); + const liveDoc = doc(Object.fromEntries(Object.entries(paths).map(([p, item]) => [p, { post: enrich(item.post, p, 'post') }]))); + assert.equal(compare({ repoDoc, liveDoc, normalize: false }).headline.sharedDrift, 40); + assert.equal(compare({ repoDoc, liveDoc }).headline.sharedDrift, 0); +}); + +// ── Exact-shape stripping: the normalizer must not go blind in the fields it touches. ─────────── +test('a 404 that is NOT the injected envelope survives normalization and surfaces as drift', () => { + const repoOp = { summary: 's' }; + const liveOp = enrich(repoOp, '/x', 'get'); + liveOp.responses['404'] = { description: 'Video not found', content: { 'application/json': { schema: { type: 'string' } } } }; + const { repo, live } = normalizePair(repoOp, liveOp, '/x', 'get'); + const fields = diffOperation(repo, live).map((d) => d.field); + assert.deepEqual(fields, ['responses'], 'a hand-written 404 is real content, not enrichment'); +}); + +test('a hand-set operationId that differs from the synthesis is never stripped', () => { + const repoOp = { summary: 's', responses: {} }; + const liveOp = enrich(repoOp, '/x', 'get'); + liveOp.operationId = 'aDeliberatelyDifferentId'; + const { repo, live } = normalizePair(repoOp, liveOp, '/x', 'get'); + assert.deepEqual(diffOperation(repo, live).map((d) => d.field), ['operationId']); +}); + +test('operationId synthesis is a faithful port of the gateway formula', () => { + assert.equal(synthesizeOperationId('/videos/{videoId}/chapters', 'get'), 'getVideosVideoIdChapters'); + assert.equal(synthesizeOperationId('/render', 'post'), 'postRender'); +}); + +test('a real description overwritten by the versioning boilerplate is COUNTED, not silently dropped', () => { + const repoOp = { description: 'The real, hand-written description.', responses: {} }; + const liveDoc = doc({ '/x': { get: enrich(repoOp, '/x', 'get') } }); + const r = compare({ repoDoc: doc({ '/x': { get: repoOp } }), liveDoc }); + assert.equal(r.headline.sharedDrift, 0, 'not counted as drift — it is a defect in the publishing service'); + assert.deepEqual(r.enrichmentObservations.descriptionsOverwritten, ['GET /x'], 'but it is reported'); +}); + +// ── Direction: unpublished-repo, and the draft rule that gates it. ────────────────────────────── +test('a draft repo-only operation is suppressed; promoting it out of draft makes it a finding', () => { + const draft = { 'x-schema-status': 'draft', 'x-price': { model: 'x402' }, responses: {} }; + const liveDoc = doc({ '/known': { get: { responses: {} } } }); + + const withDraft = compare({ repoDoc: doc({ '/known': { get: { responses: {} } }, '/new': { post: draft } }), liveDoc }); + assert.equal(withDraft.headline.unpublishedRepo, 0); + assert.equal(withDraft.headline.draftNotYetPublished, 1); + assert.equal(withDraft.draftNotYetPublished[0].xPriceModel, 'x402'); + + const { 'x-schema-status': _dropped, ...promoted } = draft; + const afterPromotion = compare({ repoDoc: doc({ '/known': { get: { responses: {} } }, '/new': { post: promoted } }), liveDoc }); + assert.equal(afterPromotion.headline.unpublishedRepo, 1, 'promotion without publication is exactly the drift this gate exists for'); + assert.equal(afterPromotion.findings[0].severity, 'contract-ahead'); +}); + +// ── Direction: undocumented-live, the security-relevant one. ──────────────────────────────────── +const injectedPublicOp = { + summary: 'LIVE inference funnel usage (registry-grounded, GROUP BY model, spend to 8 decimals)', + tags: ['public'], + responses: { 200: { description: 'ok' } }, +}; + +test('an operation served live but absent from the spec is a security-relevant finding', () => { + const r = compare({ repoDoc: doc({}), liveDoc: doc({ '/usage': { get: injectedPublicOp } }) }); + assert.equal(r.headline.undocumentedLive, 1); + assert.equal(r.findings[0].severity, 'security-relevant'); + assert.equal(r.findings[0].method, 'GET'); + assert.equal(r.findings[0].path, '/usage'); +}); + +test('an allowlist entry suppresses it — and LAPSES the moment the operation gains auth', () => { + const allowlist = [ + { + path: '/usage', + method: 'GET', + direction: 'undocumented-live', + justification: 'Gateway-native public root surface, exempt only while it stays unauthenticated.', + expect: { 'tags.0': 'public' }, + expectAbsent: ['security'], + }, + ]; + const clean = compare({ repoDoc: doc({}), liveDoc: doc({ '/usage': { get: injectedPublicOp } }), allowlist }); + assert.equal(clean.headline.undocumentedLive, 0); + assert.equal(clean.headline.allowlisted, 1); + + const behindAuth = { ...injectedPublicOp, security: [{ bearerWithScopes: ['usage:read'] }] }; + const lapsed = compare({ repoDoc: doc({}), liveDoc: doc({ '/usage': { get: behindAuth } }), allowlist }); + assert.equal(lapsed.headline.undocumentedLive, 1, 'the exemption was granted for the unauthenticated shape only'); + assert.equal(lapsed.headline.lapsedAllowlistEntries, 1); +}); + +test('an allowlist entry does not leak across directions', () => { + const allowlist = [ + { path: '/x', method: 'POST', direction: 'unpublished-repo', justification: 'A justification long enough to pass.', expect: {} }, + ]; + const r = compare({ repoDoc: doc({}), liveDoc: doc({ '/x': { post: { responses: {} } } }), allowlist }); + assert.equal(r.headline.undocumentedLive, 1, 'an unpublished-repo exemption must not silence an undocumented-live finding'); +}); + +test('a null expectation matches an absent key as well as a literal null', () => { + assert.equal(allowlistStillApplies({ expect: { security: null } }, { tags: ['public'] }), true); + assert.equal(allowlistStillApplies({ expect: { security: null } }, { security: null }), true); + assert.equal(allowlistStillApplies({ expect: { security: null } }, { security: [] }), false); +}); + +// ── Allowlist hygiene. ────────────────────────────────────────────────────────────────────────── +test('validateAllowlist rejects the ways an exemption goes bad', () => { + const ok = { path: '/a', method: 'GET', direction: 'undocumented-live', justification: 'A justification long enough.' }; + assert.equal(validateAllowlist([ok]), null); + assert.match(validateAllowlist({}), /not an array/); + assert.match(validateAllowlist([{ ...ok, justification: 'too short' }]), /needs a real justification/); + assert.match(validateAllowlist([{ ...ok, direction: 'whatever' }]), /unknown direction/); + assert.match(validateAllowlist([ok, ok]), /duplicate allowlist entry/); +}); + +test('the COMMITTED allowlist is well-formed and every entry is a live-direction exemption with a predicate', () => { + const committed = JSON.parse(readFileSync(join(__dirname, 'published-drift-allowlist.json'), 'utf8')); + assert.equal(validateAllowlist(committed), null); + for (const e of committed) { + assert.equal(e.direction, 'undocumented-live', `${e.method} ${e.path}: only live surface should ever need an exemption`); + assert.ok(Object.keys(e.expect ?? {}).length > 0, `${e.method} ${e.path}: an exemption without a predicate cannot lapse`); + assert.ok(e.expectAbsent?.includes('security'), `${e.method} ${e.path}: must lapse when the route gains auth`); + } +}); + +// ── Index and path-item handling. ─────────────────────────────────────────────────────────────── +test('indexOperations skips path-item metadata and counts only real operations', () => { + const ops = indexOperations(doc({ '/a': { get: {}, post: {}, parameters: [{ name: 'x' }], summary: 'shared', $ref: '#/x' } })); + assert.deepEqual([...ops.keys()].sort(), ['GET /a', 'POST /a']); +}); + +// ── Exit contract: a broken read is NEVER "no drift". ─────────────────────────────────────────── +test('fetchPublished reports a failure rather than throwing or defaulting', async () => { + const boom = async () => { + throw new Error('ECONNREFUSED'); + }; + assert.deepEqual(await fetchPublished('https://example.invalid/x', boom), { + ok: false, + error: 'https://example.invalid/x: ECONNREFUSED', + }); + const notOk = async () => ({ ok: false, status: 503 }); + assert.match((await fetchPublished('https://example.invalid/x', notOk)).error, /HTTP 503/); +}); + +test('an unreadable snapshot exits UNKNOWN, never OK', async () => { + assert.equal(await main([join(REPO_ROOT, 'openapi.yaml'), '--live', '/nonexistent/live.json']), EXIT_UNKNOWN); +}); + +test('an unreadable spec exits UNKNOWN, never OK', async () => { + assert.equal(await main(['/nonexistent/openapi.yaml', '--live', '/nonexistent/live.json']), EXIT_UNKNOWN); +}); + +test('a published contract with zero operations exits UNKNOWN, never OK', async () => { + // The dangerous failure is a gateway that answers 200 with an empty or truncated document: + // every repo operation would look "unpublished" and, with all of them draft-suppressed, the run + // would report a clean contract. Refusing to grade an empty document is what stops that. + const { writeFileSync, rmSync } = await import('node:fs'); + const snapshot = join(process.env.RUNNER_TEMP ?? '/tmp', `published-drift-empty-${process.pid}.json`); + writeFileSync(snapshot, JSON.stringify(doc({}))); + try { + assert.equal(await main([join(REPO_ROOT, 'openapi.yaml'), '--live', snapshot]), EXIT_UNKNOWN); + } finally { + rmSync(snapshot, { force: true }); + } +}); + +// ── End to end against the real openapi.yaml, using a fabricated published document. ──────────── +test('main() exits DRIFT on a real spec vs a published document that omits a promoted operation', async () => { + const yaml = (await import('js-yaml')).default; + const spec = yaml.load(readFileSync(join(REPO_ROOT, 'openapi.yaml'), 'utf8')); + // Serve the spec's own non-draft operations, then delete one — a published contract that has + // dropped a promoted operation is unambiguous drift. + const served = {}; + for (const [p, item] of Object.entries(spec.paths)) { + const kept = Object.fromEntries(Object.entries(item).filter(([, op]) => op?.['x-schema-status'] !== 'draft')); + if (Object.keys(kept).length) served[p] = kept; + } + const victim = Object.keys(served)[0]; + delete served[victim]; + + const snapshot = join(process.env.RUNNER_TEMP ?? '/tmp', `published-drift-fixture-${process.pid}.json`); + const { writeFileSync, rmSync } = await import('node:fs'); + writeFileSync(snapshot, JSON.stringify(doc(served))); + try { + assert.equal(await main([join(REPO_ROOT, 'openapi.yaml'), '--live', snapshot]), EXIT_DRIFT); + } finally { + rmSync(snapshot, { force: true }); + } +}); + +test('main() exits OK when the published document carries every non-draft operation verbatim', async () => { + const yaml = (await import('js-yaml')).default; + const spec = yaml.load(readFileSync(join(REPO_ROOT, 'openapi.yaml'), 'utf8')); + const served = {}; + for (const [p, item] of Object.entries(spec.paths)) { + const kept = Object.fromEntries(Object.entries(item).filter(([, op]) => op?.['x-schema-status'] !== 'draft')); + if (Object.keys(kept).length) served[p] = kept; + } + const snapshot = join(process.env.RUNNER_TEMP ?? '/tmp', `published-drift-clean-${process.pid}.json`); + const { writeFileSync, rmSync } = await import('node:fs'); + writeFileSync(snapshot, JSON.stringify(doc(served))); + try { + assert.equal(await main([join(REPO_ROOT, 'openapi.yaml'), '--live', snapshot]), EXIT_OK); + } finally { + rmSync(snapshot, { force: true }); + } +}); diff --git a/.github/workflows/published-contract-drift.yml b/.github/workflows/published-contract-drift.yml new file mode 100644 index 0000000..f95cea4 --- /dev/null +++ b/.github/workflows/published-contract-drift.yml @@ -0,0 +1,161 @@ +# published-contract-drift.yml — does the contract the gateway PUBLISHES still match the one this +# repo declares? See .github/scripts/published-drift.mjs for the full rationale. +# +# TWO JOBS, TWO TRIGGERS, ON PURPOSE. +# +# `unit` runs on PULL REQUESTS. It is offline — node --test over hand-built fixtures, no network +# at all — so it is a property of the diff and belongs on the PR path. +# +# `drift` runs on a SCHEDULE and on demand, never on a pull request. It fetches the live +# published contract, and whether it has drifted is NOT a property of any given PR: it is a +# fact about the world that changes on the serving deployment's schedule, not this repo's. Asking it +# on every PR would make every author here depend on an unauthenticated network fetch, so an +# outage could red the whole repo for a reason no author could act on. That is the exact defect +# the serving repo already fixed by moving its own spec-drift check to a cron, and this file +# follows that precedent rather than reintroducing the defect here. +# +# WHY THIS IS A NEW FILE AND NOT A JOB IN foundation-gate.yml: foundation-gate.yml mirrors +# wave-foundation's checks.yml and is contended by several in-flight branches. A gate that is +# specific to this repo's own published artifact does not belong inside the mirrored file. +# +# ON EXIT 2 (drift) the job files or updates ONE tracking issue, matched by exact title so repeated +# runs comment instead of opening a new issue every morning. On exit 1 (UNKNOWN — a broken read) it +# goes red and files NOTHING: a failed read says nothing about drift, and an issue claiming drift +# on the strength of a failed fetch would be a false report. + +name: published-contract-drift + +on: + pull_request: + paths: + - '.github/scripts/published-drift*' + - '.github/workflows/published-contract-drift.yml' + - 'openapi.yaml' + push: + branches: [main] + paths: + - '.github/scripts/published-drift*' + - '.github/workflows/published-contract-drift.yml' + schedule: + # 07:10 UTC daily — deliberately after the serving repo's own pin-staleness cron (06:40 UTC), + # so that picture is already fresh when this asks the published-contract question. + - cron: '10 7 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Offline. Safe on every PR. This is what keeps the normalizer honest: it asserts that an + # enriched-but-identical operation reads as drift WITHOUT normalization and clean WITH it, so a + # normalizer that silently stopped working could not pass. + unit: + name: unit tests (offline) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + - name: Install tooling + # js-yaml is installed explicitly rather than leaned on transitively, for the same reason + # foundation-gate.yml does it: nothing this repo depends on is otherwise importable here. + run: npm install --no-save --no-audit --no-fund js-yaml@4.1.0 + - name: node --test + run: node --test .github/scripts/published-drift.test.mjs + + # Networked. Scheduled and manual only. + drift: + name: published contract drift + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # Required to file/update the tracking issue on exit 2. + issues: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + - name: Install tooling + run: npm install --no-save --no-audit --no-fund js-yaml@4.1.0 + + - name: Compare the published contract against openapi.yaml + id: drift + run: | + set +e + node .github/scripts/published-drift.mjs openapi.yaml --out /tmp/contract-drift.json | tee /tmp/drift.log + code=${PIPESTATUS[0]} + set -e + echo "code=$code" >> "$GITHUB_OUTPUT" + echo "exit code: $code" + + - name: Upload the diff artifact + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: contract-drift + path: /tmp/contract-drift.json + if-no-files-found: warn + + # exit 1 = UNKNOWN. A broken read is a TOOLING failure: go red, file nothing. + - name: Fail loudly on a broken read + if: steps.drift.outputs.code == '1' + run: | + echo "::error::published-drift could not read the published contract or the local spec." + echo "This says NOTHING about drift and no issue was filed. Fix the read, then re-run." + exit 1 + + # exit 2 = DRIFT. File or update exactly one tracking issue. + # + # Nothing PR- or issue-controlled is interpolated into a shell command: the title is a fixed + # literal, the body is written to a file by the script above and passed with --body-file, and + # the issue number comes from a --jq filter over `gh issue list` output rather than from a + # search string. The only interpolated value is $GITHUB_REPOSITORY, which GitHub sets. + - name: File or update the tracking issue + if: steps.drift.outputs.code == '2' + env: + GH_TOKEN: ${{ github.token }} + TITLE: 'Published contract has drifted from openapi.yaml' + run: | + { + echo "The gateway's published contract no longer matches this repo's \`openapi.yaml\` at operation granularity." + echo + echo "Run: [\`$GITHUB_RUN_ID\`](https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID) · commit \`$GITHUB_SHA\`" + echo + echo '```' + cat /tmp/drift.log + echo '```' + echo + echo "The full machine-readable diff is attached to the run as the \`contract-drift\` artifact." + echo "See \`.github/scripts/published-drift.mjs\` for what each direction means and how to clear it." + } > /tmp/issue-body.md + + existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --limit 100 \ + --json number,title --jq "map(select(.title == \$ENV.TITLE)) | .[0].number // empty") + + if [ -n "$existing" ]; then + gh issue comment "$existing" --repo "$GITHUB_REPOSITORY" --body-file /tmp/issue-body.md + echo "Commented on existing issue #$existing" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file /tmp/issue-body.md + fi + + # The job's own verdict is red on drift too, so a silenced or rate-limited issue write can + # never make a drifted contract look green. + - name: Fail on drift + if: steps.drift.outputs.code == '2' + run: | + echo "::error::The published contract has drifted from openapi.yaml. A tracking issue was filed or updated." + exit 1 diff --git a/contract-drift.json b/contract-drift.json new file mode 100644 index 0000000..4e89b76 --- /dev/null +++ b/contract-drift.json @@ -0,0 +1,1471 @@ +{ + "about": "Point-in-time operation-level diff between this repo's openapi.yaml and the contract the gateway publishes. It is a dated receipt, not a live view: regenerate with `node .github/scripts/published-drift.mjs openapi.yaml --out contract-drift.json`. The published-contract-drift workflow uploads a fresh copy on every scheduled run.", + "generatedAt": "2026-09-04T00:02:48.265Z", + "criterion": [ + "CONTRACT-001", + "COMPAT-001", + "API-001" + ], + "sources": { + "repoSpec": "openapi.yaml", + "repoCommit": "9ffe7c0a8eb400ad796ec120f7c4d489ac373479", + "publishedSpec": "https://api.wave.online/openapi.json" + }, + "headline": { + "repoVersion": "1.1.0", + "repoPaths": 209, + "repoOperations": 230, + "publishedVersion": "1.0.0", + "publishedPaths": 54, + "publishedOperations": 75, + "sharedOperations": 72, + "undocumentedLive": 0, + "unpublishedRepo": 0, + "sharedDrift": 4, + "draftNotYetPublished": 158, + "allowlisted": 3, + "lapsedAllowlistEntries": 0 + }, + "findings": [ + { + "path": "/identity/resolve", + "method": "GET", + "direction": "shared-drift", + "severity": "contract-mismatch", + "differences": [ + { + "field": "parameters", + "repo": [ + { + "name": "agent", + "in": "query", + "required": true, + "description": "Agent id (lowercase, e.g. `opencode`, `claude`, `telephony`)", + "schema": { + "type": "string", + "pattern": "^[a-z0-9-]{1,64}$" + } + }, + { + "name": "org", + "in": "query", + "required": false, + "description": "Optional tenancy self-assertion; must equal the authenticated principal's org", + "schema": { + "type": "string" + } + } + ], + "published": [ + { + "name": "agent", + "in": "query", + "required": true, + "description": "Fleet agent id (lowercase, e.g. `opencode`, `claude`, `telephony`)", + "schema": { + "type": "string", + "pattern": "^[a-z0-9-]{1,64}$" + } + }, + { + "name": "org", + "in": "query", + "required": false, + "description": "Optional tenancy self-assertion; must equal the authenticated principal's org", + "schema": { + "type": "string" + } + } + ] + }, + { + "field": "summary", + "repo": "Resolve a WAVE agent id to its public channel map", + "published": "Resolve a WAVE fleet agent id to its public channel map" + } + ] + }, + { + "path": "/videos/{videoId}/chapters", + "method": "GET", + "direction": "shared-drift", + "severity": "contract-mismatch", + "differences": [ + { + "field": "deprecated", + "repo": true, + "published": null + }, + { + "field": "x-status", + "repo": "unrouted", + "published": null + } + ] + }, + { + "path": "/videos/{videoId}/chapters", + "method": "POST", + "direction": "shared-drift", + "severity": "contract-mismatch", + "differences": [ + { + "field": "deprecated", + "repo": true, + "published": null + }, + { + "field": "x-status", + "repo": "unrouted", + "published": null + } + ] + }, + { + "path": "/videos/{videoId}/chapters/detect", + "method": "POST", + "direction": "shared-drift", + "severity": "contract-mismatch", + "differences": [ + { + "field": "deprecated", + "repo": true, + "published": null + }, + { + "field": "x-status", + "repo": "unrouted", + "published": null + } + ] + } + ], + "allowlisted": [ + { + "path": "/leaderboard", + "method": "GET", + "direction": "undocumented-live", + "justification": "Gateway-NATIVE root surface, not a /v1 operation this spec describes. The published contract injects it at serve time with an explicit per-operation server override of https://api.wave.online (no /v1 prefix) because it is served pre-auth at the host root. Documenting it here as a /v1 path would state a URL that does not exist. Exempt only while it stays the unauthenticated, public-tagged, read-only surface it is today: the expectAbsent guard below drops this exemption the moment the operation gains a security requirement, which is exactly what the in-flight work to move these three behind operator auth will do." + }, + { + "path": "/usage", + "method": "GET", + "direction": "undocumented-live", + "justification": "Gateway-NATIVE root surface injected at serve time, served pre-auth at the host root. NOTE THE COLLISION, which is why this entry is the narrowest of the three: openapi.yaml separately declares POST /usage as an x402-priced draft operation under /v1, so the segment `usage` means two different things in the two documents — a free public GET at the root and a priced POST under /v1. This entry exempts ONLY the published GET; it says nothing about the draft POST, and the two must be reconciled before that draft is promoted. Like its siblings this operation is being moved behind operator auth, and the expectAbsent guard lapses the exemption when it is." + }, + { + "path": "/platform", + "method": "GET", + "direction": "undocumented-live", + "justification": "Gateway-NATIVE root surface, same shape and same reasoning as GET /leaderboard: injected at serve time with a per-operation server override of https://api.wave.online, served pre-auth at the host root rather than under /v1. It reports platform-wide aggregate usage and is one of the three operations being moved behind operator auth; when that lands, the expectAbsent guard below lapses this exemption and the gate demands the operation be described or removed rather than silently re-exempted in its new shape." + } + ], + "lapsedAllowlist": [], + "draftNotYetPublished": [ + { + "path": "/accessibility-studio", + "method": "POST", + "operationId": "accessibilityStudio", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/accessibility-studio.json" + }, + { + "path": "/acp", + "method": "POST", + "operationId": "acp", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/acp.json" + }, + { + "path": "/acuity", + "method": "POST", + "operationId": "acuity", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/acuity.json" + }, + { + "path": "/aegis", + "method": "POST", + "operationId": "aegis", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/aegis.json" + }, + { + "path": "/aes67", + "method": "POST", + "operationId": "aes67", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/aes67.json" + }, + { + "path": "/agentic-media", + "method": "POST", + "operationId": "agenticMedia", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/agentic-media.json" + }, + { + "path": "/agents", + "method": "POST", + "operationId": "agents", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/agents.json" + }, + { + "path": "/ai", + "method": "POST", + "operationId": "ai", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/ai.json" + }, + { + "path": "/analytics", + "method": "POST", + "operationId": "analytics", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/analytics.json" + }, + { + "path": "/api-gateway", + "method": "POST", + "operationId": "apiGateway", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/api-gateway.json" + }, + { + "path": "/archive", + "method": "POST", + "operationId": "archive", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/archive.json" + }, + { + "path": "/argus", + "method": "POST", + "operationId": "argus", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/argus.json" + }, + { + "path": "/audience-engagement", + "method": "POST", + "operationId": "audienceEngagement", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/audience-engagement.json" + }, + { + "path": "/audio-mastering", + "method": "POST", + "operationId": "audioMastering", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/audio-mastering.json" + }, + { + "path": "/auth", + "method": "POST", + "operationId": "auth", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/auth.json" + }, + { + "path": "/autopilot", + "method": "POST", + "operationId": "autopilot", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/autopilot.json" + }, + { + "path": "/behavioral-intelligence", + "method": "POST", + "operationId": "behavioralIntelligence", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/behavioral-intelligence.json" + }, + { + "path": "/benchmark", + "method": "POST", + "operationId": "benchmark", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/benchmark.json" + }, + { + "path": "/billing", + "method": "POST", + "operationId": "billing", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/billing.json" + }, + { + "path": "/bmd", + "method": "POST", + "operationId": "bmd", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/bmd.json" + }, + { + "path": "/bridge", + "method": "POST", + "operationId": "bridge", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/bridge.json" + }, + { + "path": "/broadcast", + "method": "POST", + "operationId": "broadcast", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/broadcast.json" + }, + { + "path": "/camera-control", + "method": "POST", + "operationId": "cameraControl", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/camera-control.json" + }, + { + "path": "/cameras", + "method": "POST", + "operationId": "cameras", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/cameras.json" + }, + { + "path": "/campus", + "method": "POST", + "operationId": "campus", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/campus.json" + }, + { + "path": "/challenge", + "method": "POST", + "operationId": "challenge", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/challenge.json" + }, + { + "path": "/chapters", + "method": "POST", + "operationId": "chapters", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/chapters.json" + }, + { + "path": "/ci", + "method": "POST", + "operationId": "ci", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/ci.json" + }, + { + "path": "/cloud-switcher", + "method": "POST", + "operationId": "cloudSwitcher", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/cloud-switcher.json" + }, + { + "path": "/companion", + "method": "POST", + "operationId": "companion", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/companion.json" + }, + { + "path": "/competitive-intel", + "method": "POST", + "operationId": "competitiveIntel", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/competitive-intel.json" + }, + { + "path": "/compliance", + "method": "POST", + "operationId": "compliance", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/compliance.json" + }, + { + "path": "/connect", + "method": "POST", + "operationId": "connect", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/connect.json" + }, + { + "path": "/cookie-consent", + "method": "POST", + "operationId": "cookieConsent", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/cookie-consent.json" + }, + { + "path": "/cost", + "method": "POST", + "operationId": "cost", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/cost.json" + }, + { + "path": "/creator", + "method": "POST", + "operationId": "creator", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/creator.json" + }, + { + "path": "/creator-economy", + "method": "POST", + "operationId": "creatorEconomy", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/creator-economy.json" + }, + { + "path": "/creator-storefront", + "method": "POST", + "operationId": "creatorStorefront", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/creator-storefront.json" + }, + { + "path": "/crest", + "method": "POST", + "operationId": "crest", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/crest.json" + }, + { + "path": "/cro", + "method": "POST", + "operationId": "cro", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/cro.json" + }, + { + "path": "/dante", + "method": "POST", + "operationId": "dante", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/dante.json" + }, + { + "path": "/data-exchange", + "method": "POST", + "operationId": "dataExchange", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/data-exchange.json" + }, + { + "path": "/decode", + "method": "POST", + "operationId": "decode", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/decode.json" + }, + { + "path": "/director", + "method": "POST", + "operationId": "director", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/director.json" + }, + { + "path": "/discovery", + "method": "POST", + "operationId": "discovery", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/discovery.json" + }, + { + "path": "/dispatch", + "method": "POST", + "operationId": "dispatch", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/dispatch.json" + }, + { + "path": "/dmca", + "method": "POST", + "operationId": "dmca", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/dmca.json" + }, + { + "path": "/dsar", + "method": "POST", + "operationId": "dsar", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/dsar.json" + }, + { + "path": "/dub", + "method": "POST", + "operationId": "dub", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/dub.json" + }, + { + "path": "/echo", + "method": "POST", + "operationId": "echo", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/echo.json" + }, + { + "path": "/edge", + "method": "POST", + "operationId": "edge", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/edge.json" + }, + { + "path": "/embeddings", + "method": "POST", + "operationId": "embeddings", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/embeddings.json" + }, + { + "path": "/encode", + "method": "POST", + "operationId": "encode", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/encode.json" + }, + { + "path": "/engagement", + "method": "POST", + "operationId": "engagement", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/engagement.json" + }, + { + "path": "/enhance", + "method": "POST", + "operationId": "enhance", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/enhance.json" + }, + { + "path": "/example", + "method": "POST", + "operationId": "example", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/example.json" + }, + { + "path": "/experiments", + "method": "POST", + "operationId": "experiments", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/experiments.json" + }, + { + "path": "/fleet", + "method": "POST", + "operationId": "fleet", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/fleet.json" + }, + { + "path": "/forecast", + "method": "POST", + "operationId": "forecast", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/forecast.json" + }, + { + "path": "/geo", + "method": "POST", + "operationId": "geo", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/geo.json" + }, + { + "path": "/ghost-producer", + "method": "POST", + "operationId": "ghostProducer", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/ghost-producer.json" + }, + { + "path": "/graphics-engine", + "method": "POST", + "operationId": "graphicsEngine", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/graphics-engine.json" + }, + { + "path": "/integrations", + "method": "POST", + "operationId": "integrations", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/integrations.json" + }, + { + "path": "/intel", + "method": "POST", + "operationId": "intel", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/intel.json" + }, + { + "path": "/listen", + "method": "POST", + "operationId": "listen", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/listen.json" + }, + { + "path": "/live", + "method": "POST", + "operationId": "live", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/live.json" + }, + { + "path": "/live-annotation", + "method": "POST", + "operationId": "liveAnnotation", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/live-annotation.json" + }, + { + "path": "/live-commerce", + "method": "POST", + "operationId": "liveCommerce", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/live-commerce.json" + }, + { + "path": "/local-ai", + "method": "POST", + "operationId": "localAi", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/local-ai.json" + }, + { + "path": "/me", + "method": "POST", + "operationId": "me", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/me.json" + }, + { + "path": "/memory", + "method": "POST", + "operationId": "memory", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/memory.json" + }, + { + "path": "/mesh", + "method": "POST", + "operationId": "mesh", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/mesh.json" + }, + { + "path": "/mlvc", + "method": "POST", + "operationId": "mlvc", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/mlvc.json" + }, + { + "path": "/mobile-producer", + "method": "POST", + "operationId": "mobileProducer", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/mobile-producer.json" + }, + { + "path": "/moderate", + "method": "POST", + "operationId": "moderate", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/moderate.json" + }, + { + "path": "/monetization", + "method": "POST", + "operationId": "monetization", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/monetization.json" + }, + { + "path": "/monitoring", + "method": "POST", + "operationId": "monitoring", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/monitoring.json" + }, + { + "path": "/mpp", + "method": "POST", + "operationId": "mpp", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/mpp.json" + }, + { + "path": "/mux", + "method": "POST", + "operationId": "mux", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/mux.json" + }, + { + "path": "/mxl", + "method": "POST", + "operationId": "mxl", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/mxl.json" + }, + { + "path": "/ndi", + "method": "POST", + "operationId": "ndi", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/ndi.json" + }, + { + "path": "/nvr", + "method": "POST", + "operationId": "nvr", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/nvr.json" + }, + { + "path": "/omt", + "method": "POST", + "operationId": "omt", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/omt.json" + }, + { + "path": "/ops", + "method": "POST", + "operationId": "ops", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/ops.json" + }, + { + "path": "/orbit", + "method": "POST", + "operationId": "orbit", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/orbit.json" + }, + { + "path": "/organizations", + "method": "POST", + "operationId": "organizations", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/organizations.json" + }, + { + "path": "/outliers", + "method": "POST", + "operationId": "outliers", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/outliers.json" + }, + { + "path": "/payments", + "method": "POST", + "operationId": "payments", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/payments.json" + }, + { + "path": "/perception", + "method": "POST", + "operationId": "perception", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/perception.json" + }, + { + "path": "/pipelines", + "method": "POST", + "operationId": "pipelines", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/pipelines.json" + }, + { + "path": "/preferences", + "method": "POST", + "operationId": "preferences", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/preferences.json" + }, + { + "path": "/presence", + "method": "POST", + "operationId": "presence", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/presence.json" + }, + { + "path": "/privy", + "method": "POST", + "operationId": "privy", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/privy.json" + }, + { + "path": "/production", + "method": "POST", + "operationId": "production", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/production.json" + }, + { + "path": "/production-graph", + "method": "POST", + "operationId": "productionGraph", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/production-graph.json" + }, + { + "path": "/productions", + "method": "POST", + "operationId": "productions", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/productions.json" + }, + { + "path": "/products", + "method": "POST", + "operationId": "products", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/products.json" + }, + { + "path": "/pulse", + "method": "POST", + "operationId": "pulse", + "xSchemaStatus": "draft", + "xPriceModel": "free", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/pulse.json" + }, + { + "path": "/qr-system", + "method": "POST", + "operationId": "qrSystem", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/qr-system.json" + }, + { + "path": "/quality-scorecard", + "method": "POST", + "operationId": "qualityScorecard", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/quality-scorecard.json" + }, + { + "path": "/radar", + "method": "POST", + "operationId": "radar", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/radar.json" + }, + { + "path": "/rate-limit", + "method": "POST", + "operationId": "rateLimit", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/rate-limit.json" + }, + { + "path": "/recommend", + "method": "POST", + "operationId": "recommend", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/recommend.json" + }, + { + "path": "/remotion", + "method": "POST", + "operationId": "remotion", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/remotion.json" + }, + { + "path": "/renders", + "method": "POST", + "operationId": "renders", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/renders.json" + }, + { + "path": "/replay", + "method": "POST", + "operationId": "replay", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/replay.json" + }, + { + "path": "/replay-engine", + "method": "POST", + "operationId": "replayEngine", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/replay-engine.json" + }, + { + "path": "/review", + "method": "POST", + "operationId": "review", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/review.json" + }, + { + "path": "/rist", + "method": "POST", + "operationId": "rist", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/rist.json" + }, + { + "path": "/router", + "method": "POST", + "operationId": "router", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/router.json" + }, + { + "path": "/routes", + "method": "POST", + "operationId": "routes", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/routes.json" + }, + { + "path": "/rtmp", + "method": "POST", + "operationId": "rtmp", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/rtmp.json" + }, + { + "path": "/runtime", + "method": "POST", + "operationId": "runtime", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/runtime.json" + }, + { + "path": "/sandbox", + "method": "POST", + "operationId": "sandbox", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/sandbox.json" + }, + { + "path": "/scene", + "method": "POST", + "operationId": "scene", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/scene.json" + }, + { + "path": "/signal", + "method": "POST", + "operationId": "signal", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/signal.json" + }, + { + "path": "/signal-generator", + "method": "POST", + "operationId": "signalGenerator", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/signal-generator.json" + }, + { + "path": "/signal-verifier", + "method": "POST", + "operationId": "signalVerifier", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/signal-verifier.json" + }, + { + "path": "/slides-to-video", + "method": "POST", + "operationId": "slidesToVideo", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/slides-to-video.json" + }, + { + "path": "/social-distribution", + "method": "POST", + "operationId": "socialDistribution", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/social-distribution.json" + }, + { + "path": "/sports-data", + "method": "POST", + "operationId": "sportsData", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/sports-data.json" + }, + { + "path": "/srt", + "method": "POST", + "operationId": "srt", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/srt.json" + }, + { + "path": "/st2110", + "method": "POST", + "operationId": "st2110", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/st2110.json" + }, + { + "path": "/stream", + "method": "POST", + "operationId": "stream", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/stream.json" + }, + { + "path": "/stream-router", + "method": "POST", + "operationId": "streamRouter", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/stream-router.json" + }, + { + "path": "/streamdeck", + "method": "POST", + "operationId": "streamdeck", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/streamdeck.json" + }, + { + "path": "/streaming", + "method": "POST", + "operationId": "streaming", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/streaming.json" + }, + { + "path": "/streams", + "method": "POST", + "operationId": "streams", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/streams.json" + }, + { + "path": "/studio", + "method": "POST", + "operationId": "studio", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/studio.json" + }, + { + "path": "/studio-automation", + "method": "POST", + "operationId": "studioAutomation", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/studio-automation.json" + }, + { + "path": "/switcher", + "method": "POST", + "operationId": "switcher", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/switcher.json" + }, + { + "path": "/tempo", + "method": "POST", + "operationId": "tempo", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/tempo.json" + }, + { + "path": "/transcode", + "method": "POST", + "operationId": "transcode", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/transcode.json" + }, + { + "path": "/twilio", + "method": "POST", + "operationId": "twilio", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/twilio.json" + }, + { + "path": "/unsubscribe", + "method": "POST", + "operationId": "unsubscribe", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/unsubscribe.json" + }, + { + "path": "/usage", + "method": "POST", + "operationId": "usage", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/usage.json" + }, + { + "path": "/usb-relay", + "method": "POST", + "operationId": "usbRelay", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/usb-relay.json" + }, + { + "path": "/vault", + "method": "POST", + "operationId": "vault", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/vault.json" + }, + { + "path": "/video-gen", + "method": "POST", + "operationId": "videoGen", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/video-gen.json" + }, + { + "path": "/viewer", + "method": "POST", + "operationId": "viewer", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/viewer.json" + }, + { + "path": "/virtual-studio", + "method": "POST", + "operationId": "virtualStudio", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/virtual-studio.json" + }, + { + "path": "/vision", + "method": "POST", + "operationId": "vision", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/vision.json" + }, + { + "path": "/visual-programming", + "method": "POST", + "operationId": "visualProgramming", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/visual-programming.json" + }, + { + "path": "/visual-qa", + "method": "POST", + "operationId": "visualQa", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/visual-qa.json" + }, + { + "path": "/vod", + "method": "POST", + "operationId": "vod", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/vod.json" + }, + { + "path": "/volumetric", + "method": "POST", + "operationId": "volumetric", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/volumetric.json" + }, + { + "path": "/wave-console", + "method": "POST", + "operationId": "waveConsole", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/wave-console.json" + }, + { + "path": "/wave-node", + "method": "POST", + "operationId": "waveNode", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/wave-node.json" + }, + { + "path": "/wave-sdk", + "method": "POST", + "operationId": "waveSdk", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/wave-sdk.json" + }, + { + "path": "/wave-tokens", + "method": "POST", + "operationId": "waveTokens", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/wave-tokens.json" + }, + { + "path": "/webrtc", + "method": "POST", + "operationId": "webrtc", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/webrtc.json" + }, + { + "path": "/whep", + "method": "POST", + "operationId": "whep", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/whep.json" + }, + { + "path": "/whip", + "method": "POST", + "operationId": "whip", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/whip.json" + }, + { + "path": "/workflow-engine", + "method": "POST", + "operationId": "workflowEngine", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/workflow-engine.json" + }, + { + "path": "/x402", + "method": "POST", + "operationId": "x402", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/x402.json" + }, + { + "path": "/zero-trust-vault", + "method": "POST", + "operationId": "zeroTrustVault", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/zero-trust-vault.json" + }, + { + "path": "/zoom", + "method": "POST", + "operationId": "zoom", + "xSchemaStatus": "draft", + "xPriceModel": "metered", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/zoom.json" + }, + { + "path": "/zoom-integration", + "method": "POST", + "operationId": "zoomIntegration", + "xSchemaStatus": "draft", + "xPriceModel": "x402", + "xSkillUrl": "https://gateway.wave.online/.well-known/wave-skills/zoom-integration.json" + } + ], + "enrichmentObservations": { + "descriptionsOverwritten": [ + "POST /agent/auth/device", + "POST /agent/auth/token", + "POST /batch", + "POST /render", + "GET /render/{jobId}", + "GET /render/{jobId}/events", + "GET /videos/{videoId}/chapters", + "POST /videos/{videoId}/chapters", + "POST /videos/{videoId}/chapters/detect", + "GET /realtime/connect", + "POST /realtime/channels/{channel}/publish", + "GET /realtime/channels/{channel}/presence", + "GET /realtime/channels/{channel}/history", + "POST /braid/publish", + "DELETE /braid/publish/{ns}", + "POST /av/remux", + "POST /av/demux", + "POST /moq/publish/{ns}/{track}", + "GET /moq/subscribe/{ns}/{track}", + "GET /identity/resolve", + "GET /pricing/manifests", + "POST /pricing/manifests", + "POST /custody/{op}", + "GET /engine/capabilities", + "POST /gpu/infer", + "GET /gpu/infer" + ], + "operationIdsSynthesized": 0, + "errorResponsesInjected": 303 + }, + "normalizationRules": [ + "strip op['x-version'] and op['x-deprecation-policy'], which the service assigns onto every operation", + "drop op['description'] on BOTH sides when the published value is the versioning boilerplate, and count the repo descriptions it destroyed", + "strip op.operationId only when it equals the service synthesis of (path, method) and this repo has none", + "strip responses 400/401/403/404/429 only when they deep-equal the injected error envelope and this repo lacks the code", + "unwrap the auto-wrapped 200 only when it deep-equals the wrapping of this repo's 200", + "components.securitySchemes: NOT APPLICABLE — this comparator is operation-scoped and never reads components", + "root-level operations the service adds of its own accord are deliberately NOT normalized away: they surface as undocumented-live findings and must be allowlisted by path+method with a justification, so they stay visible" + ] +} From 087759f4e7912891b3d4531eba7010fa7515a375 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Thu, 3 Sep 2026 23:59:10 -0400 Subject: [PATCH 2/3] ci(drift): gate the committed contract-drift.json against openapi.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The committed receipt had no freshness check anywhere in CI. `unit` never opened the file, and `drift` wrote a fresh copy to /tmp and uploaded it as a build artifact without ever diffing it against the committed one — so the receipt could disagree with the openapi.yaml sitting beside it and nothing would say a word. Its own `about` field calls it "a dated receipt, not a live view", and for the PUBLISHED half that is right: the gateway moves on its own schedule and only the networked job can see it. The REPO half is different. openapi.yaml changes only by pull request, so a receipt that disagrees with it is not a dated view of a moving world — it is simply wrong, in a file a reader has no reason to distrust. Adds published-drift-freshness.mjs: offline, no network, no writes. It pins sources.repoOperationsDigest — a sha256 over one sorted line per operation, " \t" — which is exactly the repo-side input that decides how the report CLASSIFIES an operation. Add or remove one and it moves between directions; flip x-schema-status and a draft-suppressed entry becomes a finding. It deliberately does NOT cover edits inside an operation: those can only be settled against what the gateway serves, which is the networked drift job's daily question. The two compose and neither overclaims. Severity is keyed to whether an author can act. Clearing a stale verdict means regenerating, and that needs the network — failing a PR for it would smuggle back the exact coupling this workflow's header rejects. So on a PR the job warns and writes a step summary; on schedule and on pushes to main it goes red and files one tracking issue, matched by exact title, the same way `drift` does. Exit codes match the sibling script: 0 fresh, 2 stale, 1 UNKNOWN — a receipt that cannot be graded is never reported as fresh. Measured, not assumed: node --test over both suites is 28/28 green. Against the real 447 KB openapi.yaml and the real committed receipt, four mutations each exit 2 — operation added, removed, path renamed (a swap: 209 paths and 230 ops before and after), and one draft operation promoted. The last two are caught by the digest ALONE, with every count still agreeing, which is what proves the check is not just comparing headline numbers. The unmutated spec exits 0. actionlint is clean on the workflow. Also fixes the run command in both test docstrings: `node --test .github/scripts/` cannot work, because node's test discovery skips dot-directories — it fails on the path itself. The glob does. --- .github/scripts/published-drift-freshness.mjs | 183 ++++++++++++++++++ .../published-drift-freshness.test.mjs | 121 ++++++++++++ .github/scripts/published-drift.mjs | 16 +- .github/scripts/published-drift.test.mjs | 2 +- .../workflows/published-contract-drift.yml | 126 +++++++++++- contract-drift.json | 5 +- 6 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/published-drift-freshness.mjs create mode 100644 .github/scripts/published-drift-freshness.test.mjs diff --git a/.github/scripts/published-drift-freshness.mjs b/.github/scripts/published-drift-freshness.mjs new file mode 100644 index 0000000..c4d0cb7 --- /dev/null +++ b/.github/scripts/published-drift-freshness.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * published-drift-freshness.mjs — is the COMMITTED `contract-drift.json` still a receipt for the + * CURRENT `openapi.yaml`, or has the spec moved on underneath it? + * + * WHY THIS EXISTS AS A SEPARATE GATE. `contract-drift.json` is a point-in-time artifact: it says + * so in its own `about` field. That honesty covers the PUBLISHED half of the picture — the gateway + * can change under it at any moment, and only the scheduled networked job can see that. It does + * NOT cover the REPO half. openapi.yaml lives in this repo and changes only by pull request, so a + * receipt that disagrees with the spec sitting next to it is not "a dated view of a moving world", + * it is simply wrong, and wrong in a file a reader has no reason to distrust. Nothing else in this + * repo notices: the `unit` job never opens the committed file, and the `drift` job writes a FRESH + * copy to /tmp and uploads it as a build artifact without ever diffing it against the committed + * one. This script is that missing diff. + * + * OFFLINE, ALWAYS. It compares two files that are both in the checkout. There is no fetch here and + * there must never be one: the whole point is that this question is answerable from the diff alone, + * so it can run on the pull-request path without making an author depend on a network read. + * + * WHAT THE DIGEST COVERS — AND WHAT IT DELIBERATELY DOES NOT. + * The digest is taken over one line per operation, `" \t"`, sorted. + * That is exactly the repo-side input that decides how the report CLASSIFIES an operation: + * - adding or removing an operation moves it in or out of every direction at once; + * - flipping `x-schema-status` is the promote-out-of-draft transition that turns a suppressed + * `unpublished-repo` entry into a finding (see published-drift-compare.mjs on why `draft` + * suppresses). + * It does NOT cover edits INSIDE an operation — a changed parameter, a new response field. Those + * can change a `shared-drift` finding, and no offline check can settle them, because the answer + * depends on what the gateway serves. That question belongs to the networked `drift` job, which + * runs daily. The two compose: this one catches the half that is a property of the diff, that one + * catches the half that is a property of the world. Neither pretends to cover the other. + * + * EXIT CODES — the same contract as published-drift.mjs, for the same reason: a FAILED READ IS + * NEVER REPORTED AS "FRESH". + * 0 FRESH — the receipt's repo-side facts match openapi.yaml. + * 1 UNKNOWN — a file is missing, unparseable, or the receipt predates the digest field. A + * TOOLING failure. It says nothing about freshness and must go red without filing the routine + * staleness issue. + * 2 STALE — the receipt describes a different openapi.yaml than the one in this checkout. + * + * USAGE + * node .github/scripts/published-drift-freshness.mjs [openapi.yaml] [--receipt contract-drift.json] + * + * To clear a STALE verdict, regenerate the receipt (this DOES need the network, which is why + * clearing it is a deliberate act and not something CI does behind your back): + * node .github/scripts/published-drift.mjs openapi.yaml --out contract-drift.json + */ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { indexOperations } from './published-drift-compare.mjs'; + +export const EXIT_FRESH = 0; +export const EXIT_UNKNOWN = 1; +export const EXIT_STALE = 2; + +/** The one field this gate adds to the artifact. Named here so the generator and the check agree. */ +export const DIGEST_FIELD = 'repoOperationsDigest'; + +/** + * The repo-side facts a receipt claims, recomputed from the spec. Pure: no I/O, no clock. + * `paths` and `operations` mirror published-drift-compare.mjs's headline exactly, so a receipt and + * a fresh recomputation are comparing like with like. + */ +export function repoFacts(repoDoc) { + const ops = indexOperations(repoDoc); + const lines = [...ops.entries()] + .map(([key, { op }]) => `${key}\t${op?.['x-schema-status'] ?? '-'}`) + .sort(); + return { + version: repoDoc?.info?.version ?? null, + paths: Object.keys(repoDoc?.paths ?? {}).length, + operations: ops.size, + digest: createHash('sha256').update(lines.join('\n')).digest('hex'), + }; +} + +/** + * Compare recomputed facts against a parsed receipt. Returns `{ status, reasons }` where status is + * one of 'fresh' | 'stale' | 'unknown'. Pure, so the tests can drive it without touching disk. + */ +export function checkFreshness(repoDoc, receipt) { + if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) { + return { status: 'unknown', reasons: ['the receipt is not a JSON object'] }; + } + const headline = receipt.headline; + if (!headline || typeof headline !== 'object') { + return { status: 'unknown', reasons: ['the receipt has no "headline" object'] }; + } + const recorded = receipt.sources?.[DIGEST_FIELD]; + if (typeof recorded !== 'string' || recorded.length === 0) { + return { + status: 'unknown', + reasons: [ + `the receipt carries no sources.${DIGEST_FIELD} — it predates this check. Regenerate it ` + + 'once with published-drift.mjs and the field will be there from then on.', + ], + }; + } + + const facts = repoFacts(repoDoc); + const reasons = []; + const compare = (label, mine, theirs) => { + if (mine !== theirs) reasons.push(`${label}: the spec says ${mine}, the receipt records ${theirs}`); + }; + compare('info.version', facts.version, headline.repoVersion); + compare('path count', facts.paths, headline.repoPaths); + compare('operation count', facts.operations, headline.repoOperations); + if (facts.digest !== recorded) { + reasons.push( + `operation digest: the spec hashes to ${facts.digest}, the receipt records ${recorded} ` + + '(an operation was added, removed, or promoted out of draft)', + ); + } + return { status: reasons.length ? 'stale' : 'fresh', reasons }; +} + +export function parseArgs(argv) { + const args = { spec: null, receipt: 'contract-drift.json' }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--receipt') args.receipt = argv[++i]; + else if (!a.startsWith('--') && args.spec === null) args.spec = a; + } + args.spec ??= 'openapi.yaml'; + return args; +} + +export async function main(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + + let repoDoc; + try { + const yaml = await import('js-yaml'); + repoDoc = (yaml.default ?? yaml).load(readFileSync(args.spec, 'utf8')); + } catch (err) { + console.error(`published-drift-freshness: could not read/parse ${args.spec}: ${err.message}`); + return EXIT_UNKNOWN; + } + if (!repoDoc?.paths || typeof repoDoc.paths !== 'object') { + console.error(`published-drift-freshness: ${args.spec} has no usable "paths" object`); + return EXIT_UNKNOWN; + } + + let receipt; + try { + receipt = JSON.parse(readFileSync(args.receipt, 'utf8')); + } catch (err) { + console.error(`published-drift-freshness: could not read/parse ${args.receipt}: ${err.message}`); + return EXIT_UNKNOWN; + } + + const { status, reasons } = checkFreshness(repoDoc, receipt); + + if (status === 'unknown') { + for (const r of reasons) console.error(`published-drift-freshness: UNKNOWN — ${r}`); + return EXIT_UNKNOWN; + } + if (status === 'stale') { + console.error( + `published-drift-freshness: STALE — ${args.receipt} was generated at ` + + `${receipt.generatedAt ?? 'an unrecorded time'} and no longer describes ${args.spec}.`, + ); + for (const r of reasons) console.error(`published-drift-freshness: - ${r}`); + console.error( + 'published-drift-freshness: regenerate it with ' + + `\`node .github/scripts/published-drift.mjs ${args.spec} --out ${args.receipt}\` (needs network).`, + ); + return EXIT_STALE; + } + + const facts = repoFacts(repoDoc); + console.log( + `published-drift-freshness: FRESH — ${args.receipt} matches ${args.spec} ` + + `(${facts.version}, ${facts.paths} paths / ${facts.operations} ops, digest ${facts.digest.slice(0, 12)}…).`, + ); + return EXIT_FRESH; +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + process.exitCode = await main(); +} diff --git a/.github/scripts/published-drift-freshness.test.mjs b/.github/scripts/published-drift-freshness.test.mjs new file mode 100644 index 0000000..1399b08 --- /dev/null +++ b/.github/scripts/published-drift-freshness.test.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/** + * published-drift-freshness.test.mjs — offline, deterministic, zero network. + * + * Its own file rather than more tests in published-drift.test.mjs, because it has its own subject: + * that file tests the COMPARISON against the published contract, this one tests whether the + * COMMITTED receipt still describes the spec beside it. Different module, different question. + * + * The load-bearing test here is the operation SWAP: two specs with identical version, identical + * path count and identical operation count, differing only in WHICH operations they declare. A + * freshness check built on the headline numbers alone would call that fresh. It is the one case + * that proves the digest is doing real work. + * + * Run: node --test .github/scripts/*.test.mjs + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { compare } from './published-drift-compare.mjs'; +import { DIGEST_FIELD, EXIT_UNKNOWN, checkFreshness, main, repoFacts } from './published-drift-freshness.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..', '..'); + +const doc = (paths, version = '1.0.0') => ({ openapi: '3.1.0', info: { title: 't', version }, paths }); + +/** A receipt carrying exactly the repo-side facts the given spec actually has. */ +const receiptFor = (spec, over = {}) => { + const f = repoFacts(spec); + return { + generatedAt: '2026-01-01T00:00:00.000Z', + sources: { repoSpec: 'openapi.yaml', [DIGEST_FIELD]: f.digest }, + headline: { repoVersion: f.version, repoPaths: f.paths, repoOperations: f.operations }, + ...over, + }; +}; + +// ── The facts this check recomputes must be the same facts the report publishes. ──────────────── +test('repoFacts counts agree with the headline compare() publishes, so the two cannot diverge', () => { + const spec = doc({ '/a': { get: {}, post: {} }, '/b': { get: { 'x-schema-status': 'draft' } } }, '9.9.9'); + const facts = repoFacts(spec); + const { headline } = compare({ repoDoc: spec, liveDoc: doc({}), allowlist: [] }); + assert.equal(facts.version, headline.repoVersion); + assert.equal(facts.paths, headline.repoPaths); + assert.equal(facts.operations, headline.repoOperations); +}); + +test('a receipt generated from the same spec reads FRESH', () => { + const spec = doc({ '/a': { get: {} } }); + assert.deepEqual(checkFreshness(spec, receiptFor(spec)), { status: 'fresh', reasons: [] }); +}); + +// ── The load-bearing case. ────────────────────────────────────────────────────────────────────── +test('swapping one operation for another keeps every count identical and is STILL caught', () => { + const before = doc({ '/a': { get: {} }, '/b': { get: {} } }); + const after = doc({ '/a': { get: {} }, '/c': { get: {} } }); + + const bf = repoFacts(before); + const af = repoFacts(after); + assert.equal(bf.version, af.version); + assert.equal(bf.paths, af.paths, 'the fixture must keep path counts equal or it proves nothing'); + assert.equal(bf.operations, af.operations, 'the fixture must keep operation counts equal or it proves nothing'); + assert.notEqual(bf.digest, af.digest); + + const verdict = checkFreshness(after, receiptFor(before)); + assert.equal(verdict.status, 'stale'); + assert.equal(verdict.reasons.length, 1, 'every count agrees, so the digest must be the SOLE reason'); + assert.match(verdict.reasons[0], /operation digest/); +}); + +test('promoting an operation out of draft is caught, because draft status decides its classification', () => { + // published-drift-compare.mjs suppresses an unpublished-repo operation ONLY while it is draft. + // Flipping that bit changes the report's verdict without changing any count, so the digest has + // to cover it or a promoted operation would be misreported by a receipt that still looks current. + const draft = doc({ '/a': { get: { 'x-schema-status': 'draft' } } }); + const promoted = doc({ '/a': { get: {} } }); + assert.notEqual(repoFacts(draft).digest, repoFacts(promoted).digest); + assert.equal(checkFreshness(promoted, receiptFor(draft)).status, 'stale'); +}); + +test('an added operation, a removed one, and a bumped version each read STALE', () => { + const spec = doc({ '/a': { get: {} }, '/b': { get: {} } }); + const receipt = receiptFor(spec); + assert.equal(checkFreshness(doc({ '/a': { get: {} }, '/b': { get: {} }, '/c': { get: {} } }), receipt).status, 'stale'); + assert.equal(checkFreshness(doc({ '/a': { get: {} } }), receipt).status, 'stale'); + assert.equal(checkFreshness(doc({ '/a': { get: {} }, '/b': { get: {} } }, '2.0.0'), receipt).status, 'stale'); +}); + +// ── Exit contract: a receipt that cannot be graded is NEVER a pass. ───────────────────────────── +test('an ungradable receipt is UNKNOWN, never FRESH', () => { + const spec = doc({ '/a': { get: {} } }); + const noDigest = receiptFor(spec); + delete noDigest.sources[DIGEST_FIELD]; + // The bootstrap case: a receipt written before this check existed. UNKNOWN, not FRESH — claiming + // freshness for a receipt with nothing to compare against is the exact false pass this avoids. + assert.equal(checkFreshness(spec, noDigest).status, 'unknown'); + assert.equal(checkFreshness(spec, { ...receiptFor(spec), headline: undefined }).status, 'unknown'); + assert.equal(checkFreshness(spec, null).status, 'unknown'); + assert.equal(checkFreshness(spec, []).status, 'unknown'); +}); + +test('main() exits UNKNOWN on an unreadable spec or receipt, never FRESH', async () => { + assert.equal(await main(['/nonexistent/openapi.yaml', '--receipt', '/nonexistent/r.json']), EXIT_UNKNOWN); + assert.equal(await main([join(REPO_ROOT, 'openapi.yaml'), '--receipt', '/nonexistent/r.json']), EXIT_UNKNOWN); +}); + +// ── The shipped artifact itself is the subject. ───────────────────────────────────────────────── +test('the COMMITTED contract-drift.json is a gradable receipt for the COMMITTED openapi.yaml', async () => { + // Asserts the receipt is well-formed and carries a digest — deliberately NOT that it is current. + // Clearing a stale verdict needs a networked regeneration, and requiring that on the + // pull-request path is exactly the coupling published-contract-drift.yml refuses to reintroduce. + // Currency is the freshness job's call: advisory on a PR, red on the schedule. + const yaml = await import('js-yaml'); + const spec = (yaml.default ?? yaml).load(readFileSync(join(REPO_ROOT, 'openapi.yaml'), 'utf8')); + const receipt = JSON.parse(readFileSync(join(REPO_ROOT, 'contract-drift.json'), 'utf8')); + assert.match(receipt.sources?.[DIGEST_FIELD] ?? '', /^[0-9a-f]{64}$/, 'the digest must be a sha256 hex string'); + assert.notEqual(checkFreshness(spec, receipt).status, 'unknown', 'the committed receipt must at least be gradable'); +}); diff --git a/.github/scripts/published-drift.mjs b/.github/scripts/published-drift.mjs index c1c142e..c199049 100644 --- a/.github/scripts/published-drift.mjs +++ b/.github/scripts/published-drift.mjs @@ -51,6 +51,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; import { compare, indexOperations, validateAllowlist } from './published-drift-compare.mjs'; +import { DIGEST_FIELD, repoFacts } from './published-drift-freshness.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -189,10 +190,21 @@ export async function main(argv = process.argv.slice(2)) { 'Point-in-time operation-level diff between this repo\'s openapi.yaml and the contract the gateway publishes. ' + 'It is a dated receipt, not a live view: regenerate with ' + '`node .github/scripts/published-drift.mjs openapi.yaml --out contract-drift.json`. ' + - 'The published-contract-drift workflow uploads a fresh copy on every scheduled run.', + 'The published-contract-drift workflow uploads a fresh copy on every scheduled run. ' + + 'The PUBLISHED half of this receipt ages on the gateway\'s schedule and only the scheduled ' + + 'drift job can refresh it; the REPO half is pinned by ' + + `sources.${DIGEST_FIELD}, which the freshness job checks offline on every run so this file ` + + 'cannot quietly disagree with the openapi.yaml sitting next to it.', generatedAt: new Date().toISOString(), criterion: ['CONTRACT-001', 'COMPAT-001', 'API-001'], - sources: { repoSpec: args.spec, repoCommit: process.env.GITHUB_SHA ?? null, publishedSpec: source }, + // repoOperationsDigest pins the repo-side input this report consumed, so published-drift-freshness.mjs + // can tell offline whether the spec has moved since. See that file for what the digest covers. + sources: { + repoSpec: args.spec, + repoCommit: process.env.GITHUB_SHA ?? null, + publishedSpec: source, + [DIGEST_FIELD]: repoFacts(repoDoc).digest, + }, ...result, }; if (args.out) writeFileSync(args.out, `${JSON.stringify(artifact, null, 2)}\n`); diff --git a/.github/scripts/published-drift.test.mjs b/.github/scripts/published-drift.test.mjs index d047cb2..3d886af 100644 --- a/.github/scripts/published-drift.test.mjs +++ b/.github/scripts/published-drift.test.mjs @@ -8,7 +8,7 @@ * exact enrichment observed in the published document, so the normalizer is tested against the * shape it actually has to undo. * - * Run: node --test .github/scripts/ + * Run: node --test .github/scripts/*.test.mjs */ import test from 'node:test'; import assert from 'node:assert/strict'; diff --git a/.github/workflows/published-contract-drift.yml b/.github/workflows/published-contract-drift.yml index f95cea4..804480f 100644 --- a/.github/workflows/published-contract-drift.yml +++ b/.github/workflows/published-contract-drift.yml @@ -14,6 +14,20 @@ # the serving repo already fixed by moving its own spec-drift check to a cron, and this file # follows that precedent rather than reintroducing the defect here. # +# `freshness` runs EVERYWHERE, because it is offline. It asks the one question the other two do +# not: is the COMMITTED contract-drift.json still a receipt for the CURRENT openapi.yaml? `unit` +# never opens that file, and `drift` writes a fresh copy to /tmp and uploads it as a build +# artifact without ever diffing it against the committed one — so before this job, the committed +# receipt could disagree with the spec sitting beside it and nothing would say a word. +# +# WHY `freshness` IS ADVISORY ON A PULL REQUEST AND RED ON THE SCHEDULE. Clearing a stale verdict +# means regenerating the receipt, and THAT needs the network. Failing a pull request for it would +# reintroduce, by the back door, exactly the coupling this file rejects two paragraphs above: a +# spec author blocked on an unauthenticated fetch. So on a PR the job posts a warning and a step +# summary — an automated nudge, on the diff that caused it, where the author will see it. On the +# schedule and on pushes to main there is no author to block and the receipt is simply wrong in +# the default branch, so it goes red and files the same one-tracking-issue way `drift` does. +# # WHY THIS IS A NEW FILE AND NOT A JOB IN foundation-gate.yml: foundation-gate.yml mirrors # wave-foundation's checks.yml and is contended by several in-flight branches. A gate that is # specific to this repo's own published artifact does not belong inside the mirrored file. @@ -31,11 +45,18 @@ on: - '.github/scripts/published-drift*' - '.github/workflows/published-contract-drift.yml' - 'openapi.yaml' + # The committed receipt is a subject of this workflow, not just an output of it: a PR that + # regenerates it alone must still be graded. + - 'contract-drift.json' push: branches: [main] paths: - '.github/scripts/published-drift*' - '.github/workflows/published-contract-drift.yml' + # openapi.yaml belongs here too — a merged spec change is precisely what strands the receipt, + # and on main there is no PR left to carry the warning. + - 'openapi.yaml' + - 'contract-drift.json' schedule: # 07:10 UTC daily — deliberately after the serving repo's own pin-staleness cron (06:40 UTC), # so that picture is already fresh when this asks the published-contract question. @@ -68,8 +89,111 @@ jobs: # js-yaml is installed explicitly rather than leaned on transitively, for the same reason # foundation-gate.yml does it: nothing this repo depends on is otherwise importable here. run: npm install --no-save --no-audit --no-fund js-yaml@4.1.0 + # The glob, not the directory: node's test-runner discovery skips dot-directories, so + # `node --test .github/scripts/` finds nothing and exits non-zero on the path itself. - name: node --test - run: node --test .github/scripts/published-drift.test.mjs + run: node --test .github/scripts/*.test.mjs + + # Offline. Runs on every trigger — see the header for why its severity depends on which one. + freshness: + name: committed receipt freshness (offline) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # Required to file/update the tracking issue on the schedule/push path. On a pull_request the + # issue step never runs, and for a fork PR the token is read-only regardless. + issues: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + - name: Install tooling + run: npm install --no-save --no-audit --no-fund js-yaml@4.1.0 + + - name: Is contract-drift.json still a receipt for openapi.yaml? + id: fresh + run: | + set +e + node .github/scripts/published-drift-freshness.mjs openapi.yaml --receipt contract-drift.json 2>&1 | tee /tmp/freshness.log + code=${PIPESTATUS[0]} + set -e + echo "code=$code" >> "$GITHUB_OUTPUT" + echo "exit code: $code" + + # exit 1 = UNKNOWN. A broken read is a TOOLING failure: go red everywhere, file nothing. + - name: Fail loudly on a broken read + if: steps.fresh.outputs.code == '1' + run: | + echo "::error::published-drift-freshness could not grade the committed receipt." + echo "This says NOTHING about whether it is stale. Fix the read, then re-run." + exit 1 + + # exit 2 on a PR = ADVISORY. The author cannot regenerate without the network; say so where + # they will see it and let the PR stay green. + - name: Warn that the committed receipt is stale + if: steps.fresh.outputs.code == '2' && github.event_name == 'pull_request' + run: | + echo "::warning file=contract-drift.json::This PR moves openapi.yaml past the committed contract-drift.json. Regenerate it with 'node .github/scripts/published-drift.mjs openapi.yaml --out contract-drift.json' (needs network) when convenient — the daily job will chase it otherwise." + { + echo '### Committed receipt is stale' + echo + echo 'This is advisory, not a blocker — regenerating needs a network fetch of the published contract.' + echo + echo '```' + cat /tmp/freshness.log + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # exit 2 off the PR path = the default branch carries a wrong receipt. File or update exactly + # one tracking issue, matched by exact title so repeated runs comment instead of piling up. + # + # As in the drift job, nothing PR- or issue-controlled is interpolated into a shell command: + # the title is a fixed literal, the body is written to a file and passed with --body-file, and + # the issue number comes from a --jq filter over `gh issue list` output. The only interpolated + # values are $GITHUB_REPOSITORY, $GITHUB_RUN_ID and $GITHUB_SHA, which GitHub sets. + - name: File or update the tracking issue + if: steps.fresh.outputs.code == '2' && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + TITLE: 'contract-drift.json no longer matches openapi.yaml' + run: | + { + echo "The committed \`contract-drift.json\` records a different \`openapi.yaml\` than the one on the default branch, so it is a receipt for a spec that no longer exists." + echo + echo "Run: [\`$GITHUB_RUN_ID\`](https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID) · commit \`$GITHUB_SHA\`" + echo + echo '```' + cat /tmp/freshness.log + echo '```' + echo + echo "To clear this, regenerate the receipt and commit it:" + echo '```' + echo "node .github/scripts/published-drift.mjs openapi.yaml --out contract-drift.json" + echo '```' + echo "That fetches the published contract, so it needs network access — which is why CI nudges rather than doing it for you." + } > /tmp/issue-body.md + + existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --limit 100 \ + --json number,title --jq "map(select(.title == \$ENV.TITLE)) | .[0].number // empty") + + if [ -n "$existing" ]; then + gh issue comment "$existing" --repo "$GITHUB_REPOSITORY" --body-file /tmp/issue-body.md + echo "Commented on existing issue #$existing" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file /tmp/issue-body.md + fi + + # Red on the default branch too, so a silenced or rate-limited issue write can never make a + # stranded receipt look green. + - name: Fail on a stale receipt + if: steps.fresh.outputs.code == '2' && github.event_name != 'pull_request' + run: | + echo "::error::contract-drift.json no longer describes openapi.yaml. A tracking issue was filed or updated." + exit 1 # Networked. Scheduled and manual only. drift: diff --git a/contract-drift.json b/contract-drift.json index 4e89b76..6ccd2aa 100644 --- a/contract-drift.json +++ b/contract-drift.json @@ -1,5 +1,5 @@ { - "about": "Point-in-time operation-level diff between this repo's openapi.yaml and the contract the gateway publishes. It is a dated receipt, not a live view: regenerate with `node .github/scripts/published-drift.mjs openapi.yaml --out contract-drift.json`. The published-contract-drift workflow uploads a fresh copy on every scheduled run.", + "about": "Point-in-time operation-level diff between this repo's openapi.yaml and the contract the gateway publishes. It is a dated receipt, not a live view: regenerate with `node .github/scripts/published-drift.mjs openapi.yaml --out contract-drift.json`. The published-contract-drift workflow uploads a fresh copy on every scheduled run. The PUBLISHED half of this receipt ages on the gateway's schedule and only the scheduled drift job can refresh it; the REPO half is pinned by sources.repoOperationsDigest, which the freshness job checks offline on every run so this file cannot quietly disagree with the openapi.yaml sitting next to it.", "generatedAt": "2026-09-04T00:02:48.265Z", "criterion": [ "CONTRACT-001", @@ -9,7 +9,8 @@ "sources": { "repoSpec": "openapi.yaml", "repoCommit": "9ffe7c0a8eb400ad796ec120f7c4d489ac373479", - "publishedSpec": "https://api.wave.online/openapi.json" + "publishedSpec": "https://api.wave.online/openapi.json", + "repoOperationsDigest": "f332e1622a53d2b4a50051fc54bacd7131a1b5fae2ed887dc1830e0fc3b298df" }, "headline": { "repoVersion": "1.1.0", From 6f249df06affd5eb809eb1224fe863917c533070 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Fri, 4 Sep 2026 09:38:11 -0400 Subject: [PATCH 3/3] fix(drift): close the twelve review findings on the published-contract gate Every finding was verified against the code before being fixed; none waved through, none resolved without a change. The load-bearing one is measured live. DOCUMENT-LEVEL AUTH DEFEATED THE EXEMPTION GUARD (measured, not hypothetical). All three live-surface exemptions carry expectAbsent: ["security"], and their justifications promise the exemption lapses "the moment the operation gains a security requirement". allowlistStillApplies read only the operation object. OpenAPI makes a document-level `security` the default for any operation without its own, so auth arriving at the root was invisible to the guard. Fetched the published contract on 2026-09-04: the document declares a root `security`, GET /platform and GET /usage carry their own (correctly lapsed), and GET /leaderboard carries none - so it inherited auth and its exemption silently survived the exact event it was written to catch. Same snapshot, before vs after: undocumented-live 3 -> 4, allowlisted 1 -> 0. Exit was already 2 both ways, so this reddens nothing new; it makes the count honest. Also fixed: - unpublished-repo exemptions were inert. record() passes null as liveOp for that direction and allowlistStillApplies returned false for every entry, so the direction was configurable but could never be honored - and the entry was reported with a reason naming a live operation that never existed. A predicate-free entry is now honored; one stating an unevaluable predicate lapses with an accurate reason, and validateAllowlist rejects that combination. - validateAllowlist did not require the predicate the file header promises. An entry with neither expect nor expectAbsent passed validation and was then honored on path+method alone - an exemption that could never lapse. - Allowlist entries matching no operation were invisible. allowByKey is consulted only from record(), so a dead entry appeared in neither `allowlisted` nor `lapsedAllowlistEntries` and read as "no allowlist problem" forever. Now surfaced as unmatchedAllowlist plus a ::warning::, not failed: stale bookkeeping is not drift, and reddening for it trains people to ignore it. - Redirects are no longer followed. redirect: 'follow' let whatever answers the published URL choose this CI job's next destination. Measured: the endpoint answers HTTP/2 200 directly, so refusing redirects breaks nothing that works today, and a bounce now reads as EXIT_UNKNOWN rather than a verdict. - --json wrote the artifact and the human report to the same stdout, so the stream was unparseable. Verified: piping --json into a parser fails before, succeeds after. The report moves to stderr rather than being dropped, so the CI annotations survive. - --live/--out/--receipt with no value. Bare --live silently took the NETWORK branch, the one branch --live is typed to avoid (verified against the old code). Bare --receipt surfaced as "could not read/parse undefined", sending the reader after a missing file rather than a missing argument. Both are usage errors returning EXIT_UNKNOWN now. - A malformed digest was classified STALE, not UNKNOWN. Any non-empty string passed, so a merge marker or a truncated paste read as "the spec moved" and filed the routine staleness issue. STALE and UNKNOWN drive different CI behaviour, so that was a false report. Now requires 64 lowercase hex. - Both workflow gates were FAIL-OPEN. Each per-code step enumerates only 1 and 2, so an interpreter crash, an OOM kill, or a code added later matched no step and left the job GREEN. A gate that fails open is worse than no gate because it is trusted. Both jobs now carry an explicit inverse default case. - Neither tracking issue was ever closed. An open issue asserts the default branch is currently broken; nothing retracted it, so it outlived its cause and the next real failure would arrive as a comment on an issue people had learned to ignore. Both jobs now own the full lifecycle of their own issue, matched on the same exact literal titles they file under. Tests: 30 -> 39, all offline. The exemption lifecycle moves to its own file (published-drift-allowlist.test.mjs) - a real seam, and the old file was doing two jobs. No assertion was loosened: the validateAllowlist fixture gained the predicate the stricter rule now requires, and the rule itself is asserted. Measured: node --test .github/scripts/*.test.mjs -> 39/39 pass actionlint .github/workflows/published-contract-drift.yml -> clean published-drift-freshness.mjs openapi.yaml --receipt contract-drift.json -> FRESH (exit 0) Co-Authored-By: Claude Opus 5 (1M context) --- .../published-drift-allowlist.test.mjs | 214 ++++++++++++++++++ .github/scripts/published-drift-compare.mjs | 86 ++++++- .github/scripts/published-drift-freshness.mjs | 40 +++- .../published-drift-freshness.test.mjs | 34 ++- .github/scripts/published-drift.mjs | 74 +++++- .github/scripts/published-drift.test.mjs | 161 +++++++------ .../workflows/published-contract-drift.yml | 96 +++++++- 7 files changed, 603 insertions(+), 102 deletions(-) create mode 100644 .github/scripts/published-drift-allowlist.test.mjs diff --git a/.github/scripts/published-drift-allowlist.test.mjs b/.github/scripts/published-drift-allowlist.test.mjs new file mode 100644 index 0000000..fb53d35 --- /dev/null +++ b/.github/scripts/published-drift-allowlist.test.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +/** + * published-drift-allowlist.test.mjs — the EXEMPTION LIFECYCLE, split out of published-drift.test.mjs. + * + * One responsibility: how an allowlist entry is granted, honored, and — the part that matters — + * how it LAPSES. An exemption is the only way a finding in this gate can be silenced, so the rules + * that keep one honest are worth testing as their own surface rather than as a section of the + * comparison tests. published-drift.test.mjs keeps normalization, indexing and the CLI exit + * contract; nothing is dropped in the move. + * + * Offline, deterministic, zero network. Run: node --test .github/scripts/*.test.mjs + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { allowlistStillApplies, compare, validateAllowlist } from './published-drift-compare.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** The same minimal document helper the comparison tests use. */ +const doc = (paths, version = '1.0.0') => ({ openapi: '3.1.0', info: { title: 't', version }, paths }); + +// ── Direction: undocumented-live, the security-relevant one. ──────────────────────────────────── +const injectedPublicOp = { + summary: 'LIVE inference funnel usage (registry-grounded, GROUP BY model, spend to 8 decimals)', + tags: ['public'], + responses: { 200: { description: 'ok' } }, +}; + +test('an operation served live but absent from the spec is a security-relevant finding', () => { + const r = compare({ repoDoc: doc({}), liveDoc: doc({ '/usage': { get: injectedPublicOp } }) }); + assert.equal(r.headline.undocumentedLive, 1); + assert.equal(r.findings[0].severity, 'security-relevant'); + assert.equal(r.findings[0].method, 'GET'); + assert.equal(r.findings[0].path, '/usage'); +}); + +test('an allowlist entry suppresses it — and LAPSES the moment the operation gains auth', () => { + const allowlist = [ + { + path: '/usage', + method: 'GET', + direction: 'undocumented-live', + justification: 'Gateway-native public root surface, exempt only while it stays unauthenticated.', + expect: { 'tags.0': 'public' }, + expectAbsent: ['security'], + }, + ]; + const clean = compare({ repoDoc: doc({}), liveDoc: doc({ '/usage': { get: injectedPublicOp } }), allowlist }); + assert.equal(clean.headline.undocumentedLive, 0); + assert.equal(clean.headline.allowlisted, 1); + + const behindAuth = { ...injectedPublicOp, security: [{ bearerWithScopes: ['usage:read'] }] }; + const lapsed = compare({ repoDoc: doc({}), liveDoc: doc({ '/usage': { get: behindAuth } }), allowlist }); + assert.equal(lapsed.headline.undocumentedLive, 1, 'the exemption was granted for the unauthenticated shape only'); + assert.equal(lapsed.headline.lapsedAllowlistEntries, 1); +}); + +test('an allowlist entry does not leak across directions', () => { + const allowlist = [ + { path: '/x', method: 'POST', direction: 'unpublished-repo', justification: 'A justification long enough to pass.', expect: {} }, + ]; + const r = compare({ repoDoc: doc({}), liveDoc: doc({ '/x': { post: { responses: {} } } }), allowlist }); + assert.equal(r.headline.undocumentedLive, 1, 'an unpublished-repo exemption must not silence an undocumented-live finding'); +}); + +test('a null expectation matches an absent key as well as a literal null', () => { + assert.equal(allowlistStillApplies({ expect: { security: null } }, { tags: ['public'] }), true); + assert.equal(allowlistStillApplies({ expect: { security: null } }, { security: null }), true); + assert.equal(allowlistStillApplies({ expect: { security: null } }, { security: [] }), false); +}); + +// ── Allowlist hygiene. ────────────────────────────────────────────────────────────────────────── +test('validateAllowlist rejects the ways an exemption goes bad', () => { + const ok = { + path: '/a', + method: 'GET', + direction: 'undocumented-live', + justification: 'A justification long enough.', + expectAbsent: ['security'], + }; + assert.equal(validateAllowlist([ok]), null); + assert.match(validateAllowlist({}), /not an array/); + assert.match(validateAllowlist([{ ...ok, justification: 'too short' }]), /needs a real justification/); + assert.match(validateAllowlist([{ ...ok, direction: 'whatever' }]), /unknown direction/); + assert.match(validateAllowlist([ok, ok]), /duplicate allowlist entry/); +}); + +test('a live-direction exemption without a predicate is rejected — it could never lapse', () => { + // The file header promises "a justification AND a live predicate". Before this, only the + // justification was enforced: a predicate-free entry validated, then allowlistStillApplies + // returned true on a path+method match alone, and the exemption outlived its own reasoning. + const bare = { path: '/a', method: 'GET', direction: 'undocumented-live', justification: 'A justification long enough.' }; + assert.match(validateAllowlist([bare]), /needs an expect or expectAbsent predicate/); + assert.match(validateAllowlist([{ ...bare, expect: {}, expectAbsent: [] }]), /needs an expect or expectAbsent predicate/); + assert.equal(validateAllowlist([{ ...bare, expect: { 'tags.0': 'public' } }]), null); + assert.equal(validateAllowlist([{ ...bare, expectAbsent: ['security'] }]), null); + // shared-drift also has a live operation, so the same requirement applies. + assert.match(validateAllowlist([{ ...bare, direction: 'shared-drift' }]), /needs an expect or expectAbsent predicate/); +}); + +test('an unpublished-repo exemption is rejected for CARRYING a predicate — there is nothing to evaluate it against', () => { + // The mirror image of the rule above. unpublished-repo means "declared here, not served", so + // there is no live operation; a predicate there can never be true and the entry, though it + // validates, would silently never apply. + const entry = { path: '/a', method: 'GET', direction: 'unpublished-repo', justification: 'A justification long enough.' }; + assert.equal(validateAllowlist([entry]), null); + assert.match(validateAllowlist([{ ...entry, expect: { 'tags.0': 'public' } }]), /cannot carry a predicate/); + assert.match(validateAllowlist([{ ...entry, expectAbsent: ['security'] }]), /cannot carry a predicate/); +}); + +test('an unpublished-repo exemption is HONORED, and is not reported with a live-operation reason', () => { + // `record` passes null as liveOp for this direction, so allowlistStillApplies used to return + // false for every entry: the direction was configurable but inert, and the entry landed in both + // lapsedAllowlist and findings with a reason that named a live operation that never existed. + const allowlist = [ + { path: '/legacy', method: 'POST', direction: 'unpublished-repo', justification: 'A justification long enough to pass.' }, + ]; + const repoDoc = doc({ '/legacy': { post: { responses: {} } } }); + const r = compare({ repoDoc, liveDoc: doc({ '/other': { get: { responses: {} } } }), allowlist }); + assert.equal(r.headline.unpublishedRepo, 0, 'a predicate-free unpublished-repo exemption must be honored'); + assert.equal(r.headline.allowlisted, 1); + assert.equal(r.headline.lapsedAllowlistEntries, 0); + + // An entry that DOES state a predicate cannot be graded, so it lapses — with an accurate reason. + const withPredicate = [{ ...allowlist[0], expectAbsent: ['security'] }]; + const lapsed = compare({ repoDoc, liveDoc: doc({ '/other': { get: { responses: {} } } }), allowlist: withPredicate }); + assert.equal(lapsed.headline.unpublishedRepo, 1); + assert.match(lapsed.lapsedAllowlist[0].reason, /no live operation/); + assert.doesNotMatch(lapsed.lapsedAllowlist[0].reason, /no longer matches/); +}); + +test('document-level security counts as the operation gaining auth', () => { + // THE LIVE CASE, measured against https://api.wave.online/openapi.json on 2026-09-04: the + // published document carries a root `security`, and GET /leaderboard has no `security` key of + // its own. Reading only the operation object called it unauthenticated and kept the exemption + // alive; OpenAPI says a document-level `security` is the default for exactly such an operation, + // so it is authenticated and the exemption — granted for the unauthenticated shape — must lapse. + const entry = { + path: '/leaderboard', + method: 'GET', + direction: 'undocumented-live', + justification: 'Public unauthenticated root surface; exempt only while it stays unauthenticated.', + expectAbsent: ['security'], + }; + const op = { tags: ['public'], responses: {} }; + const openDoc = doc({ '/leaderboard': { get: op } }); + assert.equal(allowlistStillApplies(entry, op, openDoc), true, 'no auth anywhere: the exemption holds'); + + const rootAuth = { ...openDoc, security: [{ BearerAuth: [] }] }; + assert.equal(allowlistStillApplies(entry, op, rootAuth), false, 'document-level auth must lapse it'); + + // And end to end, which is what the gate actually runs. + const r = compare({ repoDoc: doc({}), liveDoc: rootAuth, allowlist: [entry] }); + assert.equal(r.headline.undocumentedLive, 1, 'the operation is authenticated now, so it is a finding'); + assert.equal(r.headline.lapsedAllowlistEntries, 1); + + // An operation with its OWN security still wins over the document default, in both directions. + const ownAuth = doc({ '/leaderboard': { get: { ...op, security: [{ bearerWithScopes: [] }] } } }); + assert.equal(allowlistStillApplies(entry, ownAuth.paths['/leaderboard'].get, ownAuth), false); + const explicitlyOpen = { ...rootAuth, paths: { '/leaderboard': { get: { ...op, security: [] } } } }; + assert.equal( + allowlistStillApplies({ ...entry, expectAbsent: [], expect: { security: [] } }, explicitlyOpen.paths['/leaderboard'].get, explicitlyOpen), + true, + 'an operation that opts out with an empty security array is not inheriting the document default', + ); +}); + +test('an allowlist entry that matches no operation is surfaced, not silently ignored', () => { + // allowByKey is only ever consulted from `record`, so an entry whose operation is no longer + // served — or which openapi.yaml now documents — was never looked up and never counted. It + // appeared in neither `allowlisted` nor `lapsedAllowlistEntries`, so it read as "no allowlist + // problem" forever. + const allowlist = [ + { + path: '/gone', + method: 'GET', + direction: 'undocumented-live', + justification: 'A justification long enough to pass validation.', + expectAbsent: ['security'], + }, + ]; + const r = compare({ repoDoc: doc({}), liveDoc: doc({ '/still-here': { get: { responses: {} } } }), allowlist }); + assert.equal(r.headline.unmatchedAllowlistEntries, 1); + assert.deepEqual( + r.unmatchedAllowlist.map((e) => e.key), + ['undocumented-live GET /gone'], + ); + // It is stale bookkeeping, not drift: surfacing it must not manufacture a finding. + assert.equal(r.findings.some((f) => f.path === '/gone'), false); + + // A matched entry is not reported as unmatched. + const matched = compare({ + repoDoc: doc({}), + liveDoc: doc({ '/gone': { get: { responses: {} } } }), + allowlist, + }); + assert.equal(matched.headline.unmatchedAllowlistEntries, 0); + assert.equal(matched.headline.allowlisted, 1); +}); + +test('the COMMITTED allowlist is well-formed and every entry is a live-direction exemption with a predicate', () => { + const committed = JSON.parse(readFileSync(join(__dirname, 'published-drift-allowlist.json'), 'utf8')); + assert.equal(validateAllowlist(committed), null); + for (const e of committed) { + assert.equal(e.direction, 'undocumented-live', `${e.method} ${e.path}: only live surface should ever need an exemption`); + assert.ok(Object.keys(e.expect ?? {}).length > 0, `${e.method} ${e.path}: an exemption without a predicate cannot lapse`); + assert.ok(e.expectAbsent?.includes('security'), `${e.method} ${e.path}: must lapse when the route gains auth`); + } +}); + diff --git a/.github/scripts/published-drift-compare.mjs b/.github/scripts/published-drift-compare.mjs index 75aff0a..1998183 100644 --- a/.github/scripts/published-drift-compare.mjs +++ b/.github/scripts/published-drift-compare.mjs @@ -64,28 +64,57 @@ export function getPath(obj, path) { .reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj); } +/** + * The security requirement that ACTUALLY applies to an operation. + * + * OpenAPI 3.x makes a document-level `security` the DEFAULT for every operation that does not state + * its own. So an operation with no `security` key, in a document that has one, is authenticated — + * and reading only the operation object would call it unauthenticated. That distinction is the + * whole point of the `expectAbsent: ["security"]` guard on this repo's live-surface exemptions, + * whose justifications say in as many words that the exemption lapses "the moment the operation + * gains a security requirement". Auth arriving at the document root is that moment just as much as + * auth arriving on the operation, so it has to be resolved before the predicate is evaluated. + * + * Only `security` is inherited here. It is the one operation field OpenAPI defines as a whole-value + * document default; `parameters` and `servers` merge under different rules and no predicate in this + * repo depends on them. + */ +export function effectiveOperation(liveOp, liveDoc) { + if (!liveOp || liveOp.security !== undefined || liveDoc?.security === undefined) return liveOp; + return { ...liveOp, security: liveDoc.security }; +} + /** * An allowlist entry is honored ONLY while the live operation still matches every `expect` field * and carries none of the `expectAbsent` keys — the same idea as skills-index-coverage.mjs's * allowlistStillApplies(): an exemption that survives on a path match alone outlives its own * justification. * - * Two deliberate refinements over that function, both required for OpenAPI operation objects: + * Three deliberate refinements over that function, all required for OpenAPI operation objects: * - a `null` expectation matches an ABSENT key as well as a literal null, because for an * operation "no `security` key" and "`security: null`" make the same claim; * - `expectAbsent` names keys that must NOT appear. This is what makes an exemption granted for * an UNAUTHENTICATED public route lapse the moment that route gains a `security` requirement: * the exemption was reasoned about the unauthenticated shape and must not silently carry over - * to the authenticated one. + * to the authenticated one. `liveDoc` is read so that document-level auth counts as gaining it + * (see effectiveOperation); + * - the `unpublished-repo` direction has NO live operation by construction — the operation is + * declared here and not served — so there is nothing for a predicate to match. A predicate-free + * entry there is still a valid exemption and is honored; one that states a predicate cannot be + * graded at all and lapses rather than being honored blind. validateAllowlist rejects that + * combination up front, so this branch is the belt to its braces. */ -export function allowlistStillApplies(entry, liveOp) { - if (!liveOp) return false; - for (const [path, expected] of Object.entries(entry.expect ?? {})) { - const actual = getPath(liveOp, path); +export function allowlistStillApplies(entry, liveOp, liveDoc) { + const expect = entry.expect ?? {}; + const expectAbsent = entry.expectAbsent ?? []; + if (!liveOp) return Object.keys(expect).length === 0 && expectAbsent.length === 0; + const op = effectiveOperation(liveOp, liveDoc); + for (const [path, expected] of Object.entries(expect)) { + const actual = getPath(op, path); if (expected === null ? actual !== null && actual !== undefined : !isDeepStrictEqual(actual, expected)) return false; } - for (const key of entry.expectAbsent ?? []) { - if (getPath(liveOp, key) !== undefined) return false; + for (const key of expectAbsent) { + if (getPath(op, key) !== undefined) return false; } return true; } @@ -101,6 +130,17 @@ export function validateAllowlist(allowlist) { return `allowlist entry ${e.method} ${e.path} needs a real justification (>=20 chars)`; if (!DIRECTIONS.includes(e.direction)) return `allowlist entry ${e.method} ${e.path} has an unknown direction ${JSON.stringify(e.direction)}`; + const hasPredicate = Object.keys(e.expect ?? {}).length > 0 || (e.expectAbsent ?? []).length > 0; + // The header of this file promises that a live-direction exemption carries "a justification AND + // a live predicate". Enforce the second half: without a predicate the entry is honored on + // path+method alone, can never lapse, and outlives the reasoning that granted it — the exact + // failure `expectAbsent` exists to prevent. + if (e.direction !== 'unpublished-repo' && !hasPredicate) + return `allowlist entry ${e.method} ${e.path} (${e.direction}) needs an expect or expectAbsent predicate — an exemption that cannot lapse outlives its justification`; + // The mirror image. `unpublished-repo` has no live operation to evaluate against, so a predicate + // there can never be true; the entry would validate, then silently never apply. + if (e.direction === 'unpublished-repo' && hasPredicate) + return `allowlist entry ${e.method} ${e.path} (unpublished-repo) cannot carry a predicate — the operation is not served, so there is no live operation to evaluate one against`; const key = `${e.direction} ${e.method.toUpperCase()} ${e.path}`; if (seen.has(key)) return `duplicate allowlist entry for ${key}`; seen.add(key); @@ -119,10 +159,16 @@ export function compare({ repoDoc, liveDoc, allowlist = [], normalize = true }) const draftNotYetPublished = []; const enrichmentObservations = { descriptionsOverwritten: [], operationIdsSynthesized: 0, errorResponsesInjected: 0 }; + // Every allowlist key `record` actually consults. What is left over at the end is an exemption + // that matched nothing — see unmatchedAllowlist below. + const usedAllowKeys = new Set(); + const record = (direction, path, method, entry, detail, liveOp) => { - const allow = allowByKey.get(`${direction} ${method.toUpperCase()} ${path}`); + const allowKey = `${direction} ${method.toUpperCase()} ${path}`; + const allow = allowByKey.get(allowKey); if (allow) { - if (allowlistStillApplies(allow, liveOp)) { + usedAllowKeys.add(allowKey); + if (allowlistStillApplies(allow, liveOp, liveDoc)) { allowlisted.push({ ...entry, direction, justification: allow.justification }); return; } @@ -130,7 +176,12 @@ export function compare({ repoDoc, liveDoc, allowlist = [], normalize = true }) ...entry, direction, justification: allow.justification, - reason: "the live operation no longer matches the entry's expect/expectAbsent predicate", + // Name the actual cause. "No longer matches the predicate" is false when there was never a + // live operation to match one against, and a wrong reason sends the reader hunting for a + // change in the published contract that never happened. + reason: liveOp + ? "the live operation no longer matches the entry's expect/expectAbsent predicate" + : 'this direction has no live operation, and the entry states a predicate that therefore cannot be evaluated', }); } findings.push({ ...entry, direction, ...detail }); @@ -199,6 +250,17 @@ export function compare({ repoDoc, liveDoc, allowlist = [], normalize = true }) record('shared-drift', path, method, { path, method: method.toUpperCase() }, { severity: 'contract-mismatch', differences }, liveEntry.op); } + // An exemption `record` never consulted: its operation is no longer served, or openapi.yaml now + // documents it, so no pass ever reaches for the key. Without this it is invisible — the headline + // counts `allowlisted` and `lapsedAllowlistEntries`, and a dead entry appears in neither, so it + // reads as "no allowlist problem" indefinitely. A standing grant nobody reviews is the thing an + // allowlist is supposed to make impossible. Surfaced, not failed: a dead entry is stale + // bookkeeping, not drift, and reddening the daily job for it would train people to ignore it. + const unmatchedAllowlist = [...allowByKey.keys()] + .filter((k) => !usedAllowKeys.has(k)) + .sort() + .map((key) => ({ key, justification: allowByKey.get(key).justification })); + findings.sort((a, b) => `${a.direction} ${a.path} ${a.method}`.localeCompare(`${b.direction} ${b.path} ${b.method}`)); draftNotYetPublished.sort((a, b) => `${a.path} ${a.method}`.localeCompare(`${b.path} ${b.method}`)); @@ -218,10 +280,12 @@ export function compare({ repoDoc, liveDoc, allowlist = [], normalize = true }) draftNotYetPublished: draftNotYetPublished.length, allowlisted: allowlisted.length, lapsedAllowlistEntries: lapsedAllowlist.length, + unmatchedAllowlistEntries: unmatchedAllowlist.length, }, findings, allowlisted, lapsedAllowlist, + unmatchedAllowlist, draftNotYetPublished, enrichmentObservations, normalizationRules: normalize ? NORMALIZATION_RULES : ['NORMALIZATION DISABLED (--no-normalize)'], diff --git a/.github/scripts/published-drift-freshness.mjs b/.github/scripts/published-drift-freshness.mjs index c4d0cb7..80076db 100644 --- a/.github/scripts/published-drift-freshness.mjs +++ b/.github/scripts/published-drift-freshness.mjs @@ -58,6 +58,9 @@ export const EXIT_STALE = 2; /** The one field this gate adds to the artifact. Named here so the generator and the check agree. */ export const DIGEST_FIELD = 'repoOperationsDigest'; +/** What `createHash('sha256').digest('hex')` produces, and therefore the only gradable digest. */ +export const SHA256_HEX = /^[0-9a-f]{64}$/; + /** * The repo-side facts a receipt claims, recomputed from the spec. Pure: no I/O, no clock. * `paths` and `operations` mirror published-drift-compare.mjs's headline exactly, so a receipt and @@ -98,6 +101,22 @@ export function checkFreshness(repoDoc, receipt) { ], }; } + // A digest that is not a digest is an UNGRADABLE receipt, not a stale one. Without this any + // non-empty string — `"TODO"`, a truncated paste, a merge conflict marker — would fail the + // equality check below and be reported as STALE, which tells a reader the spec moved and sends + // them to regenerate a receipt whose real problem is that it is malformed. STALE and UNKNOWN also + // drive different CI behaviour (exit 2 files the routine staleness issue; exit 1 goes red and + // files nothing), so misclassifying one as the other files a false report. + if (!SHA256_HEX.test(recorded)) { + return { + status: 'unknown', + reasons: [ + `the receipt's sources.${DIGEST_FIELD} is not a SHA-256 digest (got ${JSON.stringify(recorded.slice(0, 80))}) — ` + + 'the receipt is malformed, which says nothing about whether the spec has moved. Regenerate it with ' + + 'published-drift.mjs.', + ], + }; + } const facts = repoFacts(repoDoc); const reasons = []; @@ -116,12 +135,22 @@ export function checkFreshness(repoDoc, receipt) { return { status: reasons.length ? 'stale' : 'fresh', reasons }; } +/** + * `--receipt` must be given a real path. Left bare it used to set `args.receipt = undefined`, which + * reached `readFileSync` and surfaced as "could not read/parse undefined" — a read error for what + * is really a usage error, sending the reader to look for a missing file instead of a missing + * argument. A following option token was accepted as a path for the same reason. + */ export function parseArgs(argv) { - const args = { spec: null, receipt: 'contract-drift.json' }; + const args = { spec: null, receipt: 'contract-drift.json', error: null }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === '--receipt') args.receipt = argv[++i]; - else if (!a.startsWith('--') && args.spec === null) args.spec = a; + if (a === '--receipt') { + const next = argv[++i]; + if (next === undefined || next.startsWith('--')) + args.error ??= `--receipt needs a value (got ${next === undefined ? 'nothing' : JSON.stringify(next)})`; + else args.receipt = next; + } else if (!a.startsWith('--') && args.spec === null) args.spec = a; } args.spec ??= 'openapi.yaml'; return args; @@ -129,6 +158,11 @@ export function parseArgs(argv) { export async function main(argv = process.argv.slice(2)) { const args = parseArgs(argv); + if (args.error) { + console.error(`published-drift-freshness: ${args.error}`); + console.error('published-drift-freshness: usage — node published-drift-freshness.mjs [openapi.yaml] [--receipt contract-drift.json]'); + return EXIT_UNKNOWN; + } let repoDoc; try { diff --git a/.github/scripts/published-drift-freshness.test.mjs b/.github/scripts/published-drift-freshness.test.mjs index 1399b08..89ff2ea 100644 --- a/.github/scripts/published-drift-freshness.test.mjs +++ b/.github/scripts/published-drift-freshness.test.mjs @@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { compare } from './published-drift-compare.mjs'; -import { DIGEST_FIELD, EXIT_UNKNOWN, checkFreshness, main, repoFacts } from './published-drift-freshness.mjs'; +import { DIGEST_FIELD, EXIT_UNKNOWN, checkFreshness, main, parseArgs, repoFacts } from './published-drift-freshness.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, '..', '..'); @@ -102,9 +102,41 @@ test('an ungradable receipt is UNKNOWN, never FRESH', () => { assert.equal(checkFreshness(spec, []).status, 'unknown'); }); +test('a digest that is not a digest is UNKNOWN, not STALE', () => { + // STALE and UNKNOWN are different claims that drive different CI behaviour: STALE (exit 2) files + // the routine staleness issue and tells the reader the spec moved; UNKNOWN (exit 1) goes red and + // files nothing. A malformed digest is the second — the receipt cannot be graded, which says + // nothing about whether the spec moved. Any non-empty string used to sail through the check and + // fail the equality test below it, so a merge marker or a truncated paste was reported as STALE. + const spec = doc({ '/a': { get: {} } }); + for (const bad of ['invalid', '', ' ', 'TODO', 'a'.repeat(63), 'a'.repeat(65), `${'A'.repeat(64)}`, '<<<<<<< HEAD']) { + const receipt = receiptFor(spec); + receipt.sources[DIGEST_FIELD] = bad; + assert.equal(checkFreshness(spec, receipt).status, 'unknown', `${JSON.stringify(bad)} is not a gradable digest`); + } + // A well-formed digest that simply disagrees is still STALE — this narrows the gate, it does not + // blunt it. + const wrongButWellFormed = receiptFor(spec); + wrongButWellFormed.sources[DIGEST_FIELD] = 'b'.repeat(64); + assert.equal(checkFreshness(spec, wrongButWellFormed).status, 'stale'); + assert.equal(checkFreshness(spec, receiptFor(spec)).status, 'fresh'); +}); + +test('--receipt with no value is a usage error, not a misleading read error', () => { + // It used to set args.receipt = undefined, reach readFileSync, and surface as + // "could not read/parse undefined" — sending the reader after a missing file, not a missing arg. + assert.match(parseArgs(['--receipt']).error, /--receipt needs a value \(got nothing\)/); + assert.match(parseArgs(['openapi.yaml', '--receipt']).error, /--receipt needs a value/); + const ok = parseArgs(['openapi.yaml', '--receipt', 'contract-drift.json']); + assert.equal(ok.error, null); + assert.deepEqual({ spec: ok.spec, receipt: ok.receipt }, { spec: 'openapi.yaml', receipt: 'contract-drift.json' }); + assert.equal(parseArgs([]).receipt, 'contract-drift.json', 'the default receipt still applies'); +}); + test('main() exits UNKNOWN on an unreadable spec or receipt, never FRESH', async () => { assert.equal(await main(['/nonexistent/openapi.yaml', '--receipt', '/nonexistent/r.json']), EXIT_UNKNOWN); assert.equal(await main([join(REPO_ROOT, 'openapi.yaml'), '--receipt', '/nonexistent/r.json']), EXIT_UNKNOWN); + assert.equal(await main(['--receipt']), EXIT_UNKNOWN, 'a usage error is UNKNOWN, never a verdict'); }); // ── The shipped artifact itself is the subject. ───────────────────────────────────────────────── diff --git a/.github/scripts/published-drift.mjs b/.github/scripts/published-drift.mjs index c199049..fba67d0 100644 --- a/.github/scripts/published-drift.mjs +++ b/.github/scripts/published-drift.mjs @@ -63,12 +63,26 @@ export const EXIT_OK = 0; export const EXIT_UNKNOWN = 1; export const EXIT_DRIFT = 2; -/** Fetch the published contract. Returns a result, never throws, never defaults to "no drift". */ +/** + * Fetch the published contract. Returns a result, never throws, never defaults to "no drift". + * + * REDIRECTS ARE NOT FOLLOWED. `redirect: 'follow'` would let whatever answers the published URL + * choose this job's next destination, and this job runs on a CI runner with a token in its + * environment. The URL is a single hardcoded HTTPS constant; there is no legitimate reason for it + * to bounce us somewhere else, and if it ever starts to, the honest answer is "I could not read the + * published contract" — EXIT_UNKNOWN, red, no issue filed — rather than grading whatever the + * redirect target happened to serve. Measured 2026-09-04: the endpoint answers HTTP/2 200 directly, + * so this refuses nothing that works today. + */ export async function fetchPublished(url = PUBLISHED_SPEC_URL, doFetch = fetch) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { - const res = await doFetch(url, { signal: controller.signal, redirect: 'follow' }); + const res = await doFetch(url, { signal: controller.signal, redirect: 'manual' }); + if (res.status >= 300 && res.status < 400) { + const target = res.headers?.get?.('location') ?? 'an undisclosed location'; + return { ok: false, error: `${url} redirected (HTTP ${res.status}) to ${target} — refusing to follow` }; + } if (!res.ok) return { ok: false, error: `HTTP ${res.status} from ${url}` }; return { ok: true, doc: await res.json() }; } catch (err) { @@ -79,12 +93,27 @@ export async function fetchPublished(url = PUBLISHED_SPEC_URL, doFetch = fetch) } } +/** + * Value-taking options must actually be given a value. `--live` with nothing after it used to set + * `args.live = undefined`, which reads as "no snapshot" and silently takes the NETWORK branch — the + * opposite of what `--live` was typed to ask for, and the one branch the caller was trying to + * avoid. `--out` with no value silently wrote no artifact. In both cases a following option token + * was also accepted as a filename. An option that quietly means its opposite is worse than one that + * errors, so this returns a usage error the caller turns into EXIT_UNKNOWN. + */ export function parseArgs(argv) { - const args = { spec: null, live: null, out: null, normalize: true, json: false }; + const args = { spec: null, live: null, out: null, normalize: true, json: false, error: null }; + const value = (name, next) => { + if (next === undefined || next.startsWith('--')) { + args.error ??= `${name} needs a value (got ${next === undefined ? 'nothing' : JSON.stringify(next)})`; + return null; + } + return next; + }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === '--live') args.live = argv[++i]; - else if (a === '--out') args.out = argv[++i]; + if (a === '--live') args.live = value('--live', argv[++i]); + else if (a === '--out') args.out = value('--out', argv[++i]); else if (a === '--no-normalize') args.normalize = false; else if (a === '--json') args.json = true; else if (!a.startsWith('--') && args.spec === null) args.spec = a; @@ -93,27 +122,40 @@ export function parseArgs(argv) { return args; } -function report(r) { +/** + * `say` is stdout normally and stderr under `--json`. Under `--json` stdout carries the artifact and + * nothing else — a consumer piping this into `jq` cannot parse a stream with prose in it — but the + * prose still has to go SOMEWHERE, because it carries the ::error:: and ::warning:: annotations CI + * renders. Dropping it would trade one defect for a quieter one. + */ +function report(r, say = console.log) { const h = r.headline; - console.log( + say( `published-drift: repo ${h.repoVersion} ${h.repoPaths} paths / ${h.repoOperations} ops vs published ` + `${h.publishedVersion} ${h.publishedPaths} paths / ${h.publishedOperations} ops — shared ${h.sharedOperations}`, ); - console.log( + say( `published-drift: findings — undocumented-live ${h.undocumentedLive}, unpublished-repo ${h.unpublishedRepo}, ` + `shared-drift ${h.sharedDrift}; suppressed — draft ${h.draftNotYetPublished}, allowlisted ${h.allowlisted}`, ); - console.log( + say( `published-drift: gateway enrichment normalized — ${r.enrichmentObservations.errorResponsesInjected} injected error ` + `responses, ${r.enrichmentObservations.operationIdsSynthesized} synthesized operationIds`, ); if (r.enrichmentObservations.descriptionsOverwritten.length) { - console.log( + say( `::warning::${r.enrichmentObservations.descriptionsOverwritten.length} operations have a real description in ` + "openapi.yaml that the published contract replaced with its versioning boilerplate. " + 'Not drift in this spec — a defect in the publishing service, tracked separately.', ); } + for (const e of r.unmatchedAllowlist ?? []) { + say( + `::warning::allowlist entry ${e.key} matched no operation in this comparison — the operation is no longer ` + + 'served, or openapi.yaml now documents it. Either way the exemption is dead and should be deleted rather ' + + `than left standing. Original justification: ${e.justification}`, + ); + } for (const e of r.lapsedAllowlist) { console.error( `::error::allowlist entry ${e.method} ${e.path} no longer matches its predicate — treating it as a finding instead ` + @@ -128,6 +170,11 @@ function report(r) { export async function main(argv = process.argv.slice(2)) { const args = parseArgs(argv); + if (args.error) { + console.error(`published-drift: ${args.error}`); + console.error('published-drift: usage — node published-drift.mjs [openapi.yaml] [--live ] [--out ] [--json] [--no-normalize]'); + return EXIT_UNKNOWN; + } let repoDoc; try { @@ -208,14 +255,17 @@ export async function main(argv = process.argv.slice(2)) { ...result, }; if (args.out) writeFileSync(args.out, `${JSON.stringify(artifact, null, 2)}\n`); + // Under --json, stdout is the artifact and only the artifact; every human line goes to stderr so + // the stream stays parseable. + const say = args.json ? console.error : console.log; if (args.json) process.stdout.write(`${JSON.stringify(artifact)}\n`); - report(result); + report(result, say); if (result.findings.length) { console.error(`published-drift: DRIFT — ${result.findings.length} unexplained operation-level difference(s).`); return EXIT_DRIFT; } - console.log('published-drift: OK — the published contract matches openapi.yaml at operation granularity.'); + say('published-drift: OK — the published contract matches openapi.yaml at operation granularity.'); return EXIT_OK; } diff --git a/.github/scripts/published-drift.test.mjs b/.github/scripts/published-drift.test.mjs index 3d886af..6edcc01 100644 --- a/.github/scripts/published-drift.test.mjs +++ b/.github/scripts/published-drift.test.mjs @@ -8,6 +8,9 @@ * exact enrichment observed in the published document, so the normalizer is tested against the * shape it actually has to undo. * + * The EXEMPTION lifecycle — how an allowlist entry is granted, honored and lapses — lives in + * published-drift-allowlist.test.mjs. This file keeps normalization, indexing and the CLI contract. + * * Run: node --test .github/scripts/*.test.mjs */ import test from 'node:test'; @@ -23,8 +26,8 @@ import { normalizePair, synthesizeOperationId, } from './published-drift-normalize.mjs'; -import { allowlistStillApplies, compare, diffOperation, indexOperations, validateAllowlist } from './published-drift-compare.mjs'; -import { EXIT_DRIFT, EXIT_OK, EXIT_UNKNOWN, fetchPublished, main } from './published-drift.mjs'; +import { compare, diffOperation, indexOperations } from './published-drift-compare.mjs'; +import { EXIT_DRIFT, EXIT_OK, EXIT_UNKNOWN, fetchPublished, main, parseArgs } from './published-drift.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, '..', '..'); @@ -130,76 +133,6 @@ test('a draft repo-only operation is suppressed; promoting it out of draft makes assert.equal(afterPromotion.findings[0].severity, 'contract-ahead'); }); -// ── Direction: undocumented-live, the security-relevant one. ──────────────────────────────────── -const injectedPublicOp = { - summary: 'LIVE inference funnel usage (registry-grounded, GROUP BY model, spend to 8 decimals)', - tags: ['public'], - responses: { 200: { description: 'ok' } }, -}; - -test('an operation served live but absent from the spec is a security-relevant finding', () => { - const r = compare({ repoDoc: doc({}), liveDoc: doc({ '/usage': { get: injectedPublicOp } }) }); - assert.equal(r.headline.undocumentedLive, 1); - assert.equal(r.findings[0].severity, 'security-relevant'); - assert.equal(r.findings[0].method, 'GET'); - assert.equal(r.findings[0].path, '/usage'); -}); - -test('an allowlist entry suppresses it — and LAPSES the moment the operation gains auth', () => { - const allowlist = [ - { - path: '/usage', - method: 'GET', - direction: 'undocumented-live', - justification: 'Gateway-native public root surface, exempt only while it stays unauthenticated.', - expect: { 'tags.0': 'public' }, - expectAbsent: ['security'], - }, - ]; - const clean = compare({ repoDoc: doc({}), liveDoc: doc({ '/usage': { get: injectedPublicOp } }), allowlist }); - assert.equal(clean.headline.undocumentedLive, 0); - assert.equal(clean.headline.allowlisted, 1); - - const behindAuth = { ...injectedPublicOp, security: [{ bearerWithScopes: ['usage:read'] }] }; - const lapsed = compare({ repoDoc: doc({}), liveDoc: doc({ '/usage': { get: behindAuth } }), allowlist }); - assert.equal(lapsed.headline.undocumentedLive, 1, 'the exemption was granted for the unauthenticated shape only'); - assert.equal(lapsed.headline.lapsedAllowlistEntries, 1); -}); - -test('an allowlist entry does not leak across directions', () => { - const allowlist = [ - { path: '/x', method: 'POST', direction: 'unpublished-repo', justification: 'A justification long enough to pass.', expect: {} }, - ]; - const r = compare({ repoDoc: doc({}), liveDoc: doc({ '/x': { post: { responses: {} } } }), allowlist }); - assert.equal(r.headline.undocumentedLive, 1, 'an unpublished-repo exemption must not silence an undocumented-live finding'); -}); - -test('a null expectation matches an absent key as well as a literal null', () => { - assert.equal(allowlistStillApplies({ expect: { security: null } }, { tags: ['public'] }), true); - assert.equal(allowlistStillApplies({ expect: { security: null } }, { security: null }), true); - assert.equal(allowlistStillApplies({ expect: { security: null } }, { security: [] }), false); -}); - -// ── Allowlist hygiene. ────────────────────────────────────────────────────────────────────────── -test('validateAllowlist rejects the ways an exemption goes bad', () => { - const ok = { path: '/a', method: 'GET', direction: 'undocumented-live', justification: 'A justification long enough.' }; - assert.equal(validateAllowlist([ok]), null); - assert.match(validateAllowlist({}), /not an array/); - assert.match(validateAllowlist([{ ...ok, justification: 'too short' }]), /needs a real justification/); - assert.match(validateAllowlist([{ ...ok, direction: 'whatever' }]), /unknown direction/); - assert.match(validateAllowlist([ok, ok]), /duplicate allowlist entry/); -}); - -test('the COMMITTED allowlist is well-formed and every entry is a live-direction exemption with a predicate', () => { - const committed = JSON.parse(readFileSync(join(__dirname, 'published-drift-allowlist.json'), 'utf8')); - assert.equal(validateAllowlist(committed), null); - for (const e of committed) { - assert.equal(e.direction, 'undocumented-live', `${e.method} ${e.path}: only live surface should ever need an exemption`); - assert.ok(Object.keys(e.expect ?? {}).length > 0, `${e.method} ${e.path}: an exemption without a predicate cannot lapse`); - assert.ok(e.expectAbsent?.includes('security'), `${e.method} ${e.path}: must lapse when the route gains auth`); - } -}); - // ── Index and path-item handling. ─────────────────────────────────────────────────────────────── test('indexOperations skips path-item metadata and counts only real operations', () => { const ops = indexOperations(doc({ '/a': { get: {}, post: {}, parameters: [{ name: 'x' }], summary: 'shared', $ref: '#/x' } })); @@ -219,6 +152,90 @@ test('fetchPublished reports a failure rather than throwing or defaulting', asyn assert.match((await fetchPublished('https://example.invalid/x', notOk)).error, /HTTP 503/); }); +test('a redirect is refused rather than followed', async () => { + // redirect: 'follow' let whatever answers the published URL choose this CI job's next + // destination. The URL is a hardcoded HTTPS constant; a bounce is not a contract to grade, it is + // a read that did not happen — so it must come back as a failure, which main() turns into + // EXIT_UNKNOWN (red, no issue filed) rather than a verdict about drift. + const withHeaders = async () => ({ ok: false, status: 302, headers: new Headers({ location: 'https://elsewhere.invalid/x' }) }); + const r = await fetchPublished('https://example.invalid/openapi.json', withHeaders); + assert.equal(r.ok, false); + assert.match(r.error, /redirected \(HTTP 302\) to https:\/\/elsewhere\.invalid\/x — refusing to follow/); + + // Every 3xx, and a 3xx with no Location at all, is still a refusal rather than a read. + for (const status of [301, 302, 307, 308]) { + const bounce = async () => ({ ok: false, status, headers: new Headers({ location: 'https://elsewhere.invalid/x' }) }); + assert.equal((await fetchPublished('https://example.invalid/openapi.json', bounce)).ok, false, `HTTP ${status}`); + } + const noLocation = async () => ({ ok: false, status: 302, headers: new Headers() }); + assert.match((await fetchPublished('https://example.invalid/openapi.json', noLocation)).error, /an undisclosed location/); + + // A 200 still reads normally — this refuses redirects, not the endpoint. + const fine = async () => ({ ok: true, status: 200, headers: new Headers(), json: async () => ({ paths: {} }) }); + assert.deepEqual(await fetchPublished('https://example.invalid/openapi.json', fine), { ok: true, doc: { paths: {} } }); +}); + +test('a value-taking option with no value is a usage error, not a silent opposite', () => { + // `--live` with nothing after it used to mean "no snapshot", which takes the NETWORK branch — + // the exact branch the caller typed --live to avoid. + assert.match(parseArgs(['--live']).error, /--live needs a value \(got nothing\)/); + assert.match(parseArgs(['--live', '--json']).error, /--live needs a value \(got "--json"\)/); + assert.match(parseArgs(['--out']).error, /--out needs a value/); + assert.match(parseArgs(['--out', '--no-normalize']).error, /--out needs a value/); + // The valid forms are unchanged. + const ok = parseArgs(['openapi.yaml', '--live', 'live.json', '--out', 'drift.json', '--json', '--no-normalize']); + assert.equal(ok.error, null); + assert.deepEqual( + { spec: ok.spec, live: ok.live, out: ok.out, json: ok.json, normalize: ok.normalize }, + { spec: 'openapi.yaml', live: 'live.json', out: 'drift.json', json: true, normalize: false }, + ); + assert.equal(parseArgs([]).spec, 'openapi.yaml', 'the default spec still applies'); +}); + +test('a usage error exits UNKNOWN and never reaches the network', async () => { + assert.equal(await main(['--live']), EXIT_UNKNOWN); + assert.equal(await main(['--out']), EXIT_UNKNOWN); +}); + +test('--json keeps stdout parseable: the artifact and nothing else', async () => { + // The human report and the OK line used to be written to stdout alongside the JSON, so a + // consumer piping this into a parser got a stream it could not read. + const { writeFileSync, rmSync } = await import('node:fs'); + const yaml = (await import('js-yaml')).default; + const spec = yaml.load(readFileSync(join(REPO_ROOT, 'openapi.yaml'), 'utf8')); + const served = {}; + for (const [p, item] of Object.entries(spec.paths)) { + const kept = Object.fromEntries(Object.entries(item).filter(([, op]) => op?.['x-schema-status'] !== 'draft')); + if (Object.keys(kept).length) served[p] = kept; + } + const snapshot = join(process.env.RUNNER_TEMP ?? '/tmp', `published-drift-json-${process.pid}.json`); + writeFileSync(snapshot, JSON.stringify(doc(served))); + + const chunks = []; + const realWrite = process.stdout.write.bind(process.stdout); + const realLog = console.log; + const realError = console.error; + process.stdout.write = (chunk, ...rest) => { + chunks.push(String(chunk)); + return true; + }; + console.log = (...a) => chunks.push(`${a.join(' ')}\n`); + console.error = () => {}; + try { + const code = await main([join(REPO_ROOT, 'openapi.yaml'), '--live', snapshot, '--json']); + assert.equal(code, EXIT_OK); + } finally { + process.stdout.write = realWrite; + console.log = realLog; + console.error = realError; + rmSync(snapshot, { force: true }); + } + + const stdout = chunks.join(''); + assert.doesNotThrow(() => JSON.parse(stdout), 'everything written to stdout under --json must be the artifact'); + assert.equal(JSON.parse(stdout).headline.sharedDrift, 0); +}); + test('an unreadable snapshot exits UNKNOWN, never OK', async () => { assert.equal(await main([join(REPO_ROOT, 'openapi.yaml'), '--live', '/nonexistent/live.json']), EXIT_UNKNOWN); }); diff --git a/.github/workflows/published-contract-drift.yml b/.github/workflows/published-contract-drift.yml index 804480f..56c0ed4 100644 --- a/.github/workflows/published-contract-drift.yml +++ b/.github/workflows/published-contract-drift.yml @@ -33,9 +33,18 @@ # specific to this repo's own published artifact does not belong inside the mirrored file. # # ON EXIT 2 (drift) the job files or updates ONE tracking issue, matched by exact title so repeated -# runs comment instead of opening a new issue every morning. On exit 1 (UNKNOWN — a broken read) it -# goes red and files NOTHING: a failed read says nothing about drift, and an issue claiming drift -# on the strength of a failed fetch would be a false report. +# runs comment instead of opening a new issue every morning. ON EXIT 0 it CLOSES that same issue: +# an open issue is a claim about the current state of the default branch, and a claim nothing ever +# retracts becomes false the moment it is fixed. Both jobs own the full lifecycle of their own +# issue, and the two titles are distinct so neither touches the other's. On exit 1 (UNKNOWN — a +# broken read) it goes red and files NOTHING: a failed read says nothing about drift, and an issue +# claiming drift on the strength of a failed fetch would be a false report. +# +# ON ANY OTHER EXIT CODE both jobs go red. The scripts define exactly three codes; a fourth means a +# crash — an interpreter error, an OOM kill, a code added to a script without teaching this file +# about it. The per-code steps are all `if: code == ...`, so an unhandled code would match none of +# them and leave the job GREEN. A gate that fails open is worse than no gate, because it is +# trusted, so each job carries an explicit inverse default case. name: published-contract-drift @@ -124,6 +133,21 @@ jobs: echo "code=$code" >> "$GITHUB_OUTPUT" echo "exit code: $code" + # The script defines exactly three exit codes. ANY OTHER CODE IS A CRASH, not a verdict: an + # interpreter error, an OOM kill (137), a missing binary (127), or a fourth code someone adds + # to the script later without teaching this file about it. The steps below enumerate '1' and + # '2'; with no default case a crash would match none of them and leave the job GREEN — a gate + # that fails open and says nothing, which is worse than no gate because it is trusted. Every + # other step here is `if: code == ...`, so this one is deliberately the inverse. + - name: Fail on an unrecognized exit code + if: steps.fresh.outputs.code != '0' && steps.fresh.outputs.code != '1' && steps.fresh.outputs.code != '2' + env: + CODE: ${{ steps.fresh.outputs.code }} + run: | + echo "::error::published-drift-freshness exited $CODE, which is not one of its three documented codes (0 FRESH / 1 UNKNOWN / 2 STALE)." + echo "Treating an unrecognized exit as a failure: it is a crash, not a verdict, and must never read as a pass." + exit 1 + # exit 1 = UNKNOWN. A broken read is a TOOLING failure: go red everywhere, file nothing. - name: Fail loudly on a broken read if: steps.fresh.outputs.code == '1' @@ -195,6 +219,33 @@ jobs: echo "::error::contract-drift.json no longer describes openapi.yaml. A tracking issue was filed or updated." exit 1 + # exit 0 off the PR path = the receipt was regenerated and the condition that opened the issue + # is gone. Close it. An open issue is a CLAIM about the default branch's current state, so one + # nothing closes decays into a false claim that outlives its cause — and the next real + # staleness would comment on a stale issue rather than announce itself. Matched by the same + # exact literal title the filing step uses, so the two halves of the lifecycle cannot drift + # apart; the drift job's issue has a different title and is untouched by this. + - name: Close the tracking issue once the receipt is fresh again + if: steps.fresh.outputs.code == '0' && github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + TITLE: 'contract-drift.json no longer matches openapi.yaml' + run: | + existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --limit 100 \ + --json number,title --jq "map(select(.title == \$ENV.TITLE)) | .[0].number // empty") + + if [ -n "$existing" ]; then + { + echo "\`contract-drift.json\` matches \`openapi.yaml\` again — the receipt was regenerated, so this is resolved." + echo + echo "Run: [\`$GITHUB_RUN_ID\`](https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID) · commit \`$GITHUB_SHA\`" + } > /tmp/close-body.md + gh issue close "$existing" --repo "$GITHUB_REPOSITORY" --comment "$(cat /tmp/close-body.md)" + echo "Closed issue #$existing — the receipt is fresh." + else + echo "Receipt is fresh and no tracking issue is open. Nothing to close." + fi + # Networked. Scheduled and manual only. drift: name: published contract drift @@ -233,6 +284,18 @@ jobs: path: /tmp/contract-drift.json if-no-files-found: warn + # Same default case as the freshness job, for the same reason: the steps below enumerate '1' + # and '2', so without this an interpreter crash, an OOM kill or a code added later would match + # nothing and leave a networked gate green while saying nothing at all. + - name: Fail on an unrecognized exit code + if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '1' && steps.drift.outputs.code != '2' + env: + CODE: ${{ steps.drift.outputs.code }} + run: | + echo "::error::published-drift exited $CODE, which is not one of its three documented codes (0 OK / 1 UNKNOWN / 2 DRIFT)." + echo "Treating an unrecognized exit as a failure: it is a crash, not a verdict, and must never read as a pass." + exit 1 + # exit 1 = UNKNOWN. A broken read is a TOOLING failure: go red, file nothing. - name: Fail loudly on a broken read if: steps.drift.outputs.code == '1' @@ -283,3 +346,30 @@ jobs: run: | echo "::error::The published contract has drifted from openapi.yaml. A tracking issue was filed or updated." exit 1 + + # exit 0 = the contract was reconciled. Close the issue that exit 2 opened, for the same + # reason the freshness job closes its own: an open issue asserts that the published contract + # is CURRENTLY drifted, and nothing else in this workflow ever retracts that. Left alone it + # would sit open past the fix, and the next genuine drift would arrive as a comment on an + # issue everyone had already learned to ignore. Exact-title match, and this title is distinct + # from the freshness job's — the two lifecycles stay separate. + - name: Close the tracking issue once the contract matches again + if: steps.drift.outputs.code == '0' + env: + GH_TOKEN: ${{ github.token }} + TITLE: 'Published contract has drifted from openapi.yaml' + run: | + existing=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --limit 100 \ + --json number,title --jq "map(select(.title == \$ENV.TITLE)) | .[0].number // empty") + + if [ -n "$existing" ]; then + { + echo "The published contract matches \`openapi.yaml\` at operation granularity again — this is resolved." + echo + echo "Run: [\`$GITHUB_RUN_ID\`](https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID) · commit \`$GITHUB_SHA\`" + } > /tmp/close-body.md + gh issue close "$existing" --repo "$GITHUB_REPOSITORY" --comment "$(cat /tmp/close-body.md)" + echo "Closed issue #$existing — the contract matches." + else + echo "No drift and no tracking issue is open. Nothing to close." + fi