Skip to content

Commit 1d22114

Browse files
authored
chore(spec): classify what a restriction-shaped property's EMPTY value means (#3945)
Follow-up to #3929 / #3896. The liveness ledger asks whether a property does anything; nothing asked what its EMPTY value means. `criteria_json` was live at every layer and still granted every record when omitted, because the spec said "leave empty to share every record" — and for an agent-authored platform a field description is not documentation about the contract, it is the contract. Sweeping the surface found the same syntactic shape carrying opposite meanings when empty: `object.apiMethods` closes on `[]`, `allowedSources` admitted everything, sharing `condition` shares nothing. A maintainer knows which is which by memory; a model cannot. New `check:empty-state` gate, wired into the Spec Liveness Check workflow: every statement declaring an empty state permissive must be classified `scope`, `closed`, `open` or `output`, with access gates citing where the posture is enforced. 20 statements across 214 schema files classified; a new unclassified one fails CI. Detection matches the statement rather than field names, ignores negated tokens, and reports unattributable prose as a non-failing note. Corrects `DynamicLoadingConfig.allowedSources` to the apiMethods three-state — the empty ARRAY is closed, only ABSENCE is open — and registers the sharing-rule-criteria-required dogfood proof #3929 left orphaned. No runtime behaviour changes.
1 parent 9703cb5 commit 1d22114

11 files changed

Lines changed: 1024 additions & 3 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
chore(spec): classify what a restriction-shaped property's EMPTY value means (#3896 follow-up)
6+
7+
#3929 fixed one field. `sys_sharing_rule.criteria_json` was optional, and its
8+
absence evaluated to `find(object, { filter: {} })` — every record of the object,
9+
granted to the recipient. The field description said so out loud: *"leave empty
10+
to share every record."*
11+
12+
That sentence is the part worth generalising. For a platform whose premise is
13+
that agents author metadata, **a field description is not documentation about the
14+
contract, it is the contract the next author reads** — and omission is the
15+
commonest authoring error a model makes, precisely because it produces no error.
16+
When omission also lands on the widest grant, the likeliest mistake is the most
17+
dangerous outcome, silently.
18+
19+
Sweeping the spec surface found the same syntactic shape — an optional list or
20+
predicate that "restricts" something — carrying opposite meanings when empty:
21+
22+
| Property | Empty means |
23+
|---|---|
24+
| `object.apiMethods` | `undefined` = unrestricted, **`[]` = deny-all** |
25+
| `plugin-runtime.allowedSources` | *"empty = all allowed"* |
26+
| sharing `condition` | nothing is shared (#3929) |
27+
28+
Nothing marked which was which. A maintainer knows by memory; a model cannot.
29+
30+
**New gate — `pnpm --filter @objectstack/spec check:empty-state`**, wired into the
31+
existing Spec Liveness Check workflow. It scans `packages/spec/src/**/*.zod.ts`
32+
for statements declaring an empty state to be permissive and requires each to be
33+
classified in `scripts/liveness/empty-state-registry.mts` as `scope` (selects a
34+
range of work — empty = all is fine), `closed` (an access gate whose empty state
35+
denies — the required posture for new gates), `open` (default-open on purpose,
36+
mandatory rationale), or `output` (a computed projection, not authorable).
37+
`closed` / `open` must cite where the posture is enforced, and the path is
38+
resolved against the checkout so a pointer that rots is reported. 20 statements
39+
across 214 schema files are now classified; adding an unclassified one fails CI.
40+
41+
Detection matches the **statement**, not field names — names would be a guess,
42+
and the liveness README is blunt about where a guessy check ends up ("a
43+
permanently-noisy check is a check nobody reads"). It ignores negated tokens, so
44+
the ⚠ `object.zod.ts` prints to warn that an empty whitelist is DENY-ALL is not
45+
flagged as if it were permissive. Statements that resolve to no property are
46+
narrative and reported as non-failing notes.
47+
48+
**One behavioural correction.** `DynamicLoadingConfig.allowedSources` — a
49+
supply-chain gate — documented `[]` as admitting every source. It now states the
50+
`apiMethods` three-state: `undefined` = any source, `[]` = **deny-all**, a subset
51+
= exactly those types. The empty ARRAY is closed; only ABSENCE is open. Collapsing
52+
the two is what makes an allow-list *vacuous*, where the value an author reaches
53+
by mistake is also the widest grant.
54+
55+
The field has **no runtime consumer** — the whole `DynamicLoadingConfig` block
56+
(`requireIntegrity`, `defaultSandbox`, `allowedSources`) is declared and
57+
unenforced, the ADR-0049 false-compliance shape, and is not addressed here. That
58+
is exactly why the wording mattered: an unimplemented property's description is
59+
the specification whoever implements it will build to. It now carries an
60+
`[EXPERIMENTAL — not enforced]` marker so authors are not misled meanwhile.
61+
62+
Also registers the `sharing-rule-criteria-required` dogfood proof added by #3929,
63+
which was declaring a `@proof:` tag the registry did not know about (unbound, for
64+
the same reason as `showcase-bu-hierarchy-sharing`: sharing rules are authored at
65+
stack level, so there is no governed per-type ledger entry to ratchet).
66+
67+
No runtime behaviour changes.

.github/workflows/spec-liveness-check.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,10 @@ jobs:
4343

4444
- name: Check spec liveness
4545
run: pnpm --filter @objectstack/spec check:liveness
46+
47+
# #3896 follow-up. The liveness ledger asks whether a property does anything;
48+
# this asks what its EMPTY value MEANS. A restriction-shaped property whose
49+
# empty state is permissive has to be classified on purpose — omission is the
50+
# commonest authoring error, and it must not also be the widest grant.
51+
- name: Check empty-state semantics
52+
run: pnpm --filter @objectstack/spec check:empty-state

content/docs/references/kernel/plugin-runtime.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Dynamic plugin loading subsystem configuration
7878
| **maxDynamicPlugins** | `integer` || Upper limit on runtime-loaded plugins |
7979
| **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 |
8080
| **defaultSandbox** | `boolean` || Sandbox dynamically loaded plugins by default |
81-
| **allowedSources** | `Enum<'npm' \| 'local' \| 'url' \| 'registry' \| 'git'>[]` | optional | Restrict which source types are permitted |
81+
| **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 |
8282
| **requireIntegrity** | `boolean` || Require integrity hash verification for remote sources |
8383
| **operationTimeout** | `integer` || Default timeout for load/unload operations in ms |
8484

packages/spec/liveness/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,10 +318,67 @@ audit gives divergent sub-statuses; otherwise the top-level entry covers the who
318318
} }
319319
```
320320

321+
## Empty-state semantics — the sibling gate (#3896)
322+
323+
This ledger asks **"does the property do anything?"** A second, smaller gate asks
324+
the question it does not: **"what does its EMPTY value mean?"**
325+
326+
`sys_sharing_rule.criteria_json` was live by any standard — parsed, stored, read
327+
by an evaluator. It was also optional, and its absence evaluated to
328+
`find(object, { filter: {} })`: every record of the object, granted to the
329+
recipient. The field description said so out loud — *"leave empty to share every
330+
record"* — and that sentence sat in the spec being read as the contract, because
331+
for an agent-authored platform **a field description is not documentation about
332+
the contract, it is the contract**.
333+
334+
Three properties, same syntactic shape (an optional list or predicate that
335+
"restricts" something), opposite meanings when empty:
336+
337+
| Property | Empty means |
338+
|---|---|
339+
| `object.apiMethods` | `undefined` = unrestricted, **`[]` = deny-all** |
340+
| `plugin-runtime.allowedSources` | was *"empty = all allowed"* — corrected |
341+
| sharing `condition` | nothing is shared (#3929) |
342+
343+
Nothing marked which was which. A maintainer knows by memory; a model authoring
344+
metadata cannot, and guessing wrong is silent and permissive. So the gate scans
345+
the schema surface for statements declaring an empty state to be permissive, and
346+
requires each to be classified in `../scripts/liveness/empty-state-registry.mts`:
347+
348+
| `semantics` | Meaning |
349+
|---|---|
350+
| `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. |
351+
| `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. |
352+
| `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. |
353+
| `output` | Not authorable: a computed projection (an explain trace, a server-resolved effective set). Its empty-state prose describes a result, not a policy. |
354+
355+
`closed` and `open` must cite where the posture is enforced; the path resolves
356+
like `evidence` above, so a pointer that rots is reported rather than trusted.
357+
358+
**The lesson `apiMethods` already encodes**, and the one worth copying: the empty
359+
ARRAY is closed, only ABSENCE is open. Collapsing the two produces a *vacuous
360+
allow-list* — where the one value an author reaches by mistake is also the widest
361+
grant. Better still is the shape `storage.zod.ts` uses: an explicit
362+
`mode: 'whitelist'` discriminator plus `.min(1)`, which makes an empty whitelist
363+
unrepresentable.
364+
365+
```bash
366+
pnpm --filter @objectstack/spec check:empty-state # run the gate
367+
tsx packages/spec/scripts/liveness/check-empty-state.mts --dump # inventory (seeding aid)
368+
```
369+
370+
Detection matches the **statement**, not field names. Names would be a guess, and
371+
this README is blunt about where a guessy check ends up: a permanently-noisy
372+
check is a check nobody reads. A statement that resolves to no property is
373+
narrative — a file header explaining a past bug — and is reported as a
374+
non-failing note.
375+
321376
## Files & usage
322377

323378
- `<type>.json` — the ledger for a governed metadata type.
324379
- `../scripts/liveness/check-liveness.mts` — the gate (tsx; imports the registry).
380+
- `../scripts/liveness/check-empty-state.mts` — the empty-state gate (above);
381+
`empty-state-registry.mts` is its source of truth.
325382

326383
```bash
327384
pnpm --filter @objectstack/spec check:liveness # run the gate

packages/spec/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@
206206
"test:watch": "vitest",
207207
"test:coverage": "vitest run --coverage",
208208
"check:liveness": "tsx scripts/liveness/check-liveness.mts",
209+
"check:empty-state": "tsx scripts/liveness/check-empty-state.mts",
209210
"gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts",
210211
"check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check",
211212
"check:react-conformance": "tsx scripts/check-react-blocks-conformance.ts",
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// #3896 follow-up — the empty-state gate (CLI entry).
2+
//
3+
// pnpm --filter @objectstack/spec check:empty-state
4+
// pnpm --filter @objectstack/spec check:empty-state -- --dump # inventory, seeding aid
5+
//
6+
// See empty-state.mts for what it enforces and why, and
7+
// packages/spec/liveness/README.md § "Empty-state semantics" for the author-facing
8+
// version.
9+
10+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
11+
import { join, resolve, relative, dirname } from 'node:path';
12+
import { fileURLToPath } from 'node:url';
13+
14+
import { EMPTY_STATE_REGISTRY } from './empty-state-registry.mts';
15+
import { checkEmptyState } from './empty-state.mts';
16+
17+
const here = dirname(fileURLToPath(import.meta.url));
18+
const specRoot = resolve(here, '../..'); // packages/spec
19+
const repoRoot = resolve(specRoot, '../..');
20+
const srcRoot = join(specRoot, 'src');
21+
22+
/** The authorable spec surface: the schema files an author (or a model) reads. */
23+
function collectSchemaFiles(dir: string, out: string[] = []): string[] {
24+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
25+
const full = join(dir, entry.name);
26+
if (entry.isDirectory()) {
27+
collectSchemaFiles(full, out);
28+
} else if (entry.name.endsWith('.zod.ts') && !entry.name.endsWith('.test.ts')) {
29+
out.push(full);
30+
}
31+
}
32+
return out;
33+
}
34+
35+
const args = process.argv.slice(2);
36+
const asJson = args.includes('--json');
37+
const dump = args.includes('--dump');
38+
39+
const files = collectSchemaFiles(srcRoot).sort();
40+
const sources = new Map<string, string>(
41+
files.map((f) => [relative(repoRoot, f), readFileSync(f, 'utf8')]),
42+
);
43+
44+
const { hits, findings, notes } = checkEmptyState({
45+
sources,
46+
registry: EMPTY_STATE_REGISTRY,
47+
exists: (p) => existsSync(join(repoRoot, p)),
48+
});
49+
50+
if (dump) {
51+
console.log(`Permissive-empty statements across ${sources.size} schema files:\n`);
52+
for (const h of hits) {
53+
console.log(` ${h.file}:${h.line} [${h.property ?? '<unresolved>'}]`);
54+
console.log(` ${h.text}`);
55+
}
56+
process.exit(0);
57+
}
58+
59+
if (asJson) {
60+
console.log(JSON.stringify({ scanned: sources.size, hits, findings, notes }, null, 2));
61+
process.exit(findings.length > 0 ? 1 : 0);
62+
}
63+
64+
console.log(`Empty-state gate — ${sources.size} schema files, ${hits.length} permissive-empty statements.`);
65+
66+
const byKind = new Map<string, typeof findings>();
67+
for (const f of findings) {
68+
const list = byKind.get(f.kind) ?? [];
69+
list.push(f);
70+
byKind.set(f.kind, list);
71+
}
72+
73+
const HEADINGS: Record<string, string> = {
74+
unregistered: 'UNCLASSIFIED — a permissive empty state nobody signed off on',
75+
unresolved: 'UNRESOLVED — statement could not be tied to a property',
76+
stale: 'STALE — registered, but the statement is gone',
77+
'missing-rationale': 'NO RATIONALE',
78+
'missing-evidence': 'NO EVIDENCE — access gates must cite their enforcement site',
79+
'rotted-evidence': 'ROTTED EVIDENCE',
80+
};
81+
82+
for (const [kind, list] of byKind) {
83+
console.log(`\n${HEADINGS[kind] ?? kind} (${list.length}):`);
84+
for (const f of list) {
85+
const where = f.line ? `${f.file}:${f.line}` : f.file;
86+
console.log(` ✗ ${where}${f.property ? ` — ${f.property}` : ''}`);
87+
console.log(` ${f.message}`);
88+
}
89+
}
90+
91+
if (notes.length > 0) {
92+
console.log(`\nNotes — narrative, not enforced (${notes.length}):`);
93+
for (const n of notes) console.log(` · ${n.file}:${n.line}${n.message}`);
94+
}
95+
96+
if (findings.length === 0) {
97+
const counts = EMPTY_STATE_REGISTRY.reduce<Record<string, number>>((acc, e) => {
98+
acc[e.semantics] = (acc[e.semantics] ?? 0) + 1;
99+
return acc;
100+
}, {});
101+
const summary = Object.entries(counts)
102+
.sort(([a], [b]) => a.localeCompare(b))
103+
.map(([k, v]) => `${v} ${k}`)
104+
.join(', ');
105+
console.log(`✓ all classified (${summary})`);
106+
process.exit(0);
107+
}
108+
109+
console.log(`\n${findings.length} finding(s) — see packages/spec/scripts/liveness/empty-state-registry.mts`);
110+
process.exit(1);

0 commit comments

Comments
 (0)