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
2 changes: 1 addition & 1 deletion .changeset/datasource-config-driver-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ And the relocations — keys that were never driver config:
| --- | --- |
| `min` / `max` / `idleTimeoutMillis` / `connectionTimeoutMillis` | the datasource's own `pool` block |
| `schemaMode` | next to `driver`, on the datasource |
| `readOnly` | `capabilities: { readOnly: true }` |
| `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.) |
| `ssl: { ca, cert, key, rejectUnauthorized }` | the datasource's own `ssl` block — inside `config`, `ssl` is the on/off boolean shorthand |

Two memory-driver keys are **removed**: `indexes` and `maxRecordsPerObject`.
Expand Down
71 changes: 71 additions & 0 deletions .changeset/liveness-governs-every-registered-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
'@objectstack/spec': patch
'@objectstack/cli': patch
---

The liveness gate now governs every registered metadata type (#4487)

`GOVERNED` in `check-liveness.mts` was a hand-maintained list, and nothing ever
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 of them security-shaped: `schemaMode` was
dropped between the record and the 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 in it 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, so it also fails: a `PENDING_GOVERNANCE` row for a type that has since been
governed claims a debt that no longer exists.

**`datasource` is now governed** — `liveness/datasource.json`, all 43 properties
classified with evidence. The result is the highest dead ratio of any governed
type: **20 of 43 have no runtime consumer.**

| Dead cluster | Why |
| --- | --- |
| `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." |
| `healthCheck.*` (3) | Nothing schedules a datasource probe. Liveness is checked on demand through the driver handle's `ping()`. |
| `retryPolicy.*` (4) | No connect or query path retries. |
| `external.label`, `external.requirePermission` | No reader. |

**One correction ships with this**, and it is the reason the audit was worth
doing rather than a bookkeeping exercise. `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` ("or `capabilities.readOnly` to describe the driver") and
the #4465 changeset's relocation table. Both now name `external.allowWrites:
false`, which is the write gate the ObjectQL engine actually checks. An author
who followed the old advice believed they had marked a datasource non-writable
and had not. The v17 release notes carried a matching false claim — that an
unregistered `capabilities` key made the engine stop pushing work down to the
driver — corrected in the same change.

Two traps worth naming, because both nearly produced a wrong verdict here:

- **`healthCheck` and `retryPolicy` are name collisions.** A bare grep for
either returns plenty of live readers — the plugin health monitor, `hook`,
`job` — none of which is this type. `hook.retryPolicy` even spells its delay
`backoffMs` where this declares `baseDelayMs`; the shape mismatch is the tell
that nothing reads both.
- **objectui's `DatasourcePreview` renders `pool`, `ssl`, `retryPolicy` and
`healthCheck` as panels**, and is cited as evidence for none of them. That is
the standing rule in `liveness/README.md`, and #4481 is the fresh precedent:
the only "consumer" of `readReplicas` in either repo was a preview pill.

The CLI advisory lint picks the ledger up automatically, so `os compile` now
warns an author who sets any of the 20. That needed one line beyond the ledger —
`datasource` had to be added to `TYPE_COLLECTIONS`. Coverage grows by marking
entries `authorWarn` only *within* a type the lint already walks; a newly
governed type needs its collection registered or its ledger warns nobody.

Nine types remain ungoverned and are now enumerated rather than implied:
`app`, `book`, `doc`, `email_template`, `job`, `mapping`, `seed`, `translation`,
`validation` (#4488).
13 changes: 10 additions & 3 deletions content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -424,9 +424,16 @@ schema to the two highest-risk authorable surfaces, per the triage in
then connected on driver defaults rather than failing. Those keys now
prescribe the move into `config`; a top-level `password` is instead pointed at
`external.credentialsRef`, because relocating an inlined secret is not the fix.
A dropped key in `capabilities` was quieter still: an unregistered capability
reads as `false`, so the engine stopped pushing that work down to the driver
and recomputed it in memory.
A dropped key in `capabilities` was quieter still — though not for the reason
this note used to give. It claimed an unregistered capability "reads as
`false`, so the engine stopped pushing that work down to the driver and
recomputed it in memory", which was never true: the #4487 liveness audit
found the whole `capabilities` block has no reader at all. The engine gates
pushdown on the runtime driver's own `supports.*` object, a different
mechanism with a non-overlapping vocabulary. Every key in the block is `dead`
in `liveness/datasource.json`, and `capabilities.readOnly` is the one to know
about: it reads as a safety switch and gates nothing — `external.allowWrites:
false` is the enforced write gate.

One clarification, since these flips are easy to over-read: making a schema
strict does **not** change its published JSON Schema. `build-schemas.ts`
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/utils/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,65 @@ describe('lintLivenessProperties', () => {
});
expect(findings).toEqual([]);
});

// ── datasource (#4487 — the type was ungoverned until the ledger was seeded) ──
// Runs against the REAL datasource.json. These pin the ledger→author loop for
// the type that most needed it: 20 of its 43 props have no runtime consumer,
// and until #4487 nothing told an author so.

it('warns on the dead datasource blocks — capabilities / healthCheck / retryPolicy (#4487)', () => {
const findings = lintLivenessProperties({
datasources: [{
name: 'warehouse',
driver: 'postgres',
config: { host: 'db.internal', database: 'analytics' },
capabilities: { transactions: true, queryAggregations: true },
healthCheck: { enabled: true, intervalMs: 30000 },
retryPolicy: { maxRetries: 5, baseDelayMs: 1000 },
}],
});
const msgs = paths(findings);
expect(msgs.some((m) => m.includes('capabilities.transactions'))).toBe(true);
expect(msgs.some((m) => m.includes('capabilities.queryAggregations'))).toBe(true);
expect(msgs.some((m) => m.includes('healthCheck.enabled'))).toBe(true);
expect(msgs.some((m) => m.includes('healthCheck.intervalMs'))).toBe(true);
expect(msgs.some((m) => m.includes('retryPolicy.maxRetries'))).toBe(true);
expect(msgs.some((m) => m.includes('retryPolicy.baseDelayMs'))).toBe(true);
});

// The entry the whole audit was worth doing for. `capabilities.readOnly` reads
// as a safety switch and gates nothing, and two shipped prescriptions pointed
// authors AT it until #4487. The hint has to name the gate that IS enforced,
// or the warning just relocates the author's confusion.
it('warns on capabilities.readOnly and names the real write gate (#4487)', () => {
const findings = lintLivenessProperties({
datasources: [{
name: 'reporting',
driver: 'postgres',
config: { host: 'ro.internal', database: 'reporting' },
capabilities: { readOnly: true },
}],
});
const hit = findings.find((f) => f.message.includes('capabilities.readOnly'));
expect(hit).toBeDefined();
expect(hit!.hint).toMatch(/allowWrites/);
});

it('stays silent on a datasource that only sets live properties (#4487)', () => {
const findings = lintLivenessProperties({
datasources: [{
name: 'warehouse',
label: 'Warehouse',
driver: 'postgres',
config: { host: 'db.internal', database: 'analytics' },
pool: { min: 1, max: 10 },
ssl: { enabled: true, rejectUnauthorized: true },
active: true,
autoConnect: true,
schemaMode: 'external',
external: { allowWrites: false, allowedSchemas: ['public'] },
}],
});
expect(findings).toEqual([]);
});
});
6 changes: 6 additions & 0 deletions packages/cli/src/utils/lint-liveness-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
{ type: 'page', key: 'pages' },
{ type: 'view', key: 'views' },
{ type: 'webhook', key: 'webhooks' },
// #4487. Note what adding a TYPE costs versus adding a warned property: the
// doc below is right that coverage grows by marking entries `authorWarn` —
// but only WITHIN a type already listed here. A newly governed type needs its
// collection registered or its ledger warns nobody, which would leave the
// ledger correct and silent: the exact shape this lint exists to prevent.
{ type: 'datasource', key: 'datasources' },
];

/**
Expand Down
20 changes: 18 additions & 2 deletions packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,9 +503,25 @@ EOF
| 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) |
| 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) |
| 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) |
| 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) |
| 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 |

The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every
misleading entry carries `authorWarn` so authors hear about it at compile time.
Not yet governed (rollout): app, job, datasource,
translation, email_template, doc, book, validation, seed.
Not yet governed: app, job, translation, email_template, doc, book, validation,
seed — now enumerated in `PENDING_GOVERNANCE` in `check-liveness.mts` and
enforced (#4487; worklist #4488). This paragraph used to be the only record of
the rollout gap, which is precisely why the gap survived: prose in a README
cannot fail a build, so a registered type nobody had ever audited looked
identical to one audited and found clean. The gate now compares `GOVERNED`
against the metadata-type registry and refuses a type in neither list.

⚠️ **Several count columns above are stale**, and #4487 deliberately did not
bulk-correct them. Two methods disagree — the python snippet below counts ledger
JSON entries, while the gate's `--json` report also resolves `describe()` markers
and drills `children` differently — and the `query` and `webhook` rows are
annotated beyond either (see their notes). A mechanical rewrite with the wrong
method silently flattens those annotations; that was attempted while writing
#4487 and produced two regressions before being caught. Fixing this properly
means deciding which method the table means and recording it here. Tracked in
#4488.
Loading
Loading