Skip to content

Commit 0f9faa2

Browse files
os-zhuangclaude
andauthored
feat(spec,cli): the liveness gate governs every registered metadata type (#4487) (#4489)
`GOVERNED` was a hand-maintained list and nothing compared it against the registry it claims to cover. It governed 15 of 25 registered metadata types while reporting itself complete: a type in the other ten was authorable — served by /api/v1/meta/types/:type, editable in Studio — and was never asked who reads its properties, so an inert key on it was invisible to CI and its silence read as success. `datasource` was in that state for its entire life. #4410, #4465 and #4481 found six inert keys on it by hand, two security-shaped: `schemaMode` was dropped between record and connection spec, so a database ObjectStack must never run DDL against was constructed as `managed`; `ssl` stopped at the record, so a TLS block with a CA certificate configured nothing while looking identical to one that worked. The gate is now answerable to the registry. Every registered type must be in GOVERNED or in PENDING_GOVERNANCE with a reason and an issue; registering a type and forgetting the ledger fails CI with the entry to write. The reverse rots too and also fails — a PENDING_GOVERNANCE row for a type since governed claims a debt that no longer exists. `datasource` is now governed: 43 properties classified with evidence, and the result is the highest dead ratio of any governed type — 20 of 43 have no runtime consumer. `capabilities.*` (11): the engine gates pushdown on the runtime driver's own `supports.*` object, a non-overlapping vocabulary. `healthCheck.*` (3): nothing schedules a datasource probe. `retryPolicy.*` (4): no connect or query path retries. Plus `external.label` and `external.requirePermission`. One correction ships with this, and it is why the audit was worth doing. `capabilities.readOnly` reads as a safety switch and gates nothing — and two shipped prescriptions pointed authors at it: the externalSettingsUnknownKeyError guidance in datasource.zod.ts and the #4465 changeset's relocation table. Both now name `external.allowWrites: false`, the write gate the engine checks. The v17 release notes carried a matching false claim about `capabilities` gating pushdown; corrected here too. The CLI advisory lint picks the ledger up, so `os compile` warns an author who sets any of the 20. That needed `datasource` in TYPE_COLLECTIONS: coverage grows by marking entries authorWarn only WITHIN a type the lint already walks, and a governed type whose collection is unregistered has a correct ledger that warns nobody. Nine types remain ungoverned and are now enumerated rather than implied (#4488). Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY Co-authored-by: Claude <noreply@anthropic.com>
1 parent cf2c9b7 commit 0f9faa2

9 files changed

Lines changed: 444 additions & 12 deletions

File tree

.changeset/datasource-config-driver-contract.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ And the relocations — keys that were never driver config:
4545
| --- | --- |
4646
| `min` / `max` / `idleTimeoutMillis` / `connectionTimeoutMillis` | the datasource's own `pool` block |
4747
| `schemaMode` | next to `driver`, on the datasource |
48-
| `readOnly` | `capabilities: { readOnly: true }` |
48+
| `readOnly` | `external: { allowWrites: false }` — the enforced write gate. (This row said `capabilities: { readOnly: true }` until #4487's liveness audit found that key has no reader.) |
4949
| `ssl: { ca, cert, key, rejectUnauthorized }` | the datasource's own `ssl` block — inside `config`, `ssl` is the on/off boolean shorthand |
5050

5151
Two memory-driver keys are **removed**: `indexes` and `maxRecordsPerObject`.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
The liveness gate now governs every registered metadata type (#4487)
7+
8+
`GOVERNED` in `check-liveness.mts` was a hand-maintained list, and nothing ever
9+
compared it against the registry it claims to cover. It governed **15 of 25**
10+
registered metadata types while reporting itself complete. A type in the other
11+
ten was authorable — served by `/api/v1/meta/types/:type`, editable in Studio —
12+
and was never asked who reads its properties, so an inert key on it was
13+
invisible to CI and its silence read as success.
14+
15+
`datasource` was in that state for its entire life. #4410, #4465 and #4481 found
16+
six inert keys on it **by hand**, two of them security-shaped: `schemaMode` was
17+
dropped between the record and the connection spec, so a database ObjectStack
18+
must never run DDL against was constructed as `managed`; `ssl` stopped at the
19+
record, so a TLS block with a CA certificate in it configured nothing while
20+
looking identical to one that worked.
21+
22+
**The gate is now answerable to the registry.** Every registered type must be in
23+
`GOVERNED` or in `PENDING_GOVERNANCE` with a reason and an issue. Registering a
24+
type and forgetting the ledger fails CI with the entry to write. The reverse rots
25+
too, so it also fails: a `PENDING_GOVERNANCE` row for a type that has since been
26+
governed claims a debt that no longer exists.
27+
28+
**`datasource` is now governed**`liveness/datasource.json`, all 43 properties
29+
classified with evidence. The result is the highest dead ratio of any governed
30+
type: **20 of 43 have no runtime consumer.**
31+
32+
| Dead cluster | Why |
33+
| --- | --- |
34+
| `capabilities.*` (11) | The engine gates pushdown on the runtime driver's own `supports.*` object — `autonumber`, `batchSchemaSync`, `queryDateGranularity` — a different mechanism whose vocabulary does not overlap this block at all. `having-filter.ts` says it outright: "SQL pushdown can come later behind a driver capability flag." |
35+
| `healthCheck.*` (3) | Nothing schedules a datasource probe. Liveness is checked on demand through the driver handle's `ping()`. |
36+
| `retryPolicy.*` (4) | No connect or query path retries. |
37+
| `external.label`, `external.requirePermission` | No reader. |
38+
39+
**One correction ships with this**, and it is the reason the audit was worth
40+
doing rather than a bookkeeping exercise. `capabilities.readOnly` reads as a
41+
safety switch and gates nothing — and **two shipped prescriptions pointed
42+
authors at it**: the `externalSettingsUnknownKeyError` guidance in
43+
`datasource.zod.ts` ("or `capabilities.readOnly` to describe the driver") and
44+
the #4465 changeset's relocation table. Both now name `external.allowWrites:
45+
false`, which is the write gate the ObjectQL engine actually checks. An author
46+
who followed the old advice believed they had marked a datasource non-writable
47+
and had not. The v17 release notes carried a matching false claim — that an
48+
unregistered `capabilities` key made the engine stop pushing work down to the
49+
driver — corrected in the same change.
50+
51+
Two traps worth naming, because both nearly produced a wrong verdict here:
52+
53+
- **`healthCheck` and `retryPolicy` are name collisions.** A bare grep for
54+
either returns plenty of live readers — the plugin health monitor, `hook`,
55+
`job` — none of which is this type. `hook.retryPolicy` even spells its delay
56+
`backoffMs` where this declares `baseDelayMs`; the shape mismatch is the tell
57+
that nothing reads both.
58+
- **objectui's `DatasourcePreview` renders `pool`, `ssl`, `retryPolicy` and
59+
`healthCheck` as panels**, and is cited as evidence for none of them. That is
60+
the standing rule in `liveness/README.md`, and #4481 is the fresh precedent:
61+
the only "consumer" of `readReplicas` in either repo was a preview pill.
62+
63+
The CLI advisory lint picks the ledger up automatically, so `os compile` now
64+
warns an author who sets any of the 20. That needed one line beyond the ledger —
65+
`datasource` had to be added to `TYPE_COLLECTIONS`. Coverage grows by marking
66+
entries `authorWarn` only *within* a type the lint already walks; a newly
67+
governed type needs its collection registered or its ledger warns nobody.
68+
69+
Nine types remain ungoverned and are now enumerated rather than implied:
70+
`app`, `book`, `doc`, `email_template`, `job`, `mapping`, `seed`, `translation`,
71+
`validation` (#4488).

content/docs/releases/v17.mdx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -424,9 +424,16 @@ schema to the two highest-risk authorable surfaces, per the triage in
424424
then connected on driver defaults rather than failing. Those keys now
425425
prescribe the move into `config`; a top-level `password` is instead pointed at
426426
`external.credentialsRef`, because relocating an inlined secret is not the fix.
427-
A dropped key in `capabilities` was quieter still: an unregistered capability
428-
reads as `false`, so the engine stopped pushing that work down to the driver
429-
and recomputed it in memory.
427+
A dropped key in `capabilities` was quieter still — though not for the reason
428+
this note used to give. It claimed an unregistered capability "reads as
429+
`false`, so the engine stopped pushing that work down to the driver and
430+
recomputed it in memory", which was never true: the #4487 liveness audit
431+
found the whole `capabilities` block has no reader at all. The engine gates
432+
pushdown on the runtime driver's own `supports.*` object, a different
433+
mechanism with a non-overlapping vocabulary. Every key in the block is `dead`
434+
in `liveness/datasource.json`, and `capabilities.readOnly` is the one to know
435+
about: it reads as a safety switch and gates nothing — `external.allowWrites:
436+
false` is the enforced write gate.
430437

431438
One clarification, since these flips are easy to over-read: making a schema
432439
strict does **not** change its published JSON Schema. `build-schemas.ts`

packages/cli/src/utils/lint-liveness-properties.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,65 @@ describe('lintLivenessProperties', () => {
189189
});
190190
expect(findings).toEqual([]);
191191
});
192+
193+
// ── datasource (#4487 — the type was ungoverned until the ledger was seeded) ──
194+
// Runs against the REAL datasource.json. These pin the ledger→author loop for
195+
// the type that most needed it: 20 of its 43 props have no runtime consumer,
196+
// and until #4487 nothing told an author so.
197+
198+
it('warns on the dead datasource blocks — capabilities / healthCheck / retryPolicy (#4487)', () => {
199+
const findings = lintLivenessProperties({
200+
datasources: [{
201+
name: 'warehouse',
202+
driver: 'postgres',
203+
config: { host: 'db.internal', database: 'analytics' },
204+
capabilities: { transactions: true, queryAggregations: true },
205+
healthCheck: { enabled: true, intervalMs: 30000 },
206+
retryPolicy: { maxRetries: 5, baseDelayMs: 1000 },
207+
}],
208+
});
209+
const msgs = paths(findings);
210+
expect(msgs.some((m) => m.includes('capabilities.transactions'))).toBe(true);
211+
expect(msgs.some((m) => m.includes('capabilities.queryAggregations'))).toBe(true);
212+
expect(msgs.some((m) => m.includes('healthCheck.enabled'))).toBe(true);
213+
expect(msgs.some((m) => m.includes('healthCheck.intervalMs'))).toBe(true);
214+
expect(msgs.some((m) => m.includes('retryPolicy.maxRetries'))).toBe(true);
215+
expect(msgs.some((m) => m.includes('retryPolicy.baseDelayMs'))).toBe(true);
216+
});
217+
218+
// The entry the whole audit was worth doing for. `capabilities.readOnly` reads
219+
// as a safety switch and gates nothing, and two shipped prescriptions pointed
220+
// authors AT it until #4487. The hint has to name the gate that IS enforced,
221+
// or the warning just relocates the author's confusion.
222+
it('warns on capabilities.readOnly and names the real write gate (#4487)', () => {
223+
const findings = lintLivenessProperties({
224+
datasources: [{
225+
name: 'reporting',
226+
driver: 'postgres',
227+
config: { host: 'ro.internal', database: 'reporting' },
228+
capabilities: { readOnly: true },
229+
}],
230+
});
231+
const hit = findings.find((f) => f.message.includes('capabilities.readOnly'));
232+
expect(hit).toBeDefined();
233+
expect(hit!.hint).toMatch(/allowWrites/);
234+
});
235+
236+
it('stays silent on a datasource that only sets live properties (#4487)', () => {
237+
const findings = lintLivenessProperties({
238+
datasources: [{
239+
name: 'warehouse',
240+
label: 'Warehouse',
241+
driver: 'postgres',
242+
config: { host: 'db.internal', database: 'analytics' },
243+
pool: { min: 1, max: 10 },
244+
ssl: { enabled: true, rejectUnauthorized: true },
245+
active: true,
246+
autoConnect: true,
247+
schemaMode: 'external',
248+
external: { allowWrites: false, allowedSchemas: ['public'] },
249+
}],
250+
});
251+
expect(findings).toEqual([]);
252+
});
192253
});

packages/cli/src/utils/lint-liveness-properties.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
182182
{ type: 'page', key: 'pages' },
183183
{ type: 'view', key: 'views' },
184184
{ type: 'webhook', key: 'webhooks' },
185+
// #4487. Note what adding a TYPE costs versus adding a warned property: the
186+
// doc below is right that coverage grows by marking entries `authorWarn` —
187+
// but only WITHIN a type already listed here. A newly governed type needs its
188+
// collection registered or its ledger warns nobody, which would leave the
189+
// ledger correct and silent: the exact shape this lint exists to prevent.
190+
{ type: 'datasource', key: 'datasources' },
185191
];
186192

187193
/**

packages/spec/liveness/README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,9 +503,25 @@ EOF
503503
| report | 13 | 0 | 0 || dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) |
504504
| dashboard | 10 | 0 | 2 || ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) |
505505
| query | 16 | 7 | 4 || **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The 7 experimental resolve from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2) |
506+
| datasource | 23 | – | 20 | – | seeded 2026-08-01 (#4487) — the **highest dead ratio of any governed type** (20 of 43), and it was ungoverned until now, which is not a coincidence: #4410/#4465/#4481 found six inert keys here by hand, two security-shaped (`schemaMode` left an external DB constructible as `managed` with DDL ungated; `ssl` configured nothing while looking configured). Dead set = `capabilities.*` (all 11 — the engine gates pushdown on the runtime driver's `supports.*` object, a non-overlapping vocabulary), `healthCheck.*` (3 — nothing schedules a datasource probe; the 20 `healthCheck` hits in the repo all belong to the PLUGIN health monitor and other surfaces), `retryPolicy.*` (4 — `retryPolicy` IS enforced on `hook` and `job`, which is what makes this one read alive; the shapes differ), `external.label`, `external.requirePermission`. **`capabilities.readOnly` is the one to know**: it reads as a safety switch, gates nothing, and two shipped prescriptions pointed authors at it until #4487 — `external.allowWrites: false` is the enforced write gate. `config` is a `z.record`, so its per-driver keys sit outside the walk (recorded in the entry's note, not silently skipped) |
506507
| webhook | 0 | 1 | 16 || **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema` (#3461/#3462). The ENTIRE authoring surface is dead: nothing materializes an authored `webhooks:` entry into a `sys_webhook` dispatcher row (#3461, enforce-or-remove pending). `url` carries the single per-webhook `authorWarn` (one no-op heads-up per artifact, not per-prop); `authentication` experimental (HMAC-`secret`-only); `isActive` unmarked (default(true)). Notes cite the sys_webhook column map as the future materializer's mapping table |
507508

508509
The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every
509510
misleading entry carries `authorWarn` so authors hear about it at compile time.
510-
Not yet governed (rollout): app, job, datasource,
511-
translation, email_template, doc, book, validation, seed.
511+
Not yet governed: app, job, translation, email_template, doc, book, validation,
512+
seed — now enumerated in `PENDING_GOVERNANCE` in `check-liveness.mts` and
513+
enforced (#4487; worklist #4488). This paragraph used to be the only record of
514+
the rollout gap, which is precisely why the gap survived: prose in a README
515+
cannot fail a build, so a registered type nobody had ever audited looked
516+
identical to one audited and found clean. The gate now compares `GOVERNED`
517+
against the metadata-type registry and refuses a type in neither list.
518+
519+
⚠️ **Several count columns above are stale**, and #4487 deliberately did not
520+
bulk-correct them. Two methods disagree — the python snippet below counts ledger
521+
JSON entries, while the gate's `--json` report also resolves `describe()` markers
522+
and drills `children` differently — and the `query` and `webhook` rows are
523+
annotated beyond either (see their notes). A mechanical rewrite with the wrong
524+
method silently flattens those annotations; that was attempted while writing
525+
#4487 and produced two regressions before being caught. Fixing this properly
526+
means deciding which method the table means and recording it here. Tracked in
527+
#4488.

0 commit comments

Comments
 (0)