Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .changeset/empty-state-semantics-gate.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .github/workflows/spec-liveness-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion content/docs/references/kernel/plugin-runtime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
57 changes: 57 additions & 0 deletions packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

- `<type>.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
Expand Down
1 change: 1 addition & 0 deletions packages/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
110 changes: 110 additions & 0 deletions packages/spec/scripts/liveness/check-empty-state.mts
Original file line number Diff line number Diff line change
@@ -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<string, string>(
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 ?? '<unresolved>'}]`);
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<string, typeof findings>();
for (const f of findings) {
const list = byKind.get(f.kind) ?? [];
list.push(f);
byKind.set(f.kind, list);
}

const HEADINGS: Record<string, string> = {
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<Record<string, number>>((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);
Loading
Loading