From c25541d2bad95ea4be0ddda5de431bdc55e86882 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 03:27:04 +0000 Subject: [PATCH 1/3] feat(spec): error-code provenance rows + check:error-code-provenance gate (WIP) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 --- .changeset/error-code-provenance-gate.md | 5 + .github/workflows/lint.yml | 17 + packages/spec/package.json | 1 + .../check-error-code-provenance.test.ts | 167 ++++++ .../scripts/check-error-code-provenance.ts | 478 ++++++++++++++++++ packages/spec/scripts/check-generated.ts | 12 + .../spec/src/api/error-code-ledger.zod.ts | 191 ++++++- 7 files changed, 870 insertions(+), 1 deletion(-) create mode 100644 .changeset/error-code-provenance-gate.md create mode 100644 packages/spec/scripts/check-error-code-provenance.test.ts create mode 100644 packages/spec/scripts/check-error-code-provenance.ts diff --git a/.changeset/error-code-provenance-gate.md b/.changeset/error-code-provenance-gate.md new file mode 100644 index 0000000000..afb46d2ec7 --- /dev/null +++ b/.changeset/error-code-provenance-gate.md @@ -0,0 +1,5 @@ +--- +"@objectstack/spec": patch +--- + +Error-code ledger: provenance rows and a provenance gate (#13353). Four adjudicated owner-key rows land for packages that already stamp registered codes on their own wire doors — `@objectstack/plugin-webhooks` / `INVALID_REQUEST`, `@objectstack/cloud-connection` / `FORBIDDEN`, `@objectstack/cli` / `ENVIRONMENT_NOT_FOUND`, `@objectstack/trigger-api` / `INVALID_REQUEST`. The registered union is unchanged (every code was already registered under another package), so `ErrorCode` accepts and rejects exactly what it did before — the rows are provenance only. A new mechanical gate (`check:error-code-provenance`) sweeps `packages/**` non-test source and fails any stamp site of a registered code the stamping package's own owner key does not list; deliberate "the door, not the producer, names the wire vocabulary" splits are recorded in the new exported `PROVENANCE_WAIVERS` table (with `ProvenanceWaiverSchema`), held live by the gate in both directions. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3dfae68f01..c1df48b127 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2262,6 +2262,23 @@ jobs: - name: Dispatcher error-code vocabulary guard run: pnpm check:dispatcher-error-vocabulary + # #13353: the PROVENANCE half of the same ledger. The gate above reports + # codes the vocabulary does not contain; this one reports REGISTERED codes + # stamped by a package whose own owner key does not list them — the drift + # the ledger's admission rules structurally cannot see (they check casing, + # duplication and shadowing, never who emits), re-found by hand three + # times (#7504, #13254, #13353). Deliberate "the door, not the producer, + # names the wire vocabulary" splits are recorded in PROVENANCE_WAIVERS + # inside the ledger file and held live by the gate in both directions. + # Same placement rationale as its sibling above — no `paths:` filter, + # deliberately: the producers live in any package, and the rows live in + # packages/spec. It CANNOT ride the spec vitest suite instead: turbo + # hashes only per-package inputs, so a cached spec `test` run would stay + # green on exactly the PR that adds an unlisted stamper elsewhere. + # Runs its own --self-test first (wired into the package script). + - name: Error-code provenance guard + run: pnpm --filter @objectstack/spec check:error-code-provenance + # #10534 follow-up 4: a `rawApp` mount under the auth basePath with no ledger # row. `auth-plugin.ts` mounts routes DIRECTLY on the raw Hono app, ahead of # the better-auth catch-all, so the vendor's route table cannot account for diff --git a/packages/spec/package.json b/packages/spec/package.json index 0db5f14ef7..0a0a725c3f 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -286,6 +286,7 @@ "gen:liveness-counts": "tsx scripts/liveness/build-state-counts.mts", "check:empty-state": "tsx scripts/liveness/check-empty-state.mts", "check:variant-docs": "tsx scripts/check-variant-docs.mts", + "check:error-code-provenance": "tsx scripts/check-error-code-provenance.ts --self-test && tsx scripts/check-error-code-provenance.ts", "gen:strictness-ledger": "tsx scripts/build-strictness-ledger-counts.mts", "check:strictness-ledger": "tsx scripts/check-strictness-ledger.mts", "gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts", diff --git a/packages/spec/scripts/check-error-code-provenance.test.ts b/packages/spec/scripts/check-error-code-provenance.test.ts new file mode 100644 index 0000000000..08b7de7599 --- /dev/null +++ b/packages/spec/scripts/check-error-code-provenance.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins for the error-code provenance gate (#13353). Fixture-driven on purpose: + * every case injects synthetic sources/ledgers into the gate's exported pure + * functions, so this suite reads nothing outside its package — the REAL + * repo-wide run belongs to the gate's own CI step (`check:error-code-provenance` + * in lint.yml's unfiltered job), where turbo's per-package input hashing + * cannot cache it stale. A vitest case that walked `packages/**` itself would + * be exactly the cross-package-invisible test `check:cross-package-test-inputs` + * exists to flag. + */ + +import { describe, it, expect } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { + STAMP_PATTERNS, + scanSourceText, + deriveFindings, + type StampSite, +} from './check-error-code-provenance'; +import type { ProvenanceWaiver } from '../src/api/error-code-ledger.zod'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +const registered = new Set(['REGISTERED_ONE', 'REGISTERED_TWO']); +const ledger = { '@objectstack/owner': ['REGISTERED_ONE', 'REGISTERED_TWO'] } as const; +const site = (pkg: string, code: string, pattern = 'objlit'): StampSite => ({ + file: 'packages/x/src/a.ts', + line: 1, + package: pkg, + code, + pattern, +}); + +describe('STAMP_PATTERNS (published, one pin per pattern)', () => { + it('publishes exactly the three sweep patterns the card measured with', () => { + // The list is a PUBLISHED bound: an unrecognised spelling produces no + // finding, silently — so growing or shrinking it is a deliberate act that + // must also touch this pin (and the gate's own --self-test). + expect(STAMP_PATTERNS.map((p) => p.name)).toEqual(['objlit', 'assign', 'constdef']); + }); + + it('objlit: a registered code in an object literal is a site', () => { + const hits = scanSourceText("return c.json({ error: { code: 'REGISTERED_ONE' } }, 400);", registered); + expect(hits).toEqual([{ code: 'REGISTERED_ONE', pattern: 'objlit', line: 1 }]); + }); + + it('assign: a registered code stamped onto a throwable is a site', () => { + const hits = scanSourceText("const err = new Error(m);\nerr.code = 'REGISTERED_ONE';", registered); + expect(hits).toEqual([{ code: 'REGISTERED_ONE', pattern: 'assign', line: 2 }]); + }); + + it('constdef: a *_CODE constant initializer is a site, type annotation included', () => { + expect(scanSourceText("export const MY_CODE = 'REGISTERED_ONE';", registered)) + .toEqual([{ code: 'REGISTERED_ONE', pattern: 'constdef', line: 1 }]); + // The union-typed spelling stays ONE site: the annotation's own literals + // sit behind no recognised token (`MY_CODE:` is not `code:`), so only the + // initializer is read. + expect(scanSourceText("const MY_CODE: 'REGISTERED_ONE' | 'X' = 'REGISTERED_ONE';", registered)) + .toEqual([{ code: 'REGISTERED_ONE', pattern: 'constdef', line: 1 }]); + }); + + it('an unregistered code is out of population — the dispatcher-vocabulary gate owns it', () => { + expect(scanSourceText("return { code: 'NOT_IN_ANY_LEDGER' };", registered)).toEqual([]); + }); + + it('a code quoted in a comment is mention, not a site', () => { + expect(scanSourceText("// the 403 carries { code: 'REGISTERED_ONE' }\nconst x = 1;", registered)).toEqual([]); + expect(scanSourceText("/* err.code = 'REGISTERED_ONE' */\nconst x = 1;", registered)).toEqual([]); + }); +}); + +describe('deriveFindings — the reconciliation, both directions', () => { + it('RED LEG: a synthetic unlisted stamper of a registered code is a violation', () => { + const { violations } = deriveFindings([site('@objectstack/rogue', 'REGISTERED_ONE')], ledger, []); + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ package: '@objectstack/rogue', code: 'REGISTERED_ONE' }); + }); + + it('a stamper listed under its own owner key is green', () => { + const { violations, listed } = deriveFindings([site('@objectstack/owner', 'REGISTERED_ONE')], ledger, []); + expect(violations).toEqual([]); + expect(listed).toHaveLength(1); + }); + + it('a waiver admits exactly the (package, code) pair it records — and only that', () => { + const waiver: ProvenanceWaiver = { + package: '@objectstack/rogue', + code: 'REGISTERED_ONE', + registeredUnder: '@objectstack/owner', + reason: 'fixture: recorded decision', + }; + const admitted = deriveFindings([site('@objectstack/rogue', 'REGISTERED_ONE')], ledger, [waiver]); + expect(admitted.violations).toEqual([]); + expect(admitted.waived).toHaveLength(1); + expect(admitted.waiverProblems).toEqual([]); + // A different code from the same package is NOT admitted. + const other = deriveFindings( + [site('@objectstack/rogue', 'REGISTERED_ONE'), site('@objectstack/rogue', 'REGISTERED_TWO')], + ledger, + [waiver], + ); + expect(other.violations).toHaveLength(1); + expect(other.violations[0]?.code).toBe('REGISTERED_TWO'); + }); + + it('a waiver whose scan site is gone reddens — the liveness ratchet', () => { + const { waiverProblems } = deriveFindings([], ledger, [{ + package: '@objectstack/rogue', + code: 'REGISTERED_ONE', + registeredUnder: '@objectstack/owner', + reason: 'fixture', + }]); + expect(waiverProblems.some((p) => p.includes('NO stamp site'))).toBe(true); + }); + + it('a waiver naming a registeredUnder key that does not list the code reddens', () => { + const { waiverProblems } = deriveFindings([site('@objectstack/rogue', 'REGISTERED_ONE')], ledger, [{ + package: '@objectstack/rogue', + code: 'REGISTERED_ONE', + registeredUnder: '@objectstack/nobody', + reason: 'fixture', + }]); + expect(waiverProblems.some((p) => p.includes('registeredUnder'))).toBe(true); + }); + + it('a row plus a waiver for the same pair is dead weight and reddens', () => { + const { waiverProblems } = deriveFindings([site('@objectstack/owner', 'REGISTERED_ONE')], ledger, [{ + package: '@objectstack/owner', + code: 'REGISTERED_ONE', + registeredUnder: '@objectstack/owner', + reason: 'fixture', + }]); + expect(waiverProblems.some((p) => p.includes('dead weight'))).toBe(true); + }); + + it('duplicate waivers for one pair redden — one decision, one record', () => { + const waiver: ProvenanceWaiver = { + package: '@objectstack/rogue', + code: 'REGISTERED_ONE', + registeredUnder: '@objectstack/owner', + reason: 'fixture', + }; + const { waiverProblems } = deriveFindings([site('@objectstack/rogue', 'REGISTERED_ONE')], ledger, [waiver, { ...waiver }]); + expect(waiverProblems.some((p) => p.includes('duplicate'))).toBe(true); + }); +}); + +describe('the shipped script', () => { + it('--self-test passes (the per-pattern red legs, run exactly as CI runs them)', () => { + const require = createRequire(import.meta.url); + const tsx = require.resolve('tsx/cli'); + const result = spawnSync(process.execPath, [tsx, path.join(HERE, 'check-error-code-provenance.ts'), '--self-test'], { + cwd: path.resolve(HERE, '..'), + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }); + if (result.error) throw result.error; + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + expect(output).toContain('self-test OK'); + expect(result.status, output).toBe(0); + }); +}); diff --git a/packages/spec/scripts/check-error-code-provenance.ts b/packages/spec/scripts/check-error-code-provenance.ts new file mode 100644 index 0000000000..3f9f272dad --- /dev/null +++ b/packages/spec/scripts/check-error-code-provenance.ts @@ -0,0 +1,478 @@ +#!/usr/bin/env tsx +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Error-code PROVENANCE gate (#13353, ADR-0112 D3). + * + * pnpm --filter @objectstack/spec check:error-code-provenance + * tsx scripts/check-error-code-provenance.ts --self-test + * tsx scripts/check-error-code-provenance.ts --report # print every site + * + * ## What it guards + * + * The ledger's admission rules check casing, duplication and shadowing — + * never WHO emits — so its provenance half ("a code emitted by several + * packages is listed once per emitting package") drifted silently by + * construction. Three hand sweeps re-found the same class (#7504 one code, + * #13254 one row, #13353 five candidates), each leaving the next drift + * invisible. This gate is the mechanical form of that sweep: every stamp site + * of a REGISTERED code in `packages/**` non-test source must be listed under + * the stamping package's own owner key, or carry a recorded + * {@link PROVENANCE_WAIVERS} entry naming the owner key that deliberately + * holds the row instead ("the door, not the producer, names the wire + * vocabulary" — the `FLOW_DISABLED` / `UPDATE_ID_MISMATCH` class). + * + * Division of labour with `check:dispatcher-error-vocabulary` (the adjacent + * gate, whose scanning idiom this one borrows): that gate reports codes the + * vocabulary does NOT contain; this one reports registered codes stamped by a + * package whose owner key does not list them. Population-disjoint on purpose + * — a code is either in the registered union (this gate's subject) or not + * (that gate's). + * + * ## Reconciled in both directions + * + * A stamp site with no row and no waiver fails. A waiver is held live three + * ways: its `registeredUnder` key must still list the code, the waived + * package must still NOT list it (a row plus a waiver is dead weight), and + * the scan must still find a site for the pair — a waiver whose site is gone + * comes out with it, which is how a refactor ratchets the waiver list down + * instead of leaving stale rows promising decisions nobody is standing on. + * + * ## Declared bounds — printed on every run, so a partial gate cannot read as + * ## a complete one + * + * Textual, not AST — the same reasoning the sibling gate records: the failure + * mode is a string literal in a handful of syntactic positions. The price of a + * source scan is that it sees only the spellings it knows, and an + * unrecognised one produces no finding, SILENTLY — so the patterns are + * PUBLISHED ({@link STAMP_PATTERNS}) and each is pinned by `--self-test`. + * Reaching for a spelling that is not here? Extend the list and add a + * self-test case in the same edit. + * + * - Scanned: every package `src/` tree under `packages/` — non-test + * TypeScript source only (`.ts`/`.tsx`; not `.d.ts`, not tests — a test + * that CONSTRUCTS a code is not a producer, the ledger's own rule). Not + * `apps/`, not `examples/`, not package `scripts/` trees. + * - The ledger file itself is excluded by name: its waiver tables spell + * `code: '…'` about codes, which is mention, not stamping. + * - BLIND, and inheriting the sibling gate's declared blindness rather than + * re-litigating it: a code arriving through a constant NOT named `*_CODE` + * (`GLOBAL_UNIQUE_CONFIRMATION_REQUIRED` in `@objectstack/types` defines a + * registered code and is invisible here), an object-literal or helper + * indirection (`{ code }` shorthand, `makeError(code, …)` call sites), a + * template literal, and a class field. Those shapes DO have recognizers in + * the sibling gate; a registered code reaching a stamp through one of them + * is simply not this gate's finding yet. Widening is a gate-population + * change with an unmeasured blast radius — its own card, never a rider. + * - OVER-matching is accepted and absorbed by rows/waivers rather than + * heuristics: a TYPE-position literal (`{ code: 'ITEM_LOCKED'; reason: + * string }`) matches the object-literal pattern. That is deliberate — the + * type and the constructor beside it name the same string, and a package + * spelling a registered code in a stamp-shaped position owes the reader an + * answer either way. + * - The scan answers "does the stamping package list this code", never + * whether the site is wire-reachable. Reachability is the adjudication a + * ROW records in its comment (the #8035 test), and what a WAIVER records + * when the answer is "another package's door owns the emission". + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, resolve, dirname, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { maskComments } from '../../../scripts/js-comment-mask.mjs'; +import { + ERROR_CODE_LEDGER, + PROVENANCE_WAIVERS, + ProvenanceWaiverSchema, + type ProvenanceWaiver, +} from '../src/api/error-code-ledger.zod'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..'); + +/** The ledger file — mention, not stamping; excluded by name (see header). */ +const LEDGER_FILE = 'packages/spec/src/api/error-code-ledger.zod.ts'; + +// --------------------------------------------------------------------------- +// The scan +// --------------------------------------------------------------------------- + +/** + * The recognised ways this repo stamps a registered code, PUBLISHED and each + * pinned by `--self-test` (see the header on why, and on what is deliberately + * NOT here). The card's own sweep method, verbatim: three patterns. + */ +export const STAMP_PATTERNS: ReadonlyArray<{ name: string; re: RegExp }> = [ + // `code: 'X'` in an object literal (a returned envelope, `c.json(…)`) — the + // broadest shape, and where four of #13353's five candidates lived. Also + // matches the same spelling in a TYPE literal; see the over-match bound. + { name: 'objlit', re: /\bcode:\s*'([A-Z][A-Z0-9_]*)'/g }, + // `err.code = 'X'` — stamped onto a value about to be thrown. + { name: 'assign', re: /\.code\s*=\s*'([A-Z][A-Z0-9_]*)'/g }, + // `X_CODE = 'X'` — a `*_CODE`-named constant's literal initializer (the + // driver-memory `UNIQUE_VIOLATION_CODE` shape). The optional `[^=\n]*?` + // limb admits a type annotation between name and `=`. + { name: 'constdef', re: /\b[A-Z][A-Z0-9_]*_CODE\s*(?::[^=\n]*?)?=\s*'([A-Z][A-Z0-9_]*)'/g }, +]; + +export interface StampSite { + /** Repo-relative file path. */ + file: string; + /** 1-based line of the match (in the comment-masked source; identical line numbering). */ + line: number; + /** The stamping package's `package.json` name. */ + package: string; + /** The registered code stamped. */ + code: string; + /** Which {@link STAMP_PATTERNS} member matched. */ + pattern: string; +} + +/** + * Scan ONE file's source text for stamp sites of registered codes. Pure and + * injectable — `--self-test` and the vitest suite drive it with synthetic + * sources; the real run feeds it every file {@link collectSourceFiles} lists. + * Comments are masked first, so a code quoted in prose is not a site. + */ +export function scanSourceText( + source: string, + registered: ReadonlySet, +): Array<{ code: string; pattern: string; line: number }> { + const masked = maskComments(source); + const hits: Array<{ code: string; pattern: string; line: number }> = []; + for (const { name, re } of STAMP_PATTERNS) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(masked)) !== null) { + const code = m[1]; + if (!registered.has(code)) continue; // the sibling gate's population + const line = masked.slice(0, m.index).split('\n').length; + hits.push({ code, pattern: name, line }); + } + } + return hits; +} + +/** Directories never descended into. */ +const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', 'coverage', 'build']); + +/** Is this a non-test TypeScript source file inside some package's `src/`? */ +function isScannable(relPath: string, name: string): boolean { + if (!/\.(ts|tsx)$/.test(name)) return false; + if (name.endsWith('.d.ts') || /\.(test|spec)\.tsx?$/.test(name)) return false; + const parts = relPath.split(sep); + if (parts.includes('__tests__') || parts.includes('tests')) return false; + // Inside a `src/` segment under packages/ (nested package dirs included). + return parts.includes('src'); +} + +/** Every scannable file under `packages/`, repo-relative. */ +export function collectSourceFiles(repoRoot: string): string[] { + const out: string[] = []; + const walk = (dir: string): void => { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const abs = join(dir, name); + if (statSync(abs).isDirectory()) { + walk(abs); + continue; + } + const rel = relative(repoRoot, abs); + if (rel === LEDGER_FILE) continue; + if (isScannable(rel, name)) out.push(rel); + } + }; + walk(join(repoRoot, 'packages')); + return out.sort(); +} + +/** Nearest-`package.json` name for a repo-relative file, cached per directory. */ +export function makePackageResolver(repoRoot: string): (relFile: string) => string | null { + const cache = new Map(); + const nameOf = (relDir: string): string | null => { + const hit = cache.get(relDir); + if (hit !== undefined) return hit; + let name: string | null = null; + try { + const parsed = JSON.parse(readFileSync(join(repoRoot, relDir, 'package.json'), 'utf8')) as { + name?: string; + }; + if (typeof parsed.name === 'string') name = parsed.name; + } catch { + // no manifest at this level — keep walking up + } + if (name === null && relDir !== 'packages' && relDir.includes(sep)) { + name = nameOf(dirname(relDir)); + } + cache.set(relDir, name); + return name; + }; + return (relFile: string) => nameOf(dirname(relFile)); +} + +// --------------------------------------------------------------------------- +// The verdicts +// --------------------------------------------------------------------------- + +export interface ProvenanceFindings { + /** Stamp sites with no owner-key row and no waiver. */ + violations: StampSite[]; + /** Waiver-table defects — each a reason string naming the entry and the fix. */ + waiverProblems: string[]; + /** Sites admitted by a waiver (for the report). */ + waived: StampSite[]; + /** Sites listed under their own owner key (count only in the verdict line). */ + listed: StampSite[]; +} + +/** + * Reconcile scan sites against the ledger and the waiver table — pure, so the + * self-test and the vitest suite can drive it with synthetic inputs. + */ +export function deriveFindings( + sites: readonly StampSite[], + ledger: Record, + waivers: readonly ProvenanceWaiver[], +): ProvenanceFindings { + const listedIn = (pkg: string, code: string): boolean => ledger[pkg]?.includes(code) ?? false; + const waiverKey = (pkg: string, code: string): string => `${pkg} → ${code}`; + + const waiverProblems: string[] = []; + const waiverByKey = new Map(); + for (const waiver of waivers) { + const parsed = ProvenanceWaiverSchema.safeParse(waiver); + if (!parsed.success) { + waiverProblems.push(`waiver ${waiverKey(waiver.package, waiver.code)} does not parse: ${parsed.error.issues[0]?.message}`); + continue; + } + const key = waiverKey(waiver.package, waiver.code); + if (waiverByKey.has(key)) { + waiverProblems.push(`duplicate waiver for ${key} — one decision, one record`); + continue; + } + waiverByKey.set(key, waiver); + if (!listedIn(waiver.registeredUnder, waiver.code)) { + waiverProblems.push( + `waiver ${key} names registeredUnder \`${waiver.registeredUnder}\`, whose owner key does not list the code — ` + + `the decision it records is gone; re-adjudicate or remove the waiver`, + ); + } + if (listedIn(waiver.package, waiver.code)) { + waiverProblems.push( + `waiver ${key} is dead weight — the package's own owner key lists the code; remove one of the two`, + ); + } + } + + const violations: StampSite[] = []; + const waived: StampSite[] = []; + const listed: StampSite[] = []; + const seenWaiverKeys = new Set(); + for (const site of sites) { + if (listedIn(site.package, site.code)) { + listed.push(site); + continue; + } + const key = waiverKey(site.package, site.code); + if (waiverByKey.has(key)) { + seenWaiverKeys.add(key); + waived.push(site); + continue; + } + violations.push(site); + } + + // The third liveness direction: a waiver whose site is gone comes out. + for (const key of waiverByKey.keys()) { + if (!seenWaiverKeys.has(key)) { + waiverProblems.push( + `waiver ${key} matches NO stamp site in the scan — its subject is gone (or moved beyond the ` + + `published patterns); remove the waiver, or extend STAMP_PATTERNS if the stamp still exists in a new spelling`, + ); + } + } + + return { violations, waiverProblems, waived, listed }; +} + +// --------------------------------------------------------------------------- +// Entry points +// --------------------------------------------------------------------------- + +function printBounds(): void { + console.log('bounds: packages/**/src non-test .ts/.tsx; ledger file excluded (mention, not stamping);'); + console.log(`bounds: patterns = ${STAMP_PATTERNS.map((p) => p.name).join(', ')} — blind to non-*_CODE constants,`); + console.log('bounds: helper/shorthand indirections, templates and class fields (see the header; sibling-gate shapes).'); +} + +function run(report: boolean): number { + const registered = new Set(Object.values(ERROR_CODE_LEDGER).flat()); + const files = collectSourceFiles(REPO_ROOT); + const packageOf = makePackageResolver(REPO_ROOT); + const sites: StampSite[] = []; + for (const file of files) { + const source = readFileSync(join(REPO_ROOT, file), 'utf8'); + if (!source.includes('code')) continue; + const hits = scanSourceText(source, registered); + if (hits.length === 0) continue; + const pkg = packageOf(file); + if (pkg === null) continue; // not under any package manifest — nothing to attribute + for (const hit of hits) sites.push({ file, package: pkg, ...hit }); + } + + const { violations, waiverProblems, waived, listed } = deriveFindings( + sites, + ERROR_CODE_LEDGER, + PROVENANCE_WAIVERS, + ); + + printBounds(); + console.log(`scanned ${files.length} files; ${sites.length} registered-code stamp site(s): ` + + `${listed.length} listed, ${waived.length} waived`); + + if (report) { + for (const site of sites) { + const status = listed.includes(site) ? 'listed' : waived.includes(site) ? 'waived' : 'VIOLATION'; + console.log(` [${status}] ${site.package} → ${site.code} (${site.pattern}) ${site.file}:${site.line}`); + } + } + + let red = false; + if (violations.length > 0) { + red = true; + console.error(`\nFAIL — ${violations.length} stamp site(s) of a registered code with no provenance row:`); + for (const v of violations) { + console.error(` ${v.package} stamps '${v.code}' (${v.pattern}) at ${v.file}:${v.line} — ` + + `not listed under its own owner key`); + } + console.error( + '\nFix: EITHER add the code under the stamping package\'s owner key in\n' + + ` ${LEDGER_FILE}\n` + + 'with a comment recording the wire path (the #8035 reachability test), OR — when a door in\n' + + 'another package deliberately names the wire vocabulary — record a PROVENANCE_WAIVERS entry\n' + + 'there naming that owner key, with the evidence. Both are decisions on the record; silence is not.', + ); + } + if (waiverProblems.length > 0) { + red = true; + console.error(`\nFAIL — ${waiverProblems.length} provenance-waiver problem(s):`); + for (const problem of waiverProblems) console.error(` ${problem}`); + } + if (!red) { + console.log(`OK — every registered-code stamp site is listed under its own owner key or carries a recorded waiver ` + + `(${PROVENANCE_WAIVERS.length} waiver(s), all live)`); + } + return red ? 1 : 0; +} + +// --------------------------------------------------------------------------- +// Self-test — the red leg, pinned per pattern and per waiver direction +// --------------------------------------------------------------------------- + +function selfTest(): number { + const failures: string[] = []; + const check = (name: string, ok: boolean): void => { + if (!ok) failures.push(name); + }; + const registered = new Set(['REGISTERED_ONE', 'REGISTERED_TWO']); + const site = (pkg: string, code: string): StampSite => ({ + file: 'packages/x/src/a.ts', + line: 1, + package: pkg, + code, + pattern: 'objlit', + }); + const ledger = { '@objectstack/owner': ['REGISTERED_ONE', 'REGISTERED_TWO'] } as const; + + // Each published pattern catches its spelling (red leg, per pattern). + check( + 'objlit catches a stamp', + scanSourceText("return { code: 'REGISTERED_ONE' };", registered).some((h) => h.pattern === 'objlit'), + ); + check( + 'assign catches a stamp', + scanSourceText("err.code = 'REGISTERED_ONE';", registered).some((h) => h.pattern === 'assign'), + ); + check( + 'constdef catches a *_CODE constant', + scanSourceText("export const MY_CODE = 'REGISTERED_ONE';", registered).some((h) => h.pattern === 'constdef'), + ); + check( + 'constdef admits a type annotation', + scanSourceText("const MY_CODE: string = 'REGISTERED_ONE';", registered).some((h) => h.pattern === 'constdef'), + ); + // Population boundary: an unregistered code is the sibling gate's subject. + check( + 'unregistered code is out of population', + scanSourceText("return { code: 'NOT_IN_LEDGER' };", registered).length === 0, + ); + // Comment masking: a code quoted in prose is not a site. + check( + 'a commented stamp is not a site', + scanSourceText("// answers { code: 'REGISTERED_ONE' } on refusal\nconst x = 1;", registered).length === 0, + ); + // A synthetic unlisted stamper is caught THROUGH the real reconciliation. + { + const { violations } = deriveFindings([site('@objectstack/rogue', 'REGISTERED_ONE')], ledger, []); + check('unlisted stamper is a violation', violations.length === 1); + } + // A listed stamper is green. + { + const { violations, listed } = deriveFindings([site('@objectstack/owner', 'REGISTERED_ONE')], ledger, []); + check('listed stamper is green', violations.length === 0 && listed.length === 1); + } + // A waiver admits exactly its (package, code) pair — and only that pair. + { + const waiver: ProvenanceWaiver = { + package: '@objectstack/rogue', + code: 'REGISTERED_ONE', + registeredUnder: '@objectstack/owner', + reason: 'self-test fixture: recorded decision', + }; + const admitted = deriveFindings([site('@objectstack/rogue', 'REGISTERED_ONE')], ledger, [waiver]); + check('waiver admits its pair', admitted.violations.length === 0 && admitted.waived.length === 1 + && admitted.waiverProblems.length === 0); + const other = deriveFindings( + [site('@objectstack/rogue', 'REGISTERED_ONE'), site('@objectstack/rogue', 'REGISTERED_TWO')], + ledger, + [waiver], + ); + check('waiver does not admit a different code', other.violations.length === 1); + } + // Stale-waiver directions, each red. + { + const noSite = deriveFindings([], ledger, [{ + package: '@objectstack/rogue', + code: 'REGISTERED_ONE', + registeredUnder: '@objectstack/owner', + reason: 'self-test fixture', + }]); + check('waiver with no site reddens', noSite.waiverProblems.some((p) => p.includes('NO stamp site'))); + const wrongOwner = deriveFindings([site('@objectstack/rogue', 'REGISTERED_ONE')], ledger, [{ + package: '@objectstack/rogue', + code: 'REGISTERED_ONE', + registeredUnder: '@objectstack/absent', + reason: 'self-test fixture', + }]); + check('waiver naming a non-listing owner reddens', wrongOwner.waiverProblems.some((p) => p.includes('registeredUnder'))); + const deadWeight = deriveFindings([site('@objectstack/owner', 'REGISTERED_ONE')], ledger, [{ + package: '@objectstack/owner', + code: 'REGISTERED_ONE', + registeredUnder: '@objectstack/owner', + reason: 'self-test fixture', + }]); + check('row + waiver is dead weight', deadWeight.waiverProblems.some((p) => p.includes('dead weight'))); + } + + if (failures.length > 0) { + console.error(`self-test FAILED: ${failures.join('; ')}`); + return 1; + } + console.log(`self-test OK — ${STAMP_PATTERNS.length} patterns and every waiver direction pinned`); + return 0; +} + +if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const args = process.argv.slice(2); + process.exit(args.includes('--self-test') ? selfTest() : run(args.includes('--report'))); +} diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index d57ccb30fc..d486ef0a44 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -223,6 +223,18 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ // failing on `main` itself. The doc it checks against is hand-written, so there // is no generator to name. { check: 'check:variant-docs', why: 'audits that each schema variant appears in its hand-written doc — no artifact' }, + // #13353. A pure source audit over the WHOLE workspace, not this package: + // every stamp site of a ledger-registered code in `packages/**` non-test + // source must be listed under the stamping package's own owner key or carry + // a recorded PROVENANCE_WAIVERS entry (both live in + // src/api/error-code-ledger.zod.ts). Reads source text through tsx and + // writes nothing: a failure is a ledger row or waiver to record — a + // provenance DECISION — never a `gen:` to run, and a generator that wrote + // rows from the scan would admit an emitter by running a command. + { + check: 'check:error-code-provenance', + why: 'audits that packages stamping ledger-registered codes list them under their own owner key (or carry a recorded waiver) — no artifact', + }, // `check:strictness-ledger` used to sit here — "the ledger it audits is a // hand-maintained doc, so there is no generator". #5107 gave it one (the ledger's // NUMBERS became an artifact; its VERDICTS stayed hand-written), so it moved to diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 09df76ca40..614fb8b4c0 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -93,6 +93,18 @@ * A code emitted by several packages is listed once per emitting package — * the union dedupes; the per-package rows are provenance, not identity. * + * Since #13353 that sentence has a mechanical half: the provenance gate + * (`check:error-code-provenance`, `packages/spec/scripts/`) sweeps every + * stamp site of a REGISTERED code in `packages/**` non-test source and fails + * when the stamping package's own owner key does not list it. The admission + * rules below never ask WHO emits, so before that gate an unlisted emitter was + * invisible to every gate the repo has (three hand sweeps found the same class + * three times: #7504, #13254, #13353). Two deliberate shapes are NOT rows and + * are recorded in {@link PROVENANCE_WAIVERS} instead: a DOOR in another + * package that names the wire vocabulary itself (`FLOW_DISABLED`, + * `UPDATE_ID_MISMATCH` — see their rows' comments), and a shared constructor + * package whose throw is served under another package's registration. + * * ## Retiring a code * * A row whose last EMITTER is deleted comes out with it. The admission rules @@ -590,8 +602,28 @@ export const ERROR_CODE_LEDGER = { ], '@objectstack/trigger-api': [ 'ENQUEUE_FAILED', // queue accepted the call but publish threw + // [#13353] The hook endpoint's malformed-body refusals — `handleRequest` + // (`api-trigger.ts`) answers 400 with this code for a body that is not + // valid JSON or not a JSON object, and the package's OWN plugin serves + // that `{ status, body }` verbatim (`plugin.ts`, `c.json(out.body, + // out.status)` on the raw-app `POST .../automation/hooks/:flowName/:hookId` + // route). Same handler, same door as the two rows beside it — the wire + // vocabulary is named here, not at some other package's door. Provenance + // only: the code was already registered (six other packages), so the + // union, casing and every other row are unchanged. + 'INVALID_REQUEST', 'INVALID_SIGNATURE', // hook secret did not verify the request body ], + '@objectstack/cli': [ + // [#13353] The serve command's unknown-hostname guard (`commands/serve.ts`, + // `unknown-hostname-guard`): a request whose hostname is bound to no + // environment is answered 404 with this code by the CLI's OWN middleware + // (`c.json`, the JSON limb beside the HTML one) on a server already + // serving HTTP. The door is the stamping package itself. Second EMITTER of + // the code `@objectstack/cloud-connection` already registers — one + // condition, one vocabulary; provenance, not identity (see above). + 'ENVIRONMENT_NOT_FOUND', + ], '@objectstack/cloud-connection': [ 'CLOUD_FETCH_FAILED', // fetching the manifest/bundle from cloud failed 'CLOUD_UNCONFIGURED', // no cloud endpoint configured on this runtime @@ -599,6 +631,16 @@ export const ERROR_CODE_LEDGER = { 'DRIVER_UNAVAILABLE', // no driver service — cannot purge seeded rows 'ENVIRONMENT_BIND_FAILED', 'ENVIRONMENT_NOT_FOUND', + // [#13353] `requireInstallCapability`'s 403 + // (`marketplace-install-local-plugin.ts`): a caller without the + // install-local capability is refused on all four install/uninstall/ + // reseed/purge doors, by the plugin's OWN Hono routes — the same + // plugin-route door this package's UNIQUE_SCOPE_CONFIRMATION_REQUIRED row + // below already records. The wire value predates the row (provenance + // only); the spelling is the #8211-waived FORBIDDEN synonym — the waiver + // admits the (code, shadows) pair, and this row extends its emitter list, + // never endorses the spelling for new code. + 'FORBIDDEN', 'INVALID_REQUEST', 'MANIFEST_CONFLICT', // manifest_id already defined by local code 'MARKETPLACE_PROXY_FAILED', @@ -676,6 +718,18 @@ export const ERROR_CODE_LEDGER = { 'SUGGESTION_NOT_FOUND', 'SUGGESTION_STATE', // suggestion exists but is not in a confirmable/dismissable state ], + '@objectstack/plugin-webhooks': [ + // [#13353] The redeliver endpoint's malformed-body refusal — the plugin + // mounts `POST /api/v1/webhooks/redeliver` DIRECTLY on the raw Hono app + // (`webhook-outbox-plugin.ts`, `registerAdminRoutes`) and answers 400 with + // this code when the body is not JSON. The door is the stamping package + // itself (the same raw-app plugin-route door shape as + // UNIQUE_SCOPE_CONFIRMATION_REQUIRED under cloud-connection); its sibling + // refusals on the route use standard-catalog members (`UNAUTHENTICATED`, + // `MISSING_REQUIRED_FIELD`), which need no row. Provenance only: the code + // was already registered by six other packages. + 'INVALID_REQUEST', + ], '@objectstack/driver-memory': [ // [#13254] Provenance for the in-memory driver's uniqueness refusal, which // #13197 (field-level `unique`) and #13239 (declared `indexes[]` entries) @@ -887,7 +941,9 @@ export const STANDARD_SYNONYM_WAIVERS: readonly StandardSynonymWaiver[] = [ code: 'FORBIDDEN', shadows: 'PERMISSION_DENIED', reason: 'Pre-gate synonym on the wire from @objectstack/rest, plugin-sharing and ' + - 'plugin-approvals. Wire value kept; consolidation deferred per #8211.', + 'plugin-approvals; #13353 added the cloud-connection provenance row for the same ' + + 'pre-existing wire value (its marketplace-install plugin-route 403). ' + + 'Wire value kept; consolidation deferred per #8211.', }, { code: 'INTERNAL', @@ -944,3 +1000,136 @@ export function standardSynonymViolations( } return violations; } + +// ========================================== +// Provenance waivers (#13353) +// ========================================== + +/** + * A recorded provenance waiver: why a package whose non-test source stamps a + * REGISTERED code deliberately carries no owner-key row for it (#13353). + * + * The provenance gate (`check:error-code-provenance`, + * `packages/spec/scripts/`) fails any stamp site of a registered code that the + * stamping package's own owner key does not list — the drift three hand + * sweeps (#7504, #13254, #13353) each re-found. But "stamps the string" and + * "owns the wire emission" are different facts, and the ledger already records + * decisions where they diverge. A waiver keeps that divergence a decision on + * the record, in the same file the rows live in, exactly as + * {@link STANDARD_SYNONYM_WAIVERS} does for the synonym rule. Three recorded + * shapes: + * + * - **The door, not the producer, names the wire vocabulary.** The stamped + * value is read by a door in ANOTHER package, which owns — and registers — + * the wire emission (`FLOW_DISABLED` et al. under `@objectstack/runtime`; + * `EXTERNAL_IMPORT_ERROR` under `@objectstack/rest`, whose import route's + * catch stamps the code itself for every `importObject` throw). + * - **A shared constructor one package over from its registered emitter.** + * The helper that spells the string lives in a dependency-light package by + * design (#8016), and the package whose production path throws/serves it is + * the one registered (`UPDATE_ID_MISMATCH` under `@objectstack/objectql`, + * stamped by metadata-core's helper). + * - **Client-side synthesis.** The SDK mirrors a code the SERVER registers so + * caller branches fire identically; the ledger's scope prose is about the + * serving side (`UPLOAD_SESSION_EXPIRED`). + * + * A waiver admits exactly the `(package, code)` pair it records, and the gate + * holds each one live in three directions: the named `registeredUnder` key + * must still list the code, the waived package must still NOT list it (a row + * plus a waiver is dead weight), and the scan must still find a stamp site for + * the pair (a waiver whose site is gone comes out with it). + */ +export const ProvenanceWaiverSchema = z.object({ + package: z.string().regex(/^@objectstack\/[a-z0-9-]+$/) + .describe('The package whose source stamps the code without an owner-key row'), + code: z.string().regex(/^[A-Z][A-Z0-9_]*$/) + .describe('The registered code the package stamps'), + registeredUnder: z.string().regex(/^@objectstack\/[a-z0-9-]+$/) + .describe('The owner key that deliberately carries the row instead'), + reason: z.string().min(1) + .describe('Why the stamping package carries no row — recorded so provenance is a decision, not drift'), +}); + +export type ProvenanceWaiver = z.input; + +/** + * The recorded provenance waivers. Every entry is a decision with its evidence + * — most were written down in the rows' own comments long before the gate + * existed and are transcribed here so a machine can hold them; the + * `EXTERNAL_IMPORT_ERROR` and `UPLOAD_SESSION_EXPIRED` entries were + * adjudicated on #13353 itself. + */ +export const PROVENANCE_WAIVERS: readonly ProvenanceWaiver[] = [ + { + package: '@objectstack/metadata-core', + code: 'UPDATE_ID_MISMATCH', + registeredUnder: '@objectstack/objectql', + reason: 'Shared constructor one package over: metadata-core\'s ' + + '`engineUpdateDispatchRejectError` spells the string, but the throw ships in ' + + 'production from `ObjectQL.update` (engine.ts) — the objectql row\'s own comment ' + + 'records "hence registered here" (#11142/#11230).', + }, + { + package: '@objectstack/service-automation', + code: 'FLOW_DISABLED', + registeredUnder: '@objectstack/runtime', + reason: 'The trigger door, not the producer, names the wire vocabulary: the engine ' + + 'returns `AutomationResult.code` and runtime\'s doors read it and answer 409 ' + + '(#9415/#9446; the runtime row\'s comment records the decision).', + }, + { + package: '@objectstack/service-automation', + code: 'FLOW_NO_START_NODE', + registeredUnder: '@objectstack/runtime', + reason: 'Same decision as FLOW_DISABLED, 422 arm (#9415/#9446): the trigger door ' + + 'names the wire vocabulary; the engine result carries the classification.', + }, + { + package: '@objectstack/service-automation', + code: 'FLOW_INPUT_SCHEMA_INVALID', + registeredUnder: '@objectstack/runtime', + reason: 'Registered ahead of its producer by design (#10025 → #11504, the #10413 → ' + + '#10576 split shape): the engine\'s `execute()` catch classifies the refusal, the ' + + 'trigger door serves it — the runtime row\'s comment records "registered HERE and ' + + 'not under the engine\'s package" with its three FLOW_* siblings.', + }, + { + package: '@objectstack/service-datasource', + code: 'EXTERNAL_IMPORT_ERROR', + registeredUnder: '@objectstack/rest', + reason: 'Adjudicated on #13353: the only door for `importObject` is rest\'s ' + + '`POST …/tables/:remote/import` (external-datasource-routes.ts), whose catch stamps ' + + 'this code itself for EVERY importObject throw and never reads the producer\'s ' + + 'declaration — the door names the wire vocabulary. The producer\'s `err.code` ' + + '(`importNameRefusedError`) is the #8016 declaration shape, agreeing with the door ' + + 'by construction, not a second wire emitter.', + }, + { + package: '@objectstack/client', + code: 'UPLOAD_SESSION_EXPIRED', + registeredUnder: '@objectstack/service-storage', + reason: 'Client-side synthesis (#7870): `resumeUpload` mirrors the server\'s 410 pair ' + + 'when the progress poll reports `expired`, so caller branches fire identically. The ' + + 'ledger\'s scope prose covers the SERVING side; whether a client-synthesised code ' + + 'belongs in the ledger at all is the open scope question #13353 recorded — ' + + 'deliberately a waiver, not a row, until that question is ruled.', + }, + { + package: '@objectstack/spec', + code: 'ITEM_LOCKED', + registeredUnder: '@objectstack/metadata-protocol', + reason: 'Shared evaluator one package over: `evaluateLockForWrite`/`…ForDelete` ' + + '(kernel/metadata-protection.zod.ts) construct the structured refusal, and the ' + + 'protocol layer — the registered emitter — turns it into the 403 the wire carries ' + + '(ADR-0010 §3.3). Spec ships schemas and pure helpers, never an HTTP door.', + }, + { + package: '@objectstack/types', + code: 'VALIDATION_FAILED', + registeredUnder: '@objectstack/runtime', + reason: 'Shared constructor by design (#8016/#3918): `validationFailure()` lives in ' + + 'the dependency-light package so BOTH doors recognise one shape; the throws are ' + + 'served under the emitting doors\' own registrations (runtime\'s dispatcher exits, ' + + 'rest\'s `mapDataError` — both packages list the code).', + }, +]; From 7e76f29f7fd225fbffd5c5956989e60f8ce136ff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 04:26:36 +0000 Subject: [PATCH 2/3] feat(spec): regen artifacts, admission-pin update, dispatcher-safe fixtures Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 --- .../docs/references/api/error-code-ledger.mdx | 30 ++++++++++- content/docs/references/index.mdx | 10 ++-- ...07-unknown-key-strictness-ledger.counts.md | 2 +- packages/spec/api-surface/api.json | 3 ++ packages/spec/authorable-surface/api.json | 4 ++ packages/spec/declaration-map/api.json | 2 + packages/spec/export-origins/api.json | 3 ++ packages/spec/json-schema.manifest/api.json | 1 + .../scripts/check-error-code-provenance.ts | 54 ++++++++++++------- .../spec/src/api/error-code-ledger.test.ts | 5 ++ 10 files changed, 86 insertions(+), 28 deletions(-) diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 18e39f4ab2..5f419edcec 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -97,6 +97,18 @@ registration is a recorded waiver, never drift. A code registered NOWHERE A code emitted by several packages is listed once per emitting package — the union dedupes; the per-package rows are provenance, not identity. +Since #13353 that sentence has a mechanical half: the provenance gate +(`check:error-code-provenance`, `packages/spec/scripts/`) sweeps every +stamp site of a REGISTERED code in `packages/**` non-test source and fails +when the stamping package's own owner key does not list it. The admission +rules below never ask WHO emits, so before that gate an unlisted emitter was +invisible to every gate the repo has (three hand sweeps found the same class +three times: #7504, #13254, #13353). Two deliberate shapes are NOT rows and +are recorded in `PROVENANCE_WAIVERS` instead: a DOOR in another +package that names the wire vocabulary itself (`FLOW_DISABLED`, +`UPDATE_ID_MISMATCH` — see their rows' comments), and a shared constructor +package whose throw is served under another package's registration. + ## Retiring a code A row whose last EMITTER is deleted comes out with it. The admission rules @@ -129,8 +141,8 @@ SEPARATE vocabulary and do not belong here — see #3977 (ADR-0112 D6). ## TypeScript Usage ```typescript -import { ErrorCode, StandardSynonymWaiverSchema } from '@objectstack/spec/api'; -import type { ErrorCode, StandardSynonymWaiver } from '@objectstack/spec/api'; +import { ErrorCode, ProvenanceWaiverSchema, StandardSynonymWaiverSchema } from '@objectstack/spec/api'; +import type { ErrorCode, ProvenanceWaiver, StandardSynonymWaiver } from '@objectstack/spec/api'; // Validate data const result = ErrorCode.parse(data); @@ -442,6 +454,20 @@ const result = ErrorCode.parse(data); * `WRONG_PASSWORD` +--- + +## ProvenanceWaiver + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **package** | `string` | ✅ | The package whose source stamps the code without an owner-key row | +| **code** | `string` | ✅ | The registered code the package stamps | +| **registeredUnder** | `string` | ✅ | The owner key that deliberately carries the row instead | +| **reason** | `string` | ✅ | Why the stamping package carries no row — recorded so provenance is a decision, not drift | + + --- ## StandardSynonymWaiver diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 03b24d1d0c..713b5ab4c6 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1597 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1598 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 31 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 31 | 439 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1597** | 14 protocol modules | +| **Total** | **200** | **1598** | 14 protocol modules | --- @@ -61,7 +61,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 438 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 439 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -77,7 +77,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`dispatcher.zod.ts`](/docs/references/api/dispatcher) | `DispatcherConfig`, `DispatcherErrorCode`, `DispatcherErrorResponse`, `DispatcherRoute` | | [`documentation.zod.ts`](/docs/references/api/documentation) | `ApiChangelogEntry`, `ApiDocumentationConfig`, `ApiTestCollection`, `ApiTestRequest`, `ApiTestingUiConfig`, `ApiTestingUiType`, `CodeGenerationTemplate`, `GeneratedApiDocumentation`, `OpenApiSecurityScheme`, `OpenApiServer`, `OpenApiSpec` | | [`endpoint.zod.ts`](/docs/references/api/endpoint) | `ApiEndpoint`, `ApiMapping` | -| [`error-code-ledger.zod.ts`](/docs/references/api/error-code-ledger) | `ErrorCode`, `StandardSynonymWaiver` | +| [`error-code-ledger.zod.ts`](/docs/references/api/error-code-ledger) | `ErrorCode`, `ProvenanceWaiver`, `StandardSynonymWaiver` | | [`errors.zod.ts`](/docs/references/api/errors) | `EnhancedApiError`, `ErrorCategory`, `ErrorResponse`, `FieldError`, `FieldErrorCode`, `RetryStrategy`, `StandardErrorCode` | | [`events.zod.ts`](/docs/references/api/events) | `BulkDataEvent`, `BulkDataEventType`, `DataEvent`, `DataEventType`, `MetadataEvent`, `MetadataEventType` | | [`export.zod.ts`](/docs/references/api/export) | `CreateExportJobRequest`, `CreateExportJobResponse`, `CreateImportJobRequest`, `CreateImportJobResponse`, `DeduplicationStrategy`, `ExportFormat`, `ExportImportTemplate`, `ExportJobProgress`, `ExportJobStatus`, `ExportJobSummary`, `FieldMappingEntry`, `GetExportJobDownloadRequest`, `GetExportJobDownloadResponse`, `ImportJobProgress`, `ImportJobResults`, `ImportJobStatus`, `ImportJobSummary`, `ImportMapping`, `ImportRequest`, `ImportResponse`, `ImportRowResult`, `ImportValidationConfig`, `ImportValidationMode`, `ImportValidationResult`, `ImportWriteMode`, `ListExportJobsRequest`, `ListExportJobsResponse`, `ListImportJobsRequest`, `ListImportJobsResponse`, `ScheduleExportRequest`, `ScheduleExportResponse`, `ScheduledExport`, `UndoImportJobResponse` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 6ecb446c23..cef0c1ffb6 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -257,7 +257,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 453 | +| `api/` | 454 | | `cloud/` | 83 | | `identity/` | 32 | | `integration/` | 10 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 2f1af995ab..7b4d07d400 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -726,6 +726,7 @@ "OpenApiSpecSchema (const)", "OperatorMapping (type)", "OperatorMappingSchema (const)", + "PROVENANCE_WAIVERS (const)", "PackageApiContracts (const)", "PackageApiErrorCode (type)", "PackageExportManifest (type)", @@ -767,6 +768,8 @@ "PresignedUrlResponse (type)", "PresignedUrlResponseParsed (type)", "PresignedUrlResponseSchema (const)", + "ProvenanceWaiver (type)", + "ProvenanceWaiverSchema (const)", "PublishMetaItemRequest (type)", "PublishMetaItemRequestSchema (const)", "PublishMetaItemResponse (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 6a10d20d70..fe97732e63 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1309,6 +1309,10 @@ "api/PresignedUrlResponse:error", "api/PresignedUrlResponse:meta", "api/PresignedUrlResponse:success", + "api/ProvenanceWaiver:code", + "api/ProvenanceWaiver:package", + "api/ProvenanceWaiver:reason", + "api/ProvenanceWaiver:registeredUnder", "api/PublishMetaItemRequest:actor", "api/PublishMetaItemRequest:message", "api/PublishMetaItemRequest:name", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index f28ac8773a..e849b01800 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -573,6 +573,8 @@ "PresenceUpdateSchema": "api/PresenceUpdate", "PresignedUrlResponse": "api/PresignedUrlResponse", "PresignedUrlResponseSchema": "api/PresignedUrlResponse", + "ProvenanceWaiver": "api/ProvenanceWaiver", + "ProvenanceWaiverSchema": "api/ProvenanceWaiver", "PublishMetaItemRequest": "api/PublishMetaItemRequest", "PublishMetaItemRequestSchema": "api/PublishMetaItemRequest", "PublishMetaItemResponse": "api/PublishMetaItemResponse", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 4467fd490a..c8f4b4232f 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -726,6 +726,7 @@ "OpenApiSpecSchema": "src/api/documentation.zod.ts#OpenApiSpecSchema (const)", "OperatorMapping": "src/api/query-adapter.zod.ts#OperatorMapping (type)", "OperatorMappingSchema": "src/api/query-adapter.zod.ts#OperatorMappingSchema (const)", + "PROVENANCE_WAIVERS": "src/api/error-code-ledger.zod.ts#PROVENANCE_WAIVERS (const)", "PackageApiContracts": "src/api/package-api.zod.ts#PackageApiContracts (const)", "PackageApiErrorCode": "src/api/package-api.zod.ts#PackageApiErrorCode (type)", "PackageExportManifest": "src/api/package-lifecycle.zod.ts#PackageExportManifest (type)", @@ -767,6 +768,8 @@ "PresignedUrlResponse": "src/api/storage.zod.ts#PresignedUrlResponse (type)", "PresignedUrlResponseParsed": "src/api/storage.zod.ts#PresignedUrlResponseParsed (type)", "PresignedUrlResponseSchema": "src/api/storage.zod.ts#PresignedUrlResponseSchema (const)", + "ProvenanceWaiver": "src/api/error-code-ledger.zod.ts#ProvenanceWaiver (type)", + "ProvenanceWaiverSchema": "src/api/error-code-ledger.zod.ts#ProvenanceWaiverSchema (const)", "PublishMetaItemRequest": "src/api/protocol.zod.ts#PublishMetaItemRequest (type)", "PublishMetaItemRequestSchema": "src/api/protocol.zod.ts#PublishMetaItemRequestSchema (const)", "PublishMetaItemResponse": "src/api/protocol.zod.ts#PublishMetaItemResponse (type)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index fde7da17d5..c886e11f74 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -315,6 +315,7 @@ "api/PresenceStatus", "api/PresenceUpdate", "api/PresignedUrlResponse", + "api/ProvenanceWaiver", "api/PublishMetaItemRequest", "api/PublishMetaItemResponse", "api/PublishPackageDraftsResponse", diff --git a/packages/spec/scripts/check-error-code-provenance.ts b/packages/spec/scripts/check-error-code-provenance.ts index 3f9f272dad..9e74797b6d 100644 --- a/packages/spec/scripts/check-error-code-provenance.ts +++ b/packages/spec/scripts/check-error-code-provenance.ts @@ -374,7 +374,20 @@ function selfTest(): number { const check = (name: string, ok: boolean): void => { if (!ok) failures.push(name); }; - const registered = new Set(['REGISTERED_ONE', 'REGISTERED_TWO']); + // ⚠️ The fixture code spellings are REAL registered codes on purpose, driven + // against a SYNTHETIC ledger (so every verdict below is still fixture-only). + // This file is itself inside `check:dispatcher-error-vocabulary`'s scan + // population (`packages/**` non-test source), and a made-up SCREAMING_SNAKE + // literal in a stamp-shaped position here would be reported by that gate as + // an unclassified unregistered stamp site — measured, not hypothetical: the + // first draft of this self-test reddened it with five fixture sites. A + // registered spelling is out of that gate's population by construction, and + // out of THIS gate's real run too (the real scan excludes non-`src/` trees, + // so this script never scans itself). + const CODE_A = 'UNIQUE_VIOLATION'; // real registered spelling, synthetic role: listed by the fixture owner + const CODE_B = 'FLOW_FAILED'; // real registered spelling, synthetic role: the OTHER listed code + const CODE_OUT = 'ITEM_LOCKED'; // real registered spelling, synthetic role: outside the injected set + const registered = new Set([CODE_A, CODE_B]); const site = (pkg: string, code: string): StampSite => ({ file: 'packages/x/src/a.ts', line: 1, @@ -382,58 +395,59 @@ function selfTest(): number { code, pattern: 'objlit', }); - const ledger = { '@objectstack/owner': ['REGISTERED_ONE', 'REGISTERED_TWO'] } as const; + const ledger = { '@objectstack/owner': [CODE_A, CODE_B] } as const; // Each published pattern catches its spelling (red leg, per pattern). check( 'objlit catches a stamp', - scanSourceText("return { code: 'REGISTERED_ONE' };", registered).some((h) => h.pattern === 'objlit'), + scanSourceText(`return { code: '${CODE_A}' };`, registered).some((h) => h.pattern === 'objlit'), ); check( 'assign catches a stamp', - scanSourceText("err.code = 'REGISTERED_ONE';", registered).some((h) => h.pattern === 'assign'), + scanSourceText(`err.code = '${CODE_A}';`, registered).some((h) => h.pattern === 'assign'), ); check( 'constdef catches a *_CODE constant', - scanSourceText("export const MY_CODE = 'REGISTERED_ONE';", registered).some((h) => h.pattern === 'constdef'), + scanSourceText(`export const MY_CODE = '${CODE_A}';`, registered).some((h) => h.pattern === 'constdef'), ); check( 'constdef admits a type annotation', - scanSourceText("const MY_CODE: string = 'REGISTERED_ONE';", registered).some((h) => h.pattern === 'constdef'), + scanSourceText(`const MY_CODE: string = '${CODE_A}';`, registered).some((h) => h.pattern === 'constdef'), ); - // Population boundary: an unregistered code is the sibling gate's subject. + // Population boundary: a code outside the registered set is the sibling + // gate's subject, never a site here. check( - 'unregistered code is out of population', - scanSourceText("return { code: 'NOT_IN_LEDGER' };", registered).length === 0, + 'a code outside the registered set is out of population', + scanSourceText(`return { code: '${CODE_OUT}' };`, registered).length === 0, ); // Comment masking: a code quoted in prose is not a site. check( 'a commented stamp is not a site', - scanSourceText("// answers { code: 'REGISTERED_ONE' } on refusal\nconst x = 1;", registered).length === 0, + scanSourceText(`// answers { code: '${CODE_A}' } on refusal\nconst x = 1;`, registered).length === 0, ); // A synthetic unlisted stamper is caught THROUGH the real reconciliation. { - const { violations } = deriveFindings([site('@objectstack/rogue', 'REGISTERED_ONE')], ledger, []); + const { violations } = deriveFindings([site('@objectstack/rogue', CODE_A)], ledger, []); check('unlisted stamper is a violation', violations.length === 1); } // A listed stamper is green. { - const { violations, listed } = deriveFindings([site('@objectstack/owner', 'REGISTERED_ONE')], ledger, []); + const { violations, listed } = deriveFindings([site('@objectstack/owner', CODE_A)], ledger, []); check('listed stamper is green', violations.length === 0 && listed.length === 1); } // A waiver admits exactly its (package, code) pair — and only that pair. { const waiver: ProvenanceWaiver = { package: '@objectstack/rogue', - code: 'REGISTERED_ONE', + code: CODE_A, registeredUnder: '@objectstack/owner', reason: 'self-test fixture: recorded decision', }; - const admitted = deriveFindings([site('@objectstack/rogue', 'REGISTERED_ONE')], ledger, [waiver]); + const admitted = deriveFindings([site('@objectstack/rogue', CODE_A)], ledger, [waiver]); check('waiver admits its pair', admitted.violations.length === 0 && admitted.waived.length === 1 && admitted.waiverProblems.length === 0); const other = deriveFindings( - [site('@objectstack/rogue', 'REGISTERED_ONE'), site('@objectstack/rogue', 'REGISTERED_TWO')], + [site('@objectstack/rogue', CODE_A), site('@objectstack/rogue', CODE_B)], ledger, [waiver], ); @@ -443,21 +457,21 @@ function selfTest(): number { { const noSite = deriveFindings([], ledger, [{ package: '@objectstack/rogue', - code: 'REGISTERED_ONE', + code: CODE_A, registeredUnder: '@objectstack/owner', reason: 'self-test fixture', }]); check('waiver with no site reddens', noSite.waiverProblems.some((p) => p.includes('NO stamp site'))); - const wrongOwner = deriveFindings([site('@objectstack/rogue', 'REGISTERED_ONE')], ledger, [{ + const wrongOwner = deriveFindings([site('@objectstack/rogue', CODE_A)], ledger, [{ package: '@objectstack/rogue', - code: 'REGISTERED_ONE', + code: CODE_A, registeredUnder: '@objectstack/absent', reason: 'self-test fixture', }]); check('waiver naming a non-listing owner reddens', wrongOwner.waiverProblems.some((p) => p.includes('registeredUnder'))); - const deadWeight = deriveFindings([site('@objectstack/owner', 'REGISTERED_ONE')], ledger, [{ + const deadWeight = deriveFindings([site('@objectstack/owner', CODE_A)], ledger, [{ package: '@objectstack/owner', - code: 'REGISTERED_ONE', + code: CODE_A, registeredUnder: '@objectstack/owner', reason: 'self-test fixture', }]); diff --git a/packages/spec/src/api/error-code-ledger.test.ts b/packages/spec/src/api/error-code-ledger.test.ts index d66f45fbc9..19d0d56e4a 100644 --- a/packages/spec/src/api/error-code-ledger.test.ts +++ b/packages/spec/src/api/error-code-ledger.test.ts @@ -157,6 +157,11 @@ describe('standard-synonym detection (#8211)', () => { const withoutForbidden = STANDARD_SYNONYM_WAIVERS.filter((w) => w.code !== 'FORBIDDEN'); const violations = standardSynonymViolations(ERROR_CODE_LEDGER, withoutForbidden); expect(violations.map((v) => v.package).sort()).toEqual([ + // cloud-connection joined with #13353's provenance row for the same + // pre-existing wire value (the marketplace-install plugin-route 403) — + // the waiver admits the (code, shadows) pair, so a new emitter listing + // widens this reverse pin, never the waiver table. + '@objectstack/cloud-connection', '@objectstack/plugin-approvals', '@objectstack/plugin-sharing', '@objectstack/rest', From da672bb561d2e5dba9631eeca4d2a2835a9cbbbf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 04:42:00 +0000 Subject: [PATCH 3/3] test(spec): ADR-0122 isomorphism pin Iso865 for ProvenanceWaiverSchema Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 --- .../spec/src/type-alias-convention.pin.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 2dd0e2893d..dac7ae4a14 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -269,7 +269,7 @@ import type * as M170 from './ui/component.zod.js'; import type * as M183 from './api/sortability.zod.js'; // --------------------------------------------------------------------------- -// 835 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 836 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -407,6 +407,7 @@ export type Iso83 = Assert, z.infer< typ // api/error-code-ledger.zod.ts export type Iso838 = Assert, z.infer< typeof M182.StandardSynonymWaiverSchema > >>; +export type Iso865 = Assert, z.infer< typeof M182.ProvenanceWaiverSchema > >>; export type Iso84 = Assert, z.infer< typeof M20.FieldErrorCode > >>; export type Iso85 = Assert, z.infer< typeof M20.FieldErrorSchema > >>; @@ -1681,7 +1682,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 835 isomorphic pins', () => { + it('still declares all 836 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2038,9 +2039,20 @@ describe('ADR-0122 type-alias convention', () => { // either tree, so the two shapes coincide and ADR-0122 gives each a pin // rather than an `XParsed`. Ids `Iso863`/`Iso864`, the next free ones — // ids are claims about pins, not positions. + // + // 835 -> 836 is #13353's `ProvenanceWaiverSchema` — the recorded waiver + // that keeps a registered-code stamp site without an owner-key row a + // decision instead of drift (the door-not-producer class). Isomorphism + // MEASURED, not assumed: four `z.string()`s (three regex-, one + // min-constrained — constraints refine, they do not reshape), with no + // `.default()`, `.transform()`, `.catch()`, `.optional()` or `.pipe()` + // anywhere, so the two shapes coincide and ADR-0122 gives it a pin rather + // than an `XParsed` — the exact reasoning of its #8211 sibling `Iso838` + // one entry up. Its id is `Iso865`, the next free one — ids are claims + // about pins, not positions. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert