From de42b0c5e0c073f5098baafe2dd19202d7c63134 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 10:07:16 +0000 Subject: [PATCH] feat(spec,cli): the liveness gate governs every registered metadata type (#4487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY --- .../datasource-config-driver-contract.md | 2 +- .../liveness-governs-every-registered-type.md | 71 +++++++ content/docs/releases/v17.mdx | 13 +- .../utils/lint-liveness-properties.test.ts | 61 ++++++ .../cli/src/utils/lint-liveness-properties.ts | 6 + packages/spec/liveness/README.md | 20 +- packages/spec/liveness/datasource.json | 177 ++++++++++++++++++ .../spec/scripts/liveness/check-liveness.mts | 93 ++++++++- packages/spec/src/data/datasource.zod.ts | 13 +- 9 files changed, 444 insertions(+), 12 deletions(-) create mode 100644 .changeset/liveness-governs-every-registered-type.md create mode 100644 packages/spec/liveness/datasource.json diff --git a/.changeset/datasource-config-driver-contract.md b/.changeset/datasource-config-driver-contract.md index c62c57dbfe..9b0d574c7f 100644 --- a/.changeset/datasource-config-driver-contract.md +++ b/.changeset/datasource-config-driver-contract.md @@ -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`. diff --git a/.changeset/liveness-governs-every-registered-type.md b/.changeset/liveness-governs-every-registered-type.md new file mode 100644 index 0000000000..bbc29915aa --- /dev/null +++ b/.changeset/liveness-governs-every-registered-type.md @@ -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). diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx index 8cac880ebe..ea64d15aec 100644 --- a/content/docs/releases/v17.mdx +++ b/content/docs/releases/v17.mdx @@ -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` diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index 2500ab101b..fdc74c542b 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -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([]); + }); }); diff --git a/packages/cli/src/utils/lint-liveness-properties.ts b/packages/cli/src/utils/lint-liveness-properties.ts index b835b874d2..c1c69d2b64 100644 --- a/packages/cli/src/utils/lint-liveness-properties.ts +++ b/packages/cli/src/utils/lint-liveness-properties.ts @@ -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' }, ]; /** diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 5b048788c6..ff6f458b51 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -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. diff --git a/packages/spec/liveness/datasource.json b/packages/spec/liveness/datasource.json new file mode 100644 index 0000000000..3b962257d0 --- /dev/null +++ b/packages/spec/liveness/datasource.json @@ -0,0 +1,177 @@ +{ + "type": "datasource", + "_note": "DatasourceSchema. Consumers: @objectstack/service-datasource (DatasourceConnectionService.toSpec → DatasourceConnectionSpec → createDefaultDatasourceDriverFactory), @objectstack/objectql (engine.ts federation write gate), @objectstack/runtime (external-validation-plugin). Seeded 2026-08-01 (#4487) after #4465/#4481 found six inert keys BY HAND on a type no gate governed. Method: the authoritative boundary is what crosses into `ConnectableDatasource` (datasource-connection-service.ts:45-74) and `DatasourceConnectionSpec` (contracts/datasource-driver-factory.ts:25-59) — a block on neither reaches no driver. objectui's DatasourcePreview renders `pool`/`ssl`/`retryPolicy`/`healthCheck` as SideBlocks and is NOT counted as evidence for any entry (see README, 'An authoring/preview renderer is NOT a runtime consumer' — the #4481 precedent was exactly this). Framework provenance/lock fields auto-live.", + "props": { + "name": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts:674", + "note": "registry key + the driver name the engine routes on (`driver.name` must equal it)." + }, + "label": { + "status": "live", + "note": "display metadata (Setup → Datasources list). No runtime consumer by design — ADR-0033 docs-shaped, deliberately kept, not authorWarn'd." + }, + "driver": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:334", + "note": "factory dispatch; `resolveDriverId` normalizes aliases before the switch." + }, + "config": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:129", + "note": "per-driver connection config. Validated against the driver contract since #4410 (data/driver/config-registry.zod.ts). NOTE the walk boundary: `config` is a `z.record`, so the gate cannot see inside it — the keys an author actually writes (`host`, `port`, `filename`) are governed by the per-driver zod schemas in data/driver/*.zod.ts, not by this ledger. That is a real gap in coverage, recorded here rather than left implicit." + }, + "pool": { + "children": { + "min": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:181", + "note": "knex pool floor. Live only since #4465 — the factory used to hardcode `{ min: 0, max: 5 }` over the carried value." + }, + "max": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:182", + "note": "knex pool ceiling; also mapped onto the Mongo client's maxPoolSize (#4465)." + }, + "idleTimeoutMillis": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:183", + "note": "passed through to knex verbatim." + }, + "connectionTimeoutMillis": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:184", + "note": "mapped onto knex's `acquireTimeoutMillis` (different name, same meaning)." + } + } + }, + "capabilities": { + "children": { + "transactions": { "status": "dead", "authorWarn": true, "authorHint": "Delete it. The engine gates pushdown on the runtime driver's own `supports.*` object (objectql/src/engine.ts:3671, :4529, :4810 — `autonumber`, `queryDateGranularity`, `batchSchemaSync`), which is a different mechanism with a different vocabulary. Nothing reads this block." }, + "queryFilters": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. Filter support is not negotiated through datasource metadata." }, + "queryAggregations": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. Whether aggregation runs in SQL or in memory is decided by the driver's own code path, never by this flag." }, + "querySorting": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`." }, + "queryPagination": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`." }, + "queryWindowFunctions": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. Window functions were themselves retired from the query surface in #4286." }, + "querySubqueries": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`." }, + "joins": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. `query.joins` was retired in #4286; related-record retrieval is `expand`." }, + "fullTextSearch": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. Search capability is deployment/locale-gated in the search companion, not declared here." }, + "readOnly": { + "status": "dead", + "authorWarn": true, + "authorHint": "Delete it. It does NOT make a datasource read-only — no write path consults it. The enforced datasource-wide write gate is `external.allowWrites: false` (objectql/src/engine.ts:620), which requires `schemaMode !== 'managed'`.", + "note": "The most dangerous entry in this ledger and the reason it was seeded. `readOnly` reads as a safety property, and until this PR TWO shipped prescriptions pointed authors AT it: the `externalSettingsUnknownKeyError` guidance in datasource.zod.ts and the #4465 changeset's relocation table both offered `capabilities.readOnly` as the place to 'describe the driver'. Both were corrected in #4487. An author following that advice believed they had marked a datasource non-writable and had not." + }, + "dynamicSchema": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `capabilities.transactions`. Whether a driver is schemaless is a property of the driver, and the drivers that are (mongo, memory) behave that way unconditionally." } + }, + "note": "All 11 dead, verified 2026-08-01 by closing the call graph in both directions: (1) `capabilities` is absent from ConnectableDatasource AND DatasourceConnectionSpec, so it cannot reach a driver; (2) grepping each flag name across the monorepo returns only packages/spec — the schema's own declaration, its alias table, and the `*DriverSpec` literals in data/driver/*.zod.ts. The engine's real capability seam is `driver.supports?.*` on the runtime driver OBJECT, whose keys (`autonumber`, `batchSchemaSync`, `queryDateGranularity`) do not overlap with this vocabulary at all. `having-filter.ts:13` states the position plainly in a comment: 'SQL pushdown can come later behind a driver capability flag' — i.e. the mechanism this block describes is not built." + }, + "healthCheck": { + "children": { + "enabled": { "status": "dead", "authorWarn": true, "authorHint": "Delete it. No health-check loop reads this block. Connection liveness is probed on demand via the driver handle's `ping()` / `checkHealth()` (contracts/datasource-driver-factory.ts:88-92), which the admin service calls for `testConnection` — not on any interval this could enable." }, + "intervalMs": { "status": "dead", "authorWarn": true, "authorHint": "Delete it. Nothing schedules a datasource health check, so there is no interval to set. The only recurring datasource timer is `external.validation.checkIntervalMs` (schema-drift checking, a different concern)." }, + "timeoutMs": { "status": "dead", "authorWarn": true, "authorHint": "Delete it. See `healthCheck.intervalMs` — there is no probe loop for this to bound." } + }, + "note": "All 3 dead, verified 2026-08-01. `healthCheck` is absent from ConnectableDatasource and DatasourceConnectionSpec. Every `healthCheck` hit in the monorepo belongs to a DIFFERENT surface — the PLUGIN health monitor (core/src/health-monitor.ts, core/src/plugin-loader.ts:316), the AI model registry, the integration connector, `StartupOrchestratorOptions.healthCheck`. Name collision, not a consumer. Easy to mis-verify: a bare grep for 'healthCheck' returns 20 hits and none of them is this block." + }, + "ssl": { + "children": { + "enabled": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:111", + "note": "`enabled: false` short-circuits to `ssl: false`; otherwise the block is assembled into client TLS options." + }, + "rejectUnauthorized": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:114" + }, + "ca": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:115" + }, + "cert": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:116" + }, + "key": { + "status": "live", + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:117" + } + }, + "note": "Live only since #4465. Before that the whole block stopped at the record — nothing put it on the connection spec, so a TLS configuration with a CA certificate in it configured nothing while looking identical to one that worked. A security-shaped property that was silently inert; exactly the ADR-0078 class this ledger exists to catch, and it was found by hand rather than by a gate." + }, + "retryPolicy": { + "children": { + "maxRetries": { "status": "dead", "authorWarn": true, "authorHint": "Delete it. No connect or query path retries on this block. Connection failure handling is the boot policy in datasource-connection-service.ts (degraded boot / `bootCritical` fail-fast), which does not retry on a schedule. Do not confuse this with `hook.retryPolicy` (enforced, objectql/src/hook-wrappers.ts:105) or `job.retryPolicy` (enforced, runtime/src/app-plugin.ts:791) — same key name, different types, different shapes." }, + "baseDelayMs": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `retryPolicy.maxRetries`. Note this key does not even exist on the two retryPolicy blocks that ARE enforced: `hook.retryPolicy` spells its delay `backoffMs`." }, + "maxDelayMs": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `retryPolicy.maxRetries`." }, + "backoffMultiplier": { "status": "dead", "authorWarn": true, "authorHint": "Delete it — see `retryPolicy.maxRetries`." } + }, + "note": "All 4 dead, verified 2026-08-01. Absent from ConnectableDatasource and DatasourceConnectionSpec. The trap here is the name: `retryPolicy` IS enforced on `hook` and on `job`, so a grep for the key looks alive and a reader who stops there concludes the datasource one works too. The shapes differ — hook uses `{maxRetries, backoffMs}`, this declares `{maxRetries, baseDelayMs, maxDelayMs, backoffMultiplier}` — which is itself the tell that nothing reads both." + }, + "description": { + "status": "live", + "note": "internal documentation. No runtime consumer by design — ADR-0033 docs-shaped, deliberately kept, not authorWarn'd." + }, + "active": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts:296", + "note": "`active: false` skips the datasource in the boot auto-connect sweep, and datasource-admin-plugin.ts:424 excludes it from the runtime re-registration set. Genuinely enforced — unlike `flow.active` / `tool.active`, both of which were retired in v17 for claiming this and not delivering it." + }, + "autoConnect": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts:233", + "note": "ADR-0062 D2(c): opts a managed, unrouted datasource into the boot connect sweep." + }, + "schemaMode": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts:679", + "note": "carried onto the connection spec (#4410) and gates DDL at the driver; also read by objectql/src/engine.ts:620 for the federation write gate. Live only since #4465 — before that it was dropped between record and spec, so an `external` database ObjectStack must never run DDL against was constructed as `managed`. Security-shaped and silently inert; the second reason this ledger was seeded." + }, + "external": { + "children": { + "label": { + "status": "dead", + "authorWarn": true, + "authorHint": "Delete it. Nothing reads the federation block's own label — use the datasource's top-level `label`, which the Setup list renders." + }, + "allowedSchemas": { + "status": "live", + "evidence": "packages/services/service-datasource/src/external-datasource-service.ts:145", + "note": "restricts which remote schemas browse/introspect will surface (ADR-0015)." + }, + "allowWrites": { + "status": "live", + "evidence": "packages/objectql/src/engine.ts:620", + "note": "the enforced datasource-wide write gate (Gate 3). This — not `capabilities.readOnly` — is how a federated datasource is made read-only." + }, + "validation": { + "status": "live", + "evidence": "packages/runtime/src/external-validation-plugin.ts:153, packages/runtime/src/external-validation-plugin.ts:231", + "note": "`onMismatch` selects the drift policy (default 'fail'); `checkIntervalMs` schedules the recurring drift check; `checkOnBoot` gates the boot-time one. Also read by the degraded-boot classifier (packages/types/src/degraded-boot.ts:13)." + }, + "credentialsRef": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-connection-service.ts:457", + "note": "dereferenced through the SecretBinder to cleartext for the duration of one connect; never persisted or logged (ADR-0015 Addendum)." + }, + "queryTimeoutMs": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-admin-service.ts:220", + "note": "carried into the external-datasource probe options as `timeoutMs`." + }, + "requirePermission": { + "status": "dead", + "authorWarn": true, + "authorHint": "Delete it. No authorization check consults it — a permission named here gates nothing, and access to a federated datasource's data is governed by the ordinary object permission sets and RLS. Naming a permission that is never required is the same false-compliance shape as the retired `tool.permissions` (#3896)." + } + }, + "note": "5 of 7 live. The two dead ones are opposite in risk: `external.label` is cosmetic, `external.requirePermission` is security-shaped — it reads as an access gate and is not one." + }, + "origin": { + "status": "live", + "evidence": "packages/services/service-datasource/src/datasource-admin-plugin.ts:244", + "note": "server-stamped provenance (ADR-0015 Addendum). `code` marks a GitOps-owned datasource read-only in the UI; `runtime` marks one editable and re-registrable (datasource-admin-plugin.ts:424). Never accepted from client input." + } + } +} diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 43be00b890..57af2d48ed 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -79,7 +79,41 @@ const ledgerRoot = join(specRoot, 'liveness'); // Governed metadata types, rolled out highest-frequency / highest-risk first. // (`query` is not a metadata type — see SPEC_ONLY_SCHEMAS below.) -const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource']; + +// Registered metadata types that are NOT yet governed — the coverage ratchet. +// +// WHY THIS EXISTS. `GOVERNED` was hand-maintained and nothing compared it +// against the registry it claims to cover. So a type could be registered — +// authorable, served by `/api/v1/meta/types/:type`, editable in Studio — and +// simply never be asked "who reads this property?". `datasource` was in that +// state for its whole life: #4410, #4465 and #4481 found SIX inert keys on it +// BY HAND, two of them security-shaped (`schemaMode` left an external database +// constructible as `managed`, DDL ungated at the driver; `ssl` configured +// nothing while looking configured). The gate never had an opinion, because the +// type was not on the list — and a list that governs 15 of 25 registered types +// while reporting itself complete is worse than one that admits the gap. +// +// So the list is now answerable to the registry: every registered type must be +// governed OR appear here with a reason. Registering a type and forgetting the +// ledger fails CI with the entry to write. +// +// This is a RATCHET, not an allowlist to grow. An entry is a debt with an issue +// number and the direction of travel is out of this map into GOVERNED. Do not +// add one to silence the gate on a type you just registered — that is exactly +// the failure this map exists to make visible, and an entry with no issue is +// indistinguishable from never having looked. +const PENDING_GOVERNANCE: Record = { + app: 'the #4001 app step retired seven dead keys without seeding a ledger, so the rest of AppSchema is unclassified — #4488', + book: 'ADR-0046 documentation spine; no property audit done — #4488', + doc: 'ADR-0046 flat Markdown docs; small surface, unaudited — #4488', + email_template: 'unaudited — #4488', + job: 'unaudited; `job.retryPolicy` IS enforced (runtime/src/app-plugin.ts:791), so this one must not be assumed dead — #4488', + mapping: '#2611 reusable import mapping; unaudited — #4488', + seed: 'fixture/init data applied on publish; unaudited — #4488', + translation: 'unaudited — #4488', + validation: 'ValidationRuleSchema carries the ADR-0020 record state machine, so a wrong verdict is expensive; unaudited — #4488', +}; // Spec-only override: governed types whose canonical schema is NOT (yet) in the // metadata-type registry, so they can't be resolved via getMetadataTypeSchema. @@ -221,6 +255,8 @@ const report: any = { proofMissing: [] as string[], // a bound high-risk `live` entry with no proof at all orphanProofs: [] as string[], // a dogfood `@proof:` tag not registered in proof-registry.mts orphanEntries: [] as string[], // a ledger row whose property is gone from the schema (the reverse direction) + ungoverned: [] as string[], // a REGISTERED metadata type absent from both GOVERNED and PENDING_GOVERNANCE + stalePending: [] as string[], // a PENDING_GOVERNANCE row for a type that is now governed / no longer registered verification: null as VerificationReport | null, // `verifiedAt` ages — the re-verification worklist evidenceLocal: 0, // repo-rooted evidence paths actually resolved against this checkout evidenceForeign: 0, // evidence paths attributed to objectui / cloud — not resolvable here @@ -348,13 +384,31 @@ const staleDays = Number(staleDaysArg?.split('=')[1]) || DEFAULT_STALE_DAYS; const showWorklist = staleDaysArg !== undefined; report.verification = buildVerificationReport(verificationEntries, { staleDays }); +// ── coverage: is every REGISTERED metadata type accounted for? ── +// The gate's own blind spot until #4487. Everything above asks "is every +// property of a governed type classified?" — nothing asked "is every authorable +// type governed?", so a type absent from GOVERNED was never in the denominator +// and its silence read as success. +const governedSet = new Set(GOVERNED); +report.ungoverned = listMetadataTypeSchemaTypes() + .filter((t) => !governedSet.has(t) && !(t in PENDING_GOVERNANCE)) + .sort(); +// A PENDING_GOVERNANCE row for a type that is now governed (or no longer +// registered) is the same rot as an orphan ledger row: it claims a debt that +// does not exist, and it makes the map's length a lie about how much is left. +report.stalePending = Object.keys(PENDING_GOVERNANCE) + .filter((t) => governedSet.has(t) || !listMetadataTypeSchemaTypes().includes(t)) + .sort(); + const totalUnclassified = report.unclassified.length; const totalProofFailures = report.proofErrors.length + report.proofMissing.length; const failed = totalUnclassified > 0 || totalProofFailures > 0 || report.orphanEntries.length > 0 || - report.verification.errors.length > 0; + report.verification.errors.length > 0 || + report.ungoverned.length > 0 || + report.stalePending.length > 0; if (asJson) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { @@ -389,6 +443,30 @@ if (asJson) { console.log(`\n✗ ${totalUnclassified} UNCLASSIFIED — classify in packages/spec/liveness/.json:`); report.unclassified.forEach((s: string) => console.log(` ${s}`)); } + if (report.ungoverned.length) { + console.log(`\n✗ ${report.ungoverned.length} REGISTERED metadata type(s) governed by nothing:`); + report.ungoverned.forEach((t: string) => console.log(` ${t}`)); + console.log( + '\n These are authorable — `/api/v1/meta/types/:type` serves them and Studio edits\n' + + ' them — but no ledger asks who reads their properties, so an inert key on one is\n' + + ' invisible to CI. `datasource` sat here for its whole life and cost six inert keys\n' + + ' found by hand, two of them security-shaped (#4410, #4465, #4481).\n\n' + + ' Either govern the type (add it to GOVERNED and seed packages/spec/liveness/.json\n' + + ' — see the seeding aid: `tsx check-liveness.mts --dump `), or record the debt in\n' + + " PENDING_GOVERNANCE with a reason AND an issue number. Do not pick the second option\n" + + ' just to get green: an entry with no issue behind it is indistinguishable from never\n' + + ' having looked, which is the state this gate exists to end.', + ); + } + if (report.stalePending.length) { + console.log(`\n✗ ${report.stalePending.length} stale PENDING_GOVERNANCE row(s) — the debt is already paid:`); + report.stalePending.forEach((t: string) => console.log(` ${t}`)); + console.log( + '\n The type is now governed (or no longer registered), so the row claims a debt that\n' + + ' no longer exists and overstates how much coverage work is left. Delete it — same\n' + + ' rot as an orphan ledger row, opposite direction.', + ); + } if (report.orphanEntries.length) { console.log(`\n✗ ${report.orphanEntries.length} ORPHAN ledger row(s) — the property is gone from the schema:`); report.orphanEntries.forEach((s: string) => console.log(` ${s}`)); @@ -418,10 +496,17 @@ if (asJson) { } else if (v.stale.length || v.unverified.length) { console.log(' run with --stale-verification[=days] for the worklist.'); } + const pendingCount = Object.keys(PENDING_GOVERNANCE).length; + if (pendingCount) { + console.log( + `\ncoverage: ${GOVERNED.length} type(s) governed, ${pendingCount} registered type(s) awaiting a ledger ` + + `(${Object.keys(PENDING_GOVERNANCE).sort().join(', ')}) — a worklist, not a merge gate.`, + ); + } if (!failed) { console.log( - '\n✓ all governed-type properties are classified, no ledger row outlives its property, ' + - 'and all bound high-risk proofs resolve.', + '\n✓ all governed-type properties are classified, every registered type is governed or ' + + 'explicitly pending, no ledger row outlives its property, and all bound high-risk proofs resolve.', ); } } diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index a64c7479cf..6703da1ef5 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -131,9 +131,18 @@ const externalSettingsUnknownKeyError = strictUnknownKeyError({ password: '`password` must never be inlined. Put the secret in the secrets store and reference ' + 'it with `credentialsRef` (e.g. `credentialsRef: "secret:warehouse/password"`).', + // #4487 corrected the second half of this line. It used to offer + // `capabilities.readOnly` as the place to "describe the driver" — a key the + // liveness audit found has NO reader (liveness/datasource.json), so an + // author who took the advice believed they had marked a datasource + // non-writable and had not. Same defect as the pre-#4410 `belongsInConfig` + // line documented above, on a property whose whole point is safety: a + // prescription must land somewhere enforced, and `allowWrites` is the only + // write gate there is. readOnly: - '`readOnly` is not an external-settings key. Use `allowWrites: false` here for the ' - + 'datasource-wide gate, or `capabilities.readOnly` to describe the driver.', + '`readOnly` is not an external-settings key. Use `allowWrites: false` here — it is the ' + + 'enforced datasource-wide write gate (checked by the ObjectQL engine before any write ' + + 'to a federated datasource).', }, history: 'Until #4001 these were dropped silently — federation ran on the defaults instead.', });