From 2debf7e860ef6e27f342affdb2192e60eab34795 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:35:03 +0000 Subject: [PATCH 1/2] chore(spec): classify what a restriction-shaped property's EMPTY value means (#3896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3929 fixed one field. `sys_sharing_rule.criteria_json` was optional, and its absence evaluated to `find(object, { filter: {} })` — every record of the object, granted to the recipient. Its description said so out loud: "leave empty to share every record." That sentence is the part worth generalising. For a platform whose premise is that agents author metadata, a field description is not documentation about the contract, it IS the contract the next author reads — and omission is the commonest error a model makes, precisely because it raises none. When omission also lands on the widest grant, the likeliest mistake is the most dangerous outcome, silently. Sweeping the spec found the same syntactic shape — an optional list or predicate that "restricts" something — carrying opposite meanings when empty: object.apiMethods undefined = unrestricted, [] = deny-all allowedSources "empty = all allowed" sharing condition nothing is shared (#3929) Nothing marked which was which. A maintainer knows by memory; a model cannot. New gate `check:empty-state`, wired into the Spec Liveness Check workflow: it scans the schema surface for statements declaring an empty state permissive and requires each to be classified in empty-state-registry.mts as `scope`, `closed`, `open` or `output`. Access-gate classifications must cite where the posture is enforced, resolved against the checkout so a rotted pointer is reported. 20 statements across 214 schema files are classified; a new unclassified one fails. Detection matches the STATEMENT, not field names — names would be a guess, and the liveness README is blunt about where that ends up ("a permanently-noisy check is a check nobody reads"). Negated tokens are ignored, so the ⚠ that object.zod.ts prints to warn an empty whitelist is DENY-ALL is not itself flagged as permissive. Statements resolving to no property are narrative and reported as non-failing notes. One correction: `DynamicLoadingConfig.allowedSources`, a supply-chain gate, documented [] as admitting every source. It now states the apiMethods three-state — undefined = any source, [] = deny-all, a subset = those types. The empty ARRAY is closed; only ABSENCE is open. Collapsing the two is what makes an allow-list vacuous. The field has no runtime consumer (the whole block is declared-but-unenforced, ADR-0049 false compliance, not addressed here), which is why the wording mattered: an unimplemented property's description is the spec whoever implements it builds to. It now carries an [EXPERIMENTAL] marker. Also registers the sharing-rule-criteria-required dogfood proof added by #3929, which had been declaring a @proof: tag the registry did not know about. No runtime behaviour changes. spec: 263 files / 6858 tests green, tsc clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- .changeset/empty-state-semantics-gate.md | 67 ++++ .github/workflows/spec-liveness-check.yml | 7 + packages/spec/liveness/README.md | 57 ++++ packages/spec/package.json | 1 + .../scripts/liveness/check-empty-state.mts | 110 ++++++ .../scripts/liveness/empty-state-registry.mts | 211 ++++++++++++ .../spec/scripts/liveness/empty-state.mts | 313 ++++++++++++++++++ .../spec/scripts/liveness/empty-state.test.ts | 222 +++++++++++++ .../spec/scripts/liveness/proof-registry.mts | 22 ++ .../spec/src/kernel/plugin-runtime.zod.ts | 15 +- 10 files changed, 1023 insertions(+), 2 deletions(-) create mode 100644 .changeset/empty-state-semantics-gate.md create mode 100644 packages/spec/scripts/liveness/check-empty-state.mts create mode 100644 packages/spec/scripts/liveness/empty-state-registry.mts create mode 100644 packages/spec/scripts/liveness/empty-state.mts create mode 100644 packages/spec/scripts/liveness/empty-state.test.ts diff --git a/.changeset/empty-state-semantics-gate.md b/.changeset/empty-state-semantics-gate.md new file mode 100644 index 0000000000..e7f9110b07 --- /dev/null +++ b/.changeset/empty-state-semantics-gate.md @@ -0,0 +1,67 @@ +--- +"@objectstack/spec": patch +--- + +chore(spec): classify what a restriction-shaped property's EMPTY value means (#3896 follow-up) + +#3929 fixed one field. `sys_sharing_rule.criteria_json` was optional, and its +absence evaluated to `find(object, { filter: {} })` — every record of the object, +granted to the recipient. The field description said so out loud: *"leave empty +to share every record."* + +That sentence is the part worth generalising. For a platform whose premise is +that agents author metadata, **a field description is not documentation about the +contract, it is the contract the next author reads** — and omission is the +commonest authoring error a model makes, precisely because it produces no error. +When omission also lands on the widest grant, the likeliest mistake is the most +dangerous outcome, silently. + +Sweeping the spec surface found the same syntactic shape — an optional list or +predicate that "restricts" something — carrying opposite meanings when empty: + +| Property | Empty means | +|---|---| +| `object.apiMethods` | `undefined` = unrestricted, **`[]` = deny-all** | +| `plugin-runtime.allowedSources` | *"empty = all allowed"* | +| sharing `condition` | nothing is shared (#3929) | + +Nothing marked which was which. A maintainer knows by memory; a model cannot. + +**New gate — `pnpm --filter @objectstack/spec check:empty-state`**, wired into the +existing Spec Liveness Check workflow. It scans `packages/spec/src/**/*.zod.ts` +for statements declaring an empty state to be permissive and requires each to be +classified in `scripts/liveness/empty-state-registry.mts` as `scope` (selects a +range of work — empty = all is fine), `closed` (an access gate whose empty state +denies — the required posture for new gates), `open` (default-open on purpose, +mandatory rationale), or `output` (a computed projection, not authorable). +`closed` / `open` must cite where the posture is enforced, and the path is +resolved against the checkout so a pointer that rots is reported. 20 statements +across 214 schema files are now classified; adding an unclassified one fails CI. + +Detection matches the **statement**, not field names — names would be a guess, +and the liveness README is blunt about where a guessy check ends up ("a +permanently-noisy check is a check nobody reads"). It ignores negated tokens, so +the ⚠ `object.zod.ts` prints to warn that an empty whitelist is DENY-ALL is not +flagged as if it were permissive. Statements that resolve to no property are +narrative and reported as non-failing notes. + +**One behavioural correction.** `DynamicLoadingConfig.allowedSources` — a +supply-chain gate — documented `[]` as admitting every source. It now states the +`apiMethods` three-state: `undefined` = any source, `[]` = **deny-all**, a subset += exactly those types. The empty ARRAY is closed; only ABSENCE is open. Collapsing +the two is what makes an allow-list *vacuous*, where the value an author reaches +by mistake is also the widest grant. + +The field has **no runtime consumer** — the whole `DynamicLoadingConfig` block +(`requireIntegrity`, `defaultSandbox`, `allowedSources`) is declared and +unenforced, the ADR-0049 false-compliance shape, and is not addressed here. That +is exactly why the wording mattered: an unimplemented property's description is +the specification whoever implements it will build to. It now carries an +`[EXPERIMENTAL — not enforced]` marker so authors are not misled meanwhile. + +Also registers the `sharing-rule-criteria-required` dogfood proof added by #3929, +which was declaring a `@proof:` tag the registry did not know about (unbound, for +the same reason as `showcase-bu-hierarchy-sharing`: sharing rules are authored at +stack level, so there is no governed per-type ledger entry to ratchet). + +No runtime behaviour changes. diff --git a/.github/workflows/spec-liveness-check.yml b/.github/workflows/spec-liveness-check.yml index efb23919af..5ca1641b03 100644 --- a/.github/workflows/spec-liveness-check.yml +++ b/.github/workflows/spec-liveness-check.yml @@ -43,3 +43,10 @@ jobs: - name: Check spec liveness run: pnpm --filter @objectstack/spec check:liveness + + # #3896 follow-up. The liveness ledger asks whether a property does anything; + # this asks what its EMPTY value MEANS. A restriction-shaped property whose + # empty state is permissive has to be classified on purpose — omission is the + # commonest authoring error, and it must not also be the widest grant. + - name: Check empty-state semantics + run: pnpm --filter @objectstack/spec check:empty-state diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 006bd5a95f..132be68dc1 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -318,10 +318,67 @@ audit gives divergent sub-statuses; otherwise the top-level entry covers the who } } ``` +## Empty-state semantics — the sibling gate (#3896) + +This ledger asks **"does the property do anything?"** A second, smaller gate asks +the question it does not: **"what does its EMPTY value mean?"** + +`sys_sharing_rule.criteria_json` was live by any standard — parsed, stored, read +by an evaluator. It was also optional, and its absence evaluated to +`find(object, { filter: {} })`: every record of the object, granted to the +recipient. The field description said so out loud — *"leave empty to share every +record"* — and that sentence sat in the spec being read as the contract, because +for an agent-authored platform **a field description is not documentation about +the contract, it is the contract**. + +Three properties, same syntactic shape (an optional list or predicate that +"restricts" something), opposite meanings when empty: + +| Property | Empty means | +|---|---| +| `object.apiMethods` | `undefined` = unrestricted, **`[]` = deny-all** | +| `plugin-runtime.allowedSources` | was *"empty = all allowed"* — corrected | +| sharing `condition` | nothing is shared (#3929) | + +Nothing marked which was which. A maintainer knows by memory; a model authoring +metadata cannot, and guessing wrong is silent and permissive. So the gate scans +the schema surface for statements declaring an empty state to be permissive, and +requires each to be classified in `../scripts/liveness/empty-state-registry.mts`: + +| `semantics` | Meaning | +|---|---| +| `scope` | Selects a range of work (which objects to replicate, which events to replay). Empty = all is correct, often the safe direction. Not an access decision. | +| `closed` | An access gate whose empty state DENIES — the required posture for new gates. Carries no permissive prose to scan, so it is exempt from the staleness check and exists as the catalogue answer. | +| `open` | An access gate default-OPEN on purpose. Legitimate — an object with no API whitelist is exposed, because exposure is the CRUD default — but mandatory rationale, since it is the shape that produced #3896. | +| `output` | Not authorable: a computed projection (an explain trace, a server-resolved effective set). Its empty-state prose describes a result, not a policy. | + +`closed` and `open` must cite where the posture is enforced; the path resolves +like `evidence` above, so a pointer that rots is reported rather than trusted. + +**The lesson `apiMethods` already encodes**, and the one worth copying: the empty +ARRAY is closed, only ABSENCE is open. Collapsing the two produces a *vacuous +allow-list* — where the one value an author reaches by mistake is also the widest +grant. Better still is the shape `storage.zod.ts` uses: an explicit +`mode: 'whitelist'` discriminator plus `.min(1)`, which makes an empty whitelist +unrepresentable. + +```bash +pnpm --filter @objectstack/spec check:empty-state # run the gate +tsx packages/spec/scripts/liveness/check-empty-state.mts --dump # inventory (seeding aid) +``` + +Detection matches the **statement**, not field names. Names would be a guess, and +this README is blunt about where a guessy check ends up: a permanently-noisy +check is a check nobody reads. A statement that resolves to no property is +narrative — a file header explaining a past bug — and is reported as a +non-failing note. + ## Files & usage - `.json` — the ledger for a governed metadata type. - `../scripts/liveness/check-liveness.mts` — the gate (tsx; imports the registry). +- `../scripts/liveness/check-empty-state.mts` — the empty-state gate (above); + `empty-state-registry.mts` is its source of truth. ```bash pnpm --filter @objectstack/spec check:liveness # run the gate diff --git a/packages/spec/package.json b/packages/spec/package.json index 0858946333..01c4507899 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -206,6 +206,7 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "check:liveness": "tsx scripts/liveness/check-liveness.mts", + "check:empty-state": "tsx scripts/liveness/check-empty-state.mts", "gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts", "check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check", "check:react-conformance": "tsx scripts/check-react-blocks-conformance.ts", diff --git a/packages/spec/scripts/liveness/check-empty-state.mts b/packages/spec/scripts/liveness/check-empty-state.mts new file mode 100644 index 0000000000..d6c8996c90 --- /dev/null +++ b/packages/spec/scripts/liveness/check-empty-state.mts @@ -0,0 +1,110 @@ +// #3896 follow-up — the empty-state gate (CLI entry). +// +// pnpm --filter @objectstack/spec check:empty-state +// pnpm --filter @objectstack/spec check:empty-state -- --dump # inventory, seeding aid +// +// See empty-state.mts for what it enforces and why, and +// packages/spec/liveness/README.md § "Empty-state semantics" for the author-facing +// version. + +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { join, resolve, relative, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { EMPTY_STATE_REGISTRY } from './empty-state-registry.mts'; +import { checkEmptyState } from './empty-state.mts'; + +const here = dirname(fileURLToPath(import.meta.url)); +const specRoot = resolve(here, '../..'); // packages/spec +const repoRoot = resolve(specRoot, '../..'); +const srcRoot = join(specRoot, 'src'); + +/** The authorable spec surface: the schema files an author (or a model) reads. */ +function collectSchemaFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + collectSchemaFiles(full, out); + } else if (entry.name.endsWith('.zod.ts') && !entry.name.endsWith('.test.ts')) { + out.push(full); + } + } + return out; +} + +const args = process.argv.slice(2); +const asJson = args.includes('--json'); +const dump = args.includes('--dump'); + +const files = collectSchemaFiles(srcRoot).sort(); +const sources = new Map( + files.map((f) => [relative(repoRoot, f), readFileSync(f, 'utf8')]), +); + +const { hits, findings, notes } = checkEmptyState({ + sources, + registry: EMPTY_STATE_REGISTRY, + exists: (p) => existsSync(join(repoRoot, p)), +}); + +if (dump) { + console.log(`Permissive-empty statements across ${sources.size} schema files:\n`); + for (const h of hits) { + console.log(` ${h.file}:${h.line} [${h.property ?? ''}]`); + console.log(` ${h.text}`); + } + process.exit(0); +} + +if (asJson) { + console.log(JSON.stringify({ scanned: sources.size, hits, findings, notes }, null, 2)); + process.exit(findings.length > 0 ? 1 : 0); +} + +console.log(`Empty-state gate — ${sources.size} schema files, ${hits.length} permissive-empty statements.`); + +const byKind = new Map(); +for (const f of findings) { + const list = byKind.get(f.kind) ?? []; + list.push(f); + byKind.set(f.kind, list); +} + +const HEADINGS: Record = { + unregistered: 'UNCLASSIFIED — a permissive empty state nobody signed off on', + unresolved: 'UNRESOLVED — statement could not be tied to a property', + stale: 'STALE — registered, but the statement is gone', + 'missing-rationale': 'NO RATIONALE', + 'missing-evidence': 'NO EVIDENCE — access gates must cite their enforcement site', + 'rotted-evidence': 'ROTTED EVIDENCE', +}; + +for (const [kind, list] of byKind) { + console.log(`\n${HEADINGS[kind] ?? kind} (${list.length}):`); + for (const f of list) { + const where = f.line ? `${f.file}:${f.line}` : f.file; + console.log(` ✗ ${where}${f.property ? ` — ${f.property}` : ''}`); + console.log(` ${f.message}`); + } +} + +if (notes.length > 0) { + console.log(`\nNotes — narrative, not enforced (${notes.length}):`); + for (const n of notes) console.log(` · ${n.file}:${n.line} — ${n.message}`); +} + +if (findings.length === 0) { + const counts = EMPTY_STATE_REGISTRY.reduce>((acc, e) => { + acc[e.semantics] = (acc[e.semantics] ?? 0) + 1; + return acc; + }, {}); + const summary = Object.entries(counts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${v} ${k}`) + .join(', '); + console.log(`✓ all classified (${summary})`); + process.exit(0); +} + +console.log(`\n${findings.length} finding(s) — see packages/spec/scripts/liveness/empty-state-registry.mts`); +process.exit(1); diff --git a/packages/spec/scripts/liveness/empty-state-registry.mts b/packages/spec/scripts/liveness/empty-state-registry.mts new file mode 100644 index 0000000000..fca529705a --- /dev/null +++ b/packages/spec/scripts/liveness/empty-state-registry.mts @@ -0,0 +1,211 @@ +// #3896 follow-up — the empty-state semantics registry. +// +// WHY THIS EXISTS +// +// `sys_sharing_rule.criteria_json` was optional, and its absence evaluated as +// `find(object, { filter: {} })` — every record of the object, granted to the +// recipient. The field description said so out loud: *"leave empty to share +// every record."* That sentence was not documenting a feature; it was naming a +// bug, and it sat in the spec for anyone — human or model — to read as the +// contract. #3929 closed the three write paths and made the evaluator +// fail-closed. +// +// The instance is fixed. The CLASS is what this registry governs. +// +// Across the spec, the same syntactic shape — an optional list or predicate that +// "restricts" something — carries opposite meanings when it is empty: +// +// object.apiMethods `undefined` = unrestricted, `[]` = deny-all (closed on empty) +// allowedSources "(empty = all allowed)" (open on empty) +// sharing criteria was match-all, now matches nothing (closed on empty) +// +// Nothing in the metadata distinguishes them. A maintainer knows by memory which +// is which; a model authoring metadata cannot, and guessing wrong is silent and +// permissive. So: a permissive empty state may still be declared, but never +// *silently* — it must be classified here, with a reason. +// +// This module is the single source of truth and is deliberately split from the +// scanner so the classification is unit-testable without touching the filesystem +// (same rationale as `proof-registry.mts`). + +/** + * What the empty/absent state of a restriction-shaped property MEANS. + * + * The distinction that matters is not list-vs-predicate, it is **does this + * property gate access**. A scope selector may safely mean "all" when empty; an + * access gate may not, unless someone decided so on purpose and said why. + */ +export type EmptyStateSemantics = + /** + * Selects a range of work — which objects to replicate, which events to + * replay, which types an action applies to. Empty = all is correct and often + * the safe direction. Not an access decision. + */ + | 'scope' + /** + * An access gate whose empty state DENIES. The required posture for any new + * gate: the most likely authoring error (omission) must land on the least + * privilege. + */ + | 'closed' + /** + * An access gate that is default-OPEN on purpose. Legitimate — an object with + * no API whitelist is exposed, because exposure is the CRUD default — but it + * is the shape that produced #3896, so it carries a mandatory rationale and + * must state its full contract (what absent means AND what empty means). + */ + | 'open' + /** + * Not authorable: a computed projection the engine emits (an explain trace, a + * server-resolved effective set). Its empty-state prose describes a RESULT, + * not a policy anyone writes. Registered so the scanner stays quiet without + * pretending these are gates. + */ + | 'output'; + +export interface EmptyStateEntry { + /** Repo-relative path of the schema file (`packages/spec/src/...`). */ + file: string; + /** The property the permissive-empty statement describes. */ + property: string; + /** What the empty state means, and therefore how careless authoring fails. */ + semantics: EmptyStateSemantics; + /** Why this classification is right. Mandatory — the whole point is that it was decided, not defaulted. */ + rationale: string; + /** + * For `closed` / `open`: where the posture actually lives. Repo-rooted paths + * are resolved by the gate (same rules as the liveness ledger's `evidence`), + * so a pointer that rots is reported rather than trusted. + */ + evidence?: string; +} + +/** + * Every permissive-empty declaration in the spec surface, classified. + * + * Adding a new one is intentionally a little annoying: that friction is the + * feature. If a property's empty state means "everything", someone has to write + * down why that is safe. + */ +export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [ + // ---- Access gates ------------------------------------------------------ + + { + file: 'packages/spec/src/data/object.zod.ts', + property: 'apiMethods', + semantics: 'open', + rationale: + 'Deliberate default-open, and the reference example of stating it fully: `undefined` = unrestricted (an object is API-exposed unless it opts into a whitelist — exposure is the CRUD default), `[]` = deny-all, a subset = the derived closure (#3391/#3543). The empty ARRAY is closed; only ABSENCE is open. That two-part contract is what makes it safe to author against, and object.zod.ts additionally emits a ⚠ in its describe text when stripping leaves the whitelist empty.', + evidence: 'packages/spec/src/data/api-derivation.ts', + }, + { + file: 'packages/spec/src/kernel/plugin-runtime.zod.ts', + property: 'allowedSources', + semantics: 'open', + rationale: + 'A source allow-list for dynamically loaded plugins — a supply-chain gate. Corrected to the `apiMethods` three-state: absence stays open (no policy declared, matching how every other config default behaves and how the runtime behaves today), but `[]` now DENIES instead of admitting everything. The vacuous allow-list was the whole defect: emptiness was both the likeliest authoring slip and the widest grant. Registered `open` rather than `closed` because absence really is permissive here — evading the scanner with softer wording would be the same silence this gate exists to break. NOTE it has no runtime consumer at all: the whole DynamicLoadingConfig block (with `requireIntegrity`, `defaultSandbox`) is declared-but-unenforced, ADR-0049 false compliance, tracked separately — an unimplemented gate is read as the specification by whoever implements it.', + evidence: 'packages/spec/src/kernel/plugin-runtime.zod.ts', + }, + { + file: 'packages/spec/src/security/sharing.zod.ts', + property: 'condition', + semantics: 'closed', + rationale: + "The #3896 outcome, recorded so the answer is findable: a sharing rule's criteria is REQUIRED, and a rule that reaches storage without one grants nothing. There is no 'share every record' rule — object-wide read is the object's organization-wide default (`sharingModel`); the match-all shape only ever existed as a failure mode. Carries no permissive statement to scan (that is what being closed means), so it is exempt from the staleness check and exists purely as the catalogue answer to 'what does an empty criteria do?'.", + evidence: 'packages/spec/src/security/sharing.zod.ts', + }, + + // ---- Scope selectors --------------------------------------------------- + + { + file: 'packages/spec/src/system/disaster-recovery.zod.ts', + property: 'includeObjects', + semantics: 'scope', + rationale: + 'Which objects a DR replica covers. Empty = all is both correct and the safe direction: an under-specified DR config replicates more than needed, never less.', + }, + { + file: 'packages/spec/src/kernel/events/queue.zod.ts', + property: 'eventTypes', + semantics: 'scope', + rationale: 'Replay filter for a dead-letter/replay operation. Selects work to redo; grants nothing.', + }, + { + file: 'packages/spec/src/kernel/events/queue.zod.ts', + property: 'targetHandlers', + semantics: 'scope', + rationale: 'Replay filter for a dead-letter/replay operation. Selects work to redo; grants nothing.', + }, + { + file: 'packages/spec/src/api/rest-server.zod.ts', + property: 'includeObjects', + semantics: 'scope', + rationale: + 'Which objects get generated REST routes. Route existence is not authorization — the generated routes still run the full permission stack, so a wider set is not a wider grant.', + }, + { + file: 'packages/spec/src/data/object.zod.ts', + property: 'stageField', + semantics: 'scope', + rationale: + '[ADR-0085] Absence permits stage heuristics to infer the lifecycle field; `false` suppresses them. It selects how the renderer guesses, not who may read anything.', + }, + { + file: 'packages/spec/src/ui/page.zod.ts', + property: 'columns', + semantics: 'scope', + rationale: + 'Blank shows every object field on the page. Presentation breadth, not authorization: field-level security is enforced downstream, so a page that lists all fields still renders only the ones the viewer may read. (It is the "empty = everything" shape though — if FLS ever stops being the gate here, this becomes a `closed` question, not a `scope` one.)', + }, + { + file: 'packages/spec/src/studio/plugin.zod.ts', + property: 'metadataTypes', + semantics: 'scope', + rationale: 'Which metadata types a Studio action offers itself for. An authoring-surface affordance, not an access decision.', + }, + { + file: 'packages/spec/src/automation/flow.zod.ts', + property: 'errorCode', + semantics: 'scope', + rationale: + 'Which error a catch node handles; empty catches all of them. Catching broadly is the safe direction for an error handler — the failure mode of the opposite default is an unhandled fault, not an over-grant.', + }, + { + file: 'packages/spec/src/ai/knowledge-source.zod.ts', + property: 'mimeTypes', + semantics: 'scope', + rationale: + 'Which files under a storage prefix get indexed into a knowledge source. Ingestion scope, not read authorization — retrieval is gated separately. NOTE the shape though: it is a vacuous-allow-list ("allow-list" whose empty value admits everything), the same shape as #3896. `storage.zod.ts` models the identical concept the better way — an explicit `mode: "whitelist"` discriminator plus `.min(1)`, so an empty whitelist cannot be expressed at all. Prefer that form for new allow-lists; changing this one is a breaking change to an authorable field and is not in scope here.', + }, + + // ---- Engine output, not authorable ------------------------------------- + + { + file: 'packages/spec/src/security/explain.zod.ts', + property: 'predicate', + semantics: 'output', + rationale: + 'The row predicate a rule contributed, as reported by the explain engine. `null` = unrestricted describes what the engine COMPUTED, not a policy an author writes — and the same schema spells out the opposite pole (`__deny_all__` = zero rows), so the trace is unambiguous to read.', + }, + { + file: 'packages/spec/src/security/explain.zod.ts', + property: 'rowFilter', + semantics: 'output', + rationale: + 'The effective row predicate a layer contributed. Same as `predicate`: a computed diagnostic projection, paired with an explicit `__deny_all__` sentinel for the closed pole.', + }, + { + file: 'packages/spec/src/security/explain.zod.ts', + property: 'readFilter', + semantics: 'output', + rationale: + 'The machine artifact behind the human-readable explain prose. Same computed-projection reasoning as `predicate` / `rowFilter`.', + }, + { + file: 'packages/spec/src/security/permission.zod.ts', + property: 'apiOperations', + semantics: 'output', + rationale: + 'The server-resolved EFFECTIVE operation set, present only when the object tightens exposure via `apiMethods`. Absent = default-allow mirrors the authorable `apiMethods` contract it is derived from; the frontend renders this set and never the raw whitelist.', + }, +]; diff --git a/packages/spec/scripts/liveness/empty-state.mts b/packages/spec/scripts/liveness/empty-state.mts new file mode 100644 index 0000000000..7e15d736e3 --- /dev/null +++ b/packages/spec/scripts/liveness/empty-state.mts @@ -0,0 +1,313 @@ +// #3896 follow-up — the empty-state gate. +// +// Scans the authorable spec surface (`packages/spec/src/**/*.zod.ts`) for +// statements that declare a property's empty/absent state to be PERMISSIVE +// ("empty = all", "absent = default-allow", "leave empty to …"), and requires +// every one of them to be classified in `empty-state-registry.mts`. +// +// Why prose and not a name heuristic: the hazard is not a naming convention, it +// is the SENTENCE. `sys_sharing_rule.criteria_json` said "leave empty to share +// every record" — and for a platform whose premise is that agents author +// metadata, a field description is not documentation *about* the contract, it IS +// the contract the next author reads. Matching the dangerous statement is exact; +// matching field names would be a guess, and the liveness ledger's README is +// blunt about where that ends up: "a permanently-noisy check is a check nobody +// reads." +// +// Pure by construction — every entry point takes its inputs, so the unit tests +// never touch the filesystem. + +import type { EmptyStateEntry } from './empty-state-registry.mts'; + +/** A permissive-empty statement found in the spec surface. */ +export interface PermissiveEmptyHit { + /** Repo-relative file path. */ + file: string; + /** 1-indexed line of the statement. */ + line: number; + /** The matched line, trimmed — quoted back in findings so the author sees what tripped. */ + text: string; + /** The property the statement describes, or null when it could not be resolved. */ + property: string | null; +} + +export type FindingKind = + | 'unregistered' + | 'unresolved' + | 'stale' + | 'missing-rationale' + | 'missing-evidence' + | 'rotted-evidence'; + +export interface EmptyStateFinding { + kind: FindingKind; + file: string; + line?: number; + property?: string; + message: string; +} + +// ---- Detection ---------------------------------------------------------- + +// "empty-ish" — the states an author reaches by NOT writing something. +const EMPTY_TOKENS = ['empty', 'absent', 'unset', 'omitted', 'undefined', 'null', 'missing', 'blank']; + +// "permissive-ish" — the meanings that make omission dangerous. +const PERMISSIVE_TOKENS = [ + 'all', + 'any', + 'every', + 'everything', + 'everyone', + 'unrestricted', + 'allowed', + 'default-allow', + 'default-open', + 'match-all', + 'no restriction', + 'no limit', +]; + +// ` <=|means|→> [up to 3 filler words] `. +// +// The filler window catches "empty = catch all errors" without opening the +// pattern up to whole sentences. Deliberately NOT anchored on `.describe(` — +// a JSDoc line above the property is just as load-bearing as the describe text. +// +// `:` is deliberately NOT a separator. It reads as one in prose ("absent: all"), +// but in a schema file every property declaration is `name:` and every object +// literal is `key:`, so admitting it turned ordinary code into matches. +const PERMISSIVE_EMPTY_RE = new RegExp( + String.raw`\b(${EMPTY_TOKENS.join('|')})\b[^.;!?\n]{0,24}?` + + String.raw`(?:=|==|→|->|⇒|\bmeans\b|\bimplies\b)\s*` + + String.raw`(?:\W*\b\w+\b){0,3}?\W*\b(${PERMISSIVE_TOKENS.join('|')})\b`, + 'i', +); + +// The imperative form, which names no state at all: "leave empty to share every +// record" — the exact sentence #3896 shipped. +// +// It must carry a permissive CONSEQUENCE. A bare "leave it unset" is an ordinary +// deprecation instruction (`metadata-persistence.environmentId`), not a claim +// about what emptiness grants, and matching it produced pure noise. +const LEAVE_EMPTY_RE = new RegExp( + String.raw`\bleave\s+(?:it\s+|them\s+|this\s+)?(?:empty|blank|unset)\b` + + String.raw`[^.;!?\n]{0,24}?\bto\b[^.;!?\n]{0,32}?\b(?:${PERMISSIVE_TOKENS.join('|')})\b`, + 'i', +); + +// `all` is the workhorse permissive token and also the tail of `deny-all` — so +// the ⚠ that object.zod.ts prints precisely to warn that an empty whitelist is +// DENY-ALL matched as if it were permissive. A negated match is the OPPOSITE of +// the hazard; dropping it is not a tolerance, it is a correction. +const NEGATED_BEFORE_RE = /(?:deny|denies|no|not|never|zero|none)[-\s]*$/i; + +// A match only counts as PROSE. `const empty = {}` is code and must not trip the +// gate, so the match has to sit after a comment marker or inside a string. +const PROSE_PREFIX_RE = /(\/\/|\/\*|\*|['"`])/; + +/** True when the match at `index` is inside a comment or a string literal, not bare code. */ +function isProse(line: string, index: number): boolean { + return PROSE_PREFIX_RE.test(line.slice(0, index)); +} + +const PROP_RE = /^\s*([A-Za-z_$][\w$]*)\s*:/; +const DOC_LINE_RE = /^\s*(\/\*|\*|\/\/)/; + +/** How far from the statement to look for the property it describes. */ +const MAX_PROPERTY_DISTANCE = 8; + +/** + * Resolve which property a statement belongs to. + * + * Direction matters and gets this wrong if you pick one: a JSDoc block sits + * ABOVE its property, while a `.describe(...)` argument sits BELOW it. Searching + * forward only mis-attributed `explain.zod.ts`'s effective-predicate describe to + * the *next* property down the file. + */ +export function resolveProperty(lines: string[], matchLine: number): string | null { + const own = lines[matchLine]?.match(PROP_RE); + if (own) return own[1]; + + const isDoc = DOC_LINE_RE.test(lines[matchLine] ?? ''); + const forward = () => { + for (let i = matchLine + 1; i <= matchLine + MAX_PROPERTY_DISTANCE && i < lines.length; i++) { + const m = lines[i]?.match(PROP_RE); + if (m) return m[1]; + } + return null; + }; + const backward = () => { + for (let i = matchLine - 1; i >= matchLine - MAX_PROPERTY_DISTANCE && i >= 0; i--) { + const m = lines[i]?.match(PROP_RE); + if (m) return m[1]; + } + return null; + }; + + // Doc comment → its property is below. Anything else (a describe argument, a + // trailing comment) → above. + return isDoc ? (forward() ?? backward()) : (backward() ?? forward()); +} + +/** True when the permissive token this match hinged on is negated ("deny-all", "no limit"). */ +function isNegated(line: string, match: RegExpExecArray): boolean { + const token = match[2]; + if (!token) return false; + const tokenAt = match[0].lastIndexOf(token); + if (tokenAt < 0) return false; + return NEGATED_BEFORE_RE.test(line.slice(0, match.index + tokenAt)); +} + +/** Find every permissive-empty statement in one file. */ +export function scanSource(file: string, source: string): PermissiveEmptyHit[] { + const lines = source.split('\n'); + const hits: PermissiveEmptyHit[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ''; + const m = PERMISSIVE_EMPTY_RE.exec(line) ?? LEAVE_EMPTY_RE.exec(line); + if (!m || m.index === undefined) continue; + if (!isProse(line, m.index)) continue; + if (isNegated(line, m)) continue; + + hits.push({ + file, + line: i + 1, + text: line.trim(), + property: resolveProperty(lines, i), + }); + } + + return hits; +} + +// ---- Checking ----------------------------------------------------------- + +export interface CheckInput { + /** The scanned surface: repo-relative path → file contents. */ + sources: Map; + registry: EmptyStateEntry[]; + /** Resolves a repo-rooted evidence path against the checkout. */ + exists: (repoRelativePath: string) => boolean; +} + +export interface CheckResult { + hits: PermissiveEmptyHit[]; + /** Failing. */ + findings: EmptyStateFinding[]; + /** + * Non-failing. A statement that resolves to no property is narrative — a file + * header explaining a past bug, say — not a claim about any field's contract, + * so it is surfaced rather than enforced. The hazard this gate exists for is a + * FIELD whose description misstates what its empty value grants; failing CI on + * prose would be the "permanently-noisy check nobody reads" the liveness README + * warns about. + */ + notes: EmptyStateFinding[]; +} + +const key = (file: string, property: string) => `${file}#${property}`; + +/** + * Enforce the registry against the scanned surface, in both directions. + * + * Forward (the ratchet): a permissive-empty statement with no entry fails — you + * cannot add undeclared permissive surface. + * + * Reverse (rot): an entry whose statement no longer exists also fails. The + * liveness ledger learned this the expensive way — "a ledger entry is a claim + * with a timestamp; code moves under it in both directions" — and a stale + * classification is worse than no classification, because it reads as reviewed. + */ +export function checkEmptyState({ sources, registry, exists }: CheckInput): CheckResult { + const hits: PermissiveEmptyHit[] = []; + for (const [file, source] of sources) hits.push(...scanSource(file, source)); + + const findings: EmptyStateFinding[] = []; + const notes: EmptyStateFinding[] = []; + const registryByKey = new Map(registry.map((e) => [key(e.file, e.property), e])); + const matchedKeys = new Set(); + + for (const hit of hits) { + if (!hit.property) { + notes.push({ + kind: 'unresolved', + file: hit.file, + line: hit.line, + message: + `Narrative, not a property contract: "${hit.text}". ` + + `If it does describe a property, move it within ${MAX_PROPERTY_DISTANCE} lines of the declaration so the gate can classify it.`, + }); + continue; + } + + const k = key(hit.file, hit.property); + matchedKeys.add(k); + if (registryByKey.has(k)) continue; + + findings.push({ + kind: 'unregistered', + file: hit.file, + line: hit.line, + property: hit.property, + message: + `\`${hit.property}\` declares a permissive empty state ("${hit.text}") but is not classified.\n` + + ` Add an entry to empty-state-registry.mts. If it gates ACCESS, the empty state must DENY\n` + + ` (semantics: 'closed') — omission is the most common authoring error and it must not land\n` + + ` on the widest grant. If it only selects a range of work, 'scope' is fine — say why.`, + }); + } + + for (const entry of registry) { + const k = key(entry.file, entry.property); + + // A `closed` gate states that its empty value DENIES, and the scanner only + // matches permissive statements — so a correct `closed` entry is invisible + // to it, permanently. Staleness cannot apply: these entries exist to record + // "this is a gate and its empty state is safe", which is precisely the + // question an author (or a model) authoring a rule needs answered, and it is + // answerable nowhere else in the spec. + if (entry.semantics !== 'closed' && !matchedKeys.has(k)) { + findings.push({ + kind: 'stale', + file: entry.file, + property: entry.property, + message: + `Registered as '${entry.semantics}', but no permissive-empty statement was found for \`${entry.property}\`. ` + + `If the wording was fixed, drop this entry; if the property moved, update \`file\`.`, + }); + } + + if (!entry.rationale?.trim()) { + findings.push({ + kind: 'missing-rationale', + file: entry.file, + property: entry.property, + message: `Entry has no rationale. The classification has to be a decision someone wrote down, not a default.`, + }); + } + + // Access gates must point at where their posture actually lives; scope + // selectors and engine output have no enforcement site to cite. + if (entry.semantics === 'closed' || entry.semantics === 'open') { + if (!entry.evidence?.trim()) { + findings.push({ + kind: 'missing-evidence', + file: entry.file, + property: entry.property, + message: `'${entry.semantics}' is an access-gate classification and must cite where the posture is enforced.`, + }); + } else if (!exists(entry.evidence)) { + findings.push({ + kind: 'rotted-evidence', + file: entry.file, + property: entry.property, + message: `Evidence path does not resolve: ${entry.evidence}`, + }); + } + } + } + + return { hits, findings, notes }; +} diff --git a/packages/spec/scripts/liveness/empty-state.test.ts b/packages/spec/scripts/liveness/empty-state.test.ts new file mode 100644 index 0000000000..1ab0a7a7b8 --- /dev/null +++ b/packages/spec/scripts/liveness/empty-state.test.ts @@ -0,0 +1,222 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Unit tests for the empty-state gate (#3896 follow-up). +// +// Two things are worth failing over here. The gate must catch the sentence that +// shipped the original bug — and it must stay quiet on the many innocent lines +// that look like it, because a check that cries wolf is a check that gets +// skipped, which is how the sentence shipped in the first place. + +import { describe, it, expect } from 'vitest'; + +import { scanSource, resolveProperty, checkEmptyState } from './empty-state.mts'; +import { EMPTY_STATE_REGISTRY } from './empty-state-registry.mts'; +import type { EmptyStateEntry } from './empty-state-registry.mts'; + +const all = () => true; + +/** Build a one-file source map for `checkEmptyState`. */ +const sourcesOf = (file: string, source: string) => new Map([[file, source]]); + +describe('scanSource — statements it must catch', () => { + it('catches the sentence #3896 shipped', () => { + const hits = scanSource( + 'f.zod.ts', + [ + ' /** Criteria for the rule. Leave empty to share every record. */', + ' criteria: z.record(z.string(), z.unknown()).optional(),', + ].join('\n'), + ); + expect(hits).toHaveLength(1); + expect(hits[0]?.property).toBe('criteria'); + }); + + it('catches `empty = all` inside a describe argument', () => { + const hits = scanSource( + 'f.zod.ts', + [ + ' includeObjects: z.array(z.string()).optional()', + " .describe('Objects to replicate (empty = all)'),", + ].join('\n'), + ); + expect(hits).toHaveLength(1); + expect(hits[0]?.property).toBe('includeObjects'); + }); + + it('catches `absent = default-allow`', () => { + const hits = scanSource('f.zod.ts', " apiOperations: z.array(x).optional().describe('absent = default-allow'),"); + expect(hits.map((h) => h.property)).toEqual(['apiOperations']); + }); + + it('catches a permissive empty state in a JSDoc line above the property', () => { + const hits = scanSource( + 'f.zod.ts', + [' /**', ' * Allowed plugin sources (empty = all allowed)', ' */', ' allowedSources: z.array(x).optional(),'].join('\n'), + ); + expect(hits.map((h) => h.property)).toEqual(['allowedSources']); + }); + + it('catches filler words between the separator and the permissive token', () => { + const hits = scanSource('f.zod.ts', " errorCode: z.string().optional().describe('(empty = catch all errors)'),"); + expect(hits).toHaveLength(1); + }); +}); + +describe('scanSource — lines it must stay quiet on', () => { + it('ignores code that merely assigns something named `empty`', () => { + expect(scanSource('f.zod.ts', ' const empty = { all: true };')).toEqual([]); + }); + + it('ignores a NEGATED permissive token — `deny-all` is the opposite of the hazard', () => { + // object.zod.ts prints this precisely to warn that an empty whitelist closes + // the object. Flagging it would invert the gate's meaning. + const hits = scanSource('f.zod.ts', ' // After stripping, this whitelist is EMPTY — `[]` means DENY-ALL (fully closed)'); + expect(hits).toEqual([]); + }); + + it('ignores a bare deprecation instruction with no permissive consequence', () => { + const hits = scanSource('f.zod.ts', " environmentId: z.string().optional().describe('Deprecated. New writes leave unset.'),"); + expect(hits).toEqual([]); + }); + + it('does not treat `:` as a separator — every schema line has one', () => { + const hits = scanSource('f.zod.ts', ' * guards pin those keys to `undefined`: a body carrying any of them is a record'); + expect(hits).toEqual([]); + }); + + it('ignores "no grants" — an empty state that is already restrictive', () => { + expect(scanSource('f.zod.ts', ' // a per-object grant map; empty = no grants yet.')).toEqual([]); + }); +}); + +describe('resolveProperty — direction matters', () => { + const lines = [ + ' /** Doc above its property. */', // 0 + ' docProp: z.string(),', // 1 + '', // 2 + ' describedProp: z.string()', // 3 + " .describe('trailing describe below its property'),", // 4 + ]; + + it('resolves a doc comment FORWARD to the property beneath it', () => { + expect(resolveProperty(lines, 0)).toBe('docProp'); + }); + + it('resolves a describe argument BACKWARD to the property above it', () => { + // Searching forward here would attribute the statement to the NEXT property + // down the file — the mis-attribution that showed up on explain.zod.ts. + expect(resolveProperty(lines, 4)).toBe('describedProp'); + }); + + it('prefers the property on the matched line itself', () => { + expect(resolveProperty([" inline: z.string().describe('empty = all'),"], 0)).toBe('inline'); + }); +}); + +describe('checkEmptyState — the ratchet', () => { + const file = 'packages/spec/src/x.zod.ts'; + const permissive = [" thing: z.array(z.string()).optional()", " .describe('Scope (empty = all)'),"].join('\n'); + + const entry = (over: Partial = {}): EmptyStateEntry => ({ + file, + property: 'thing', + semantics: 'scope', + rationale: 'selects work, grants nothing', + ...over, + }); + + it('FAILS on a permissive empty state nobody classified', () => { + const { findings } = checkEmptyState({ sources: sourcesOf(file, permissive), registry: [], exists: all }); + expect(findings.map((f) => f.kind)).toEqual(['unregistered']); + expect(findings[0]?.property).toBe('thing'); + }); + + it('passes once it is classified', () => { + const { findings } = checkEmptyState({ sources: sourcesOf(file, permissive), registry: [entry()], exists: all }); + expect(findings).toEqual([]); + }); + + it('FAILS on a stale entry whose statement is gone', () => { + const { findings } = checkEmptyState({ + sources: sourcesOf(file, ' thing: z.array(z.string()).optional(),'), + registry: [entry()], + exists: all, + }); + expect(findings.map((f) => f.kind)).toEqual(['stale']); + }); + + it('exempts `closed` entries from staleness — a closed gate has no permissive prose to find', () => { + const { findings } = checkEmptyState({ + sources: sourcesOf(file, ' thing: z.array(z.string()),'), + registry: [entry({ semantics: 'closed', evidence: 'packages/spec/src/x.zod.ts' })], + exists: all, + }); + expect(findings).toEqual([]); + }); + + it('FAILS an access-gate classification with no evidence', () => { + const { findings } = checkEmptyState({ + sources: sourcesOf(file, permissive), + registry: [entry({ semantics: 'open' })], + exists: all, + }); + expect(findings.map((f) => f.kind)).toEqual(['missing-evidence']); + }); + + it('FAILS on evidence that no longer resolves', () => { + const { findings } = checkEmptyState({ + sources: sourcesOf(file, permissive), + registry: [entry({ semantics: 'open', evidence: 'packages/spec/src/gone.ts' })], + exists: () => false, + }); + expect(findings.map((f) => f.kind)).toEqual(['rotted-evidence']); + }); + + it('FAILS an entry with no rationale — the classification must be a decision', () => { + const { findings } = checkEmptyState({ + sources: sourcesOf(file, permissive), + registry: [entry({ rationale: ' ' })], + exists: all, + }); + expect(findings.map((f) => f.kind)).toContain('missing-rationale'); + }); + + it('does not require evidence of `scope` / `output` — they have no enforcement site', () => { + const { findings } = checkEmptyState({ + sources: sourcesOf(file, permissive), + registry: [entry({ semantics: 'output' })], + exists: () => false, + }); + expect(findings).toEqual([]); + }); + + it('reports narrative prose as a NOTE, not a failure', () => { + const narrative = ['/**', ' * A past bug: a missing criteria became the empty filter → every record.', ' */', 'export const X = 1;'].join('\n'); + const { findings, notes } = checkEmptyState({ sources: sourcesOf(file, narrative), registry: [], exists: all }); + expect(findings).toEqual([]); + expect(notes.map((n) => n.kind)).toEqual(['unresolved']); + }); +}); + +describe('the shipped registry', () => { + it('gives every entry a rationale', () => { + const bare = EMPTY_STATE_REGISTRY.filter((e) => !e.rationale?.trim()); + expect(bare).toEqual([]); + }); + + it('gives every access-gate entry an evidence pointer', () => { + const gates = EMPTY_STATE_REGISTRY.filter((e) => e.semantics === 'closed' || e.semantics === 'open'); + expect(gates.length).toBeGreaterThan(0); + expect(gates.filter((e) => !e.evidence?.trim())).toEqual([]); + }); + + it('has no duplicate file#property keys', () => { + const keys = EMPTY_STATE_REGISTRY.map((e) => `${e.file}#${e.property}`); + expect(keys).toEqual([...new Set(keys)]); + }); + + it('records the #3896 gate itself, so "what does an empty criteria do?" is answerable', () => { + const criteria = EMPTY_STATE_REGISTRY.find((e) => e.property === 'condition' && e.file.endsWith('sharing.zod.ts')); + expect(criteria?.semantics).toBe('closed'); + }); +}); diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index 18d8dd6c9d..c1e564d9c5 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -406,6 +406,28 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ + 'type — the ledger governs per-type property surfaces, and there is no `permission.*` entry ' + 'for the rule\'s recipient kind.', }, + { + id: 'sharing-rule-criteria-required', + label: 'Sharing rule — criteria is required', + summary: + 'a sharing rule with no criteria must share NOTHING. `criteria_json: null` used to evaluate as ' + + '`find(object, { filter: {} })` under the system context — every record of the object, granted ' + + 'to the recipient — reachable by a typo through three write paths that never parsed the schema ' + + '(#3896). The proof POSTs the reported body against a booted stack and asserts no ' + + '`sys_record_share` row appears, and covers the already-stored legacy row whose grants the next ' + + 'reconcile REVOKES rather than leaving materialised.', + proofId: 'sharing-rule-criteria-required', + proofRef: + 'packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts#sharing-rule-criteria-required', + bound: false, + ledgerBindings: [], + blockedReason: + 'same shape as `showcase-bu-hierarchy-sharing`: the criteria is authored at STACK level ' + + '(`sharingRules[].condition`), not as a property of a governed metadata type, so there is no ' + + 'ledger entry to ratchet. Registered so the tag is not an orphan; it runs unconditionally in ' + + 'the dogfood suite. The invariant itself is recorded in the empty-state registry ' + + '(sharing `condition` → `closed`), which is the surface that CAN carry it.', + }, { id: 'declarative-rbac-seeding', label: 'Declarative RBAC seeding', diff --git a/packages/spec/src/kernel/plugin-runtime.zod.ts b/packages/spec/src/kernel/plugin-runtime.zod.ts index 9b22090fea..3b0a3a170f 100644 --- a/packages/spec/src/kernel/plugin-runtime.zod.ts +++ b/packages/spec/src/kernel/plugin-runtime.zod.ts @@ -327,10 +327,21 @@ export const DynamicLoadingConfigSchema = lazySchema(() => z.object({ .describe('Sandbox dynamically loaded plugins by default'), /** - * Allowed plugin sources (empty = all allowed) + * Allowed plugin sources — three-state, the same contract as + * `object.apiMethods` (#3391/#3543): `undefined` = no source policy declared, + * `[]` = deny-all, a subset = exactly those source types. + * + * The empty ARRAY is closed; only ABSENCE is open. The previous wording + * collapsed the two, making this a vacuous allow-list — one where the value an + * author reaches by mistake is also the widest grant. That is the shape #3896 + * turned into an over-share, and this is a supply-chain gate. + * + * Nothing enforces this field yet (hence the marker), which is precisely why + * the wording mattered: an unimplemented property's description is the + * specification whoever implements it builds to. */ allowedSources: z.array(z.enum(['npm', 'local', 'url', 'registry', 'git'])).optional() - .describe('Restrict which source types are permitted'), + .describe('[EXPERIMENTAL — not enforced] Restrict which plugin source types are permitted: undefined = any source, [] = deny-all, a subset = exactly those types'), /** * Require integrity verification for remote plugins From 802c004a3f8af5359827be5f572cf7b5411cd792 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 12:01:11 +0000 Subject: [PATCH 2/2] docs(spec): regenerate the plugin-runtime reference for the allowedSources contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `check:docs` step of the "TypeScript Type Check" job regenerates content/docs/references/ from the spec and diffs it. Correcting the `allowedSources` describe text left the generated page stale. The regenerated line is the point of the change, not a side effect: the reference table now states the three-state contract — undefined = any source, [] = deny-all, a subset = exactly those types — where an author reads it, instead of "Restrict which source types are permitted", which said nothing about what emptiness does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX --- content/docs/references/kernel/plugin-runtime.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/kernel/plugin-runtime.mdx b/content/docs/references/kernel/plugin-runtime.mdx index b2b9046f10..46b5370f22 100644 --- a/content/docs/references/kernel/plugin-runtime.mdx +++ b/content/docs/references/kernel/plugin-runtime.mdx @@ -78,7 +78,7 @@ Dynamic plugin loading subsystem configuration | **maxDynamicPlugins** | `integer` | ✅ | Upper limit on runtime-loaded plugins | | **discovery** | `{ enabled: boolean; sources: { type: Enum<'registry' \| 'npm' \| 'directory' \| 'url'>; endpoint: string; pollInterval: integer; filter?: object }[]; autoLoad: boolean; requireApproval: boolean }` | optional | Runtime plugin discovery configuration | | **defaultSandbox** | `boolean` | ✅ | Sandbox dynamically loaded plugins by default | -| **allowedSources** | `Enum<'npm' \| 'local' \| 'url' \| 'registry' \| 'git'>[]` | optional | Restrict which source types are permitted | +| **allowedSources** | `Enum<'npm' \| 'local' \| 'url' \| 'registry' \| 'git'>[]` | optional | [EXPERIMENTAL — not enforced] Restrict which plugin source types are permitted: undefined = any source, [] = deny-all, a subset = exactly those types | | **requireIntegrity** | `boolean` | ✅ | Require integrity hash verification for remote sources | | **operationTimeout** | `integer` | ✅ | Default timeout for load/unload operations in ms |