From 840c6552c6dc17c12e6f57fb0fb874392eabfbf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:16:15 +0000 Subject: [PATCH 1/3] wip(spec): seed connector + analytics_cube liveness ledgers Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- packages/spec/liveness/analytics_cube.json | 152 +++++++ packages/spec/liveness/connector.json | 405 ++++++++++++++++++ .../spec/scripts/liveness/check-liveness.mts | 35 +- 3 files changed, 572 insertions(+), 20 deletions(-) create mode 100644 packages/spec/liveness/analytics_cube.json create mode 100644 packages/spec/liveness/connector.json diff --git a/packages/spec/liveness/analytics_cube.json b/packages/spec/liveness/analytics_cube.json new file mode 100644 index 00000000000..72fc67b4aed --- /dev/null +++ b/packages/spec/liveness/analytics_cube.json @@ -0,0 +1,152 @@ +{ + "type": "analytics_cube", + "_note": "CubeSchema (packages/spec/src/data/analytics.zod.ts). Seeded 2026-09-17 (#18582): the LAST of the three PENDING_GOVERNANCE debts #18133 declared when PR #18581 widened the governance denominator from the registered kinds to `authorableTypes()`; `sharing_rule` was paid first (PR #18587) and `connector` is paid in the same diff as this file, which empties the map. NOT a registered metadata KIND — it is bound in `UNREGISTERED_KIND_SCHEMAS` (#10194) and reaches this walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so the ledger governs it while `listMetadataTypeSchemaTypes()` still does not enumerate it. THE SHAPE FACT THAT DECIDES EVERY ROW BELOW: one Cube shape, THREE producers, one registry. `packages/services/service-analytics/src/cube-registry.ts` names them itself — (1) authored cubes (`defineStack({ analyticsCubes })` / `defineCube()`), threaded by the CLI into `AnalyticsServiceConfig.cubes` and registered by `registerAll`; (2) COMPILED DATASETS (ADR-0021), where `dataset-compiler.ts` MINTS a Cube from a `dataset` document; (3) ad-hoc query inference (`inferCubeFromQuery`). Only (1) is the authoring door this ledger governs, so a key whose only reader sits on path (2) is NOT live here however busy that reader is — that is the #4837 producer rule applied to a shape with three producers, and it is what decides `dimensions.granularities` and `measures.format` below. Every `live` row therefore carries a `producer` naming the CLI threading site: a consumer citation alone would be the `seed.env` shape, where the mechanism was right and nobody supplied the input. #10238 IS NOT PREJUDGED: the PENDING_GOVERNANCE row this file discharges said whether cube authoring is live end-to-end is its own measurement and 'this row does not prejudge it'. This ledger does not answer that question either — it answers the per-key one (who reads this key?), and the answers below are mixed: the query path (`sql`, `measures.sql`/`.type`, `dimensions.sql`/`.type`, `joins.name`) is genuinely consumed, while the caching, access-control and display-annotation keys are not. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f3815): `registerBuiltinPreviews()` in packages/app-shell/src/views/metadata-admin/previews/index.ts registers nineteen types and `analytics_cube` is NOT one of them — this type has no registered metadata-admin preview. Recorded rather than skipped, because 'the type has no registered preview' is the sentence a later sweep needs. What objectui DOES consume is the whole SHAPE: `clientValidation.ts` maps `analytics_cube` to `CubeSchema` itself, and unlike `sharing_rule` it is absent from `AUTHOR_SHAPE_ONLY_TYPES`, so both the CREATE and the EDIT door in metadata-admin refuse a cube this schema rejects. ADR-0054: no row here carries a `proof`, and none is owed — the `analytics` high-risk class binds `dataset/dimensions.dateGranularity` (the dataset door), not this type.", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-analytics/src/cube-registry.ts#register — `this.cubes.set(cube.name, cube)`: the authored name IS the registry key, so it decides what `AnalyticsQuery.cube` can resolve at all; packages/services/service-analytics/src/analytics-service.ts#getMeta emits it as the cube's `name` and as the `${cube.name}.${key}` member id of every measure and dimension served by `GET /api/v1/analytics/meta`; packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql names it in the `INVALID_FIELD` refusal for an undeclared member.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "Identity, not display — `title` is the display half. A second cube registered under the same name silently replaces the first (`register` is a `Map.set`)." + }, + "title": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: cube.title` is the cube's display title on `GET /api/v1/analytics/meta`, the discovery surface a dashboard/report builder picks a cube from; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta answers the identical projection for the in-memory driver, so the value reaches the wire on both execution paths.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "Display-shaped, so the #7131 split settles it: being shown to a human IS the whole of the claimed effect, and there is no second layer where a 'real' consumer would live. The read point is the platform's own analytics discovery endpoint, NOT a metadata-admin preview — this type has none (enumerated in the file note above). `label` is the metric/dimension spelling of the same idea and is declared as an ALIAS of `title` on the cube (`strictObject` aliases), so a mis-spelled `label:` is refused with the corrective name rather than dropped." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Parsed, stored, and read by NOTHING. The `CubeMeta` projection both `getMeta` implementations build (`analytics-service.ts#getMeta`, `memory-analytics.ts#getMeta`) carries `name`, `title`, `measures` and `dimensions` — `description` is not in it, so unlike `title` it never reaches the discovery wire, and no strategy, driver or lint rule reads it. Census: zero reads of `cube.description` across packages/services, packages/drivers, packages/rest and packages/objectql, with `cube.title` as the lit control in the same scan (two hits, both cited on the `title` row above). ⛔ NOT an ADR-0049 retirement candidate on this reading alone: it is an administrative note on an authoring surface whose sibling annotations (`measures.description`, `dimensions.description`) are dead for the same reason and would go with it, and the `position.description` precedent keeps a documentation-shaped key that only documents. What it is NOT is enforced — an author who expects it on a dashboard picker is wrong today." + }, + "sql": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#extractObjectName — `return cube.sql.trim()` is the FROM table of every generated statement and the object whose RLS/tenant read scope is injected (`applyReadScope(this.extractObjectName(cube), …)`); packages/services/service-analytics/src/strategies/objectql-strategy.ts#extractObjectName resolves the same value for the `engine.aggregate` path; packages/services/service-analytics/src/analytics-service.ts#cubeObjects adds `cube.sql.trim()` to the object set the tenant scope is computed over; packages/drivers/driver-memory/src/memory-analytics.ts#extractTableName reads it as the in-memory collection name.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "'Base SQL statement or Table Name' in the describe, and the platform's own aliases (`table`, `sqlTable`) say which half is load-bearing: every consumer treats it as an OBJECT/TABLE name. Authoring it wrong is not silent — an unknown object fails the query." + }, + "measures": { + "children": { + "name": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "The RECORD KEY is the identity, not this field. `measures` is `z.record(z.string(), MetricSchema)`, and every consumer resolves a measure by its key: `analytics-service.ts#getMeta` publishes `${cube.name}.${key}`, `native-sql-strategy.ts#lookupMember` looks the member up in the `cube.measures` bag by key, `memory-analytics.ts` indexes `cube.measures[fieldName]`. Nothing reads the inner `name`. The dataset compiler WRITES it (`dataset-compiler.ts`: `const metric: Metric = { name: m.name, … }; measures[m.name] = metric`) — writing it equal to the key it files the metric under, which is exactly why no reader ever needed it. Authoring a `name` that DISAGREES with the key is the trap: the key wins everywhere and the authored value is inert. ⛔ Not an ADR-0049 deletion candidate as it stands: it is `z.string().regex(…)` and REQUIRED, so removing it breaks every authored cube to delete nothing; the honest repairs are to derive it from the key or to make the disagreement an authoring refusal, and both are decisions rather than sweeps." + }, + "label": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: measure.label` is the human title of each measure on `GET /api/v1/analytics/meta`; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta emits the identical projection.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "Display-shaped and settled by the #7131 split, same as the cube's `title`. `CubeRegistry`'s own header names this read point ('maps every registered cube's measure/dimension `label` onto the `CubeMeta` titles served by GET /api/v1/analytics/meta'). REQUIRED on the schema, so there is no empty state to classify." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Not in the `CubeMeta` measure projection (`{ name, type, title }`) and read nowhere else — the same census, and the same reasoning, as the cube-level `description` row above." + }, + "type": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql — `const wrap = AGGREGATE_SQL[measure.type]` picks the aggregate the column is wrapped in (SUM / COUNT / AVG / …), `CONDITIONAL_AGGREGATE_SQL[measure.type]` picks its conditional form for a scoped measure filter, and `EXPRESSION_METRIC_TYPES.has(measure.type)` is what emits a custom expression UNWRAPPED; an unrecognised type throws rather than substituting `COUNT(*)` (#4157). packages/services/service-analytics/src/analytics-service.ts#getMeta also publishes it as the measure's `type` on `GET /api/v1/analytics/meta`.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "The arithmetic itself: this key decides what number the query returns. `metric-type-coverage.test.ts` pins `AGGREGATE_SQL` ∪ `EXPRESSION_METRIC_TYPES` as an exact partition of the `AggregationMetricType` enum, so no spec-valid value can fall through." + }, + "sql": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveMeasureSql — `measure.sql` is the column (or expression) the aggregate is applied to, and `qualifyAndRegisterJoin(measure.sql, …)` is what lowers a dotted reference into a LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `measure.sql.replace(/^\\$/, '')` as the aggregate field for the `engine.aggregate` path.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "REQUIRED, and the one place a cube author writes physical SQL. `'*'` is special-cased to a bare `*` (the COUNT(*) form); anything with a dot is either a relationship path or a SQL expression, and `IDENTIFIER_PATH` is what tells them apart (#4157)." + }, + "format": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Written by the DATASET compiler, read by nobody — the three-producer fact in the file note decides this row. `dataset-compiler.ts` copies a dataset measure's own `format` onto the minted cube metric (`if (typeof m.format === 'string') metric.format = m.format`), and the value a caller actually receives is threaded from the DATASET measure, not from the cube: `analytics-service.ts` enriches the result's `fields[]` with `if (f.format == null && m.format) f.format = m.format`, where `m` is `dataset.measures[…]`. So the live key is `dataset.measures[].format` (governed in dataset.json), and the cube metric's own `format` is a carbon copy nothing reads back. On the AUTHORING door governed here there is no dataset to copy from, so a hand-written `format:` is inert. ⛔ Retirement is NOT the remedy: the key is the dataset compiler's own output slot on a shared shape, so deleting it from `MetricSchema` would break a live internal write; what is dead is authoring it by hand." + } + } + }, + "dimensions": { + "children": { + "name": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Same shape and same verdict as `measures.name`: `dimensions` is a `z.record`, every consumer resolves by the record KEY (`getMeta` publishes `${cube.name}.${key}`, `lookupMember` indexes the bag, `memory-analytics.ts` reads `cube.dimensions[fieldName]`), and the dataset compiler writes the inner `name` equal to the key it files the dimension under (`dimensions[d.name] = dim`). A `name` that disagrees with its key is inert. Not a deletion candidate for the reason given on `measures.name`." + }, + "label": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-analytics/src/analytics-service.ts#getMeta — `title: dimension.label` is the human title of each dimension on `GET /api/v1/analytics/meta`; packages/drivers/driver-memory/src/memory-analytics.ts#getMeta emits the identical projection.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "Display-shaped, #7131 split, same as `measures.label`. The dataset side of the same field is where #6761 was found (an inline locale map was dropped and the machine name published as a title); a hand-authored cube declares a plain string, so that resolver is not in this path." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Not in the `CubeMeta` dimension projection (`{ name, type, title }`) and read nowhere else — same census as the two `description` rows above." + }, + "type": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#carriesUninterpretableTemporalComparand — `this.lookupMember(cube, member, 'dimension')?.type === 'time' ? 'datetime' : null` decides how a filter comparand is shaped before it is bound as a parameter; packages/services/service-analytics/src/analytics-service.ts#getMeta publishes it as the dimension's `type` on `GET /api/v1/analytics/meta`; packages/services/service-analytics/src/dataset-executor.ts#timeDimensionsOf selects the bucketable dimensions with `compiled.cube.dimensions[d]?.type === 'time'`.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "`time` is the member that carries behaviour (comparand shaping, bucketability); the other members are published and otherwise inert. The dataset-executor citation is on the compiled-dataset path — it is listed because it reads the same key off the same shape, and the authoring-door consumer is the comparand shaping in native-sql-strategy." + }, + "sql": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#resolveDimensionSql — `dim.sql` is the GROUP BY / SELECT column, and `qualifyAndRegisterJoin(dim.sql, …)` lowers a dotted path into the LEFT JOIN chain; packages/services/service-analytics/src/strategies/objectql-strategy.ts#resolveFieldName reads `dim.sql.replace(/^\\$/, '')` as the group-by field for the `engine.aggregate` path.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "REQUIRED. Same dotted-path / expression split as `measures.sql`." + }, + "granularities": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "There IS a reader and an AUTHORED cube cannot reach it — the distinction the file note's three-producer fact exists to make. The one read is `packages/services/service-analytics/src/dataset-executor.ts#granularityOf` (`const cd = compiled.cube.dimensions[name]; … cd.granularities?.length === 1 ? String(cd.granularities[0]) : undefined`), whose argument is a `CompiledDataset` — a shape only `compileDataset()` produces, from a `dataset` document. The writers are the same two non-authoring producers: `dataset-compiler.ts` (`dim.granularities = d.dateGranularity ? [d.dateGranularity] : ['day','week','month','quarter','year']`), `cube-registry.ts#inferFromObject` and `analytics-service.ts#inferCubeFromQuery` (the 5-entry 'all granularities' list). A cube registered through `AnalyticsServiceConfig.cubes` never becomes a `CompiledDataset`, so a hand-authored `granularities:` is read by nothing. The AUTHORABLE key that really drives bucketing is `dataset.dimensions[].dateGranularity` — governed in dataset.json and the ADR-0054 `analytics` high-risk class's bound property. ⛔ ADR-0049 RETIREMENT IS NOT THE REMEDY, and this row says so explicitly so the enforce-or-remove channel does not act on the word `dead`: the key is the dataset compiler's own output channel on a shared shape, so deleting it from `DimensionSchema` would break a live internal path. What is dead is AUTHORING it on a hand-written cube." + } + } + }, + "joins": { + "children": { + "name": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-analytics/src/strategies/native-sql-strategy.ts#qualifyAndRegisterJoin — `const joinTable = cube?.joins?.[alias]?.name ?? alias` is the TABLE the LEFT JOIN is emitted against, which is what makes an alias differ from its object (`account` -> `crm_account`); packages/services/service-analytics/src/strategies/native-sql-strategy.ts#crossFieldComparisonIn resolves `cube.joins?.[alias]?.name ?? alias` for every joined object when it walks the RLS read scopes; packages/services/service-analytics/src/analytics-service.ts#cubeObjects adds every `cube.joins[*].name` to the tenant-scoped object set; packages/services/service-analytics/src/strategies/objectql-strategy.ts#isCrossObjectField resolves the same value for the aggregate path.", + "producer": "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS — the `analytics` entry declares `configKey: 'analyticsCubes'` and the capability resolver threads it into the plugin (`const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? []; arg = { cubes }`); packages/services/service-analytics/src/analytics-service.ts#registerAll (`if (config.cubes) this.cubeRegistry.registerAll(config.cubes)`) is where the authored array becomes the registry every consumer below resolves through. Without this thread an authored cube reaches no reader at all — the `seed.env` shape (#4837).", + "note": "Security-relevant, not cosmetic: the joined object resolved here is what the read scope is computed for, so a wrong name would scope the wrong table. Note the JOIN is keyed by the record key (the alias) — `name` is its TARGET." + }, + "relationship": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Declared with a `.default('many_to_one')` and dispatched on by nothing. The join SQL is SYNTHESISED, not read: `native-sql-strategy.ts#qualifyAndRegisterJoin` emits `LEFT JOIN ON \"\".\"\" = \"\".\"id\"` for every hop, a foreign-key equality that is the same statement whichever cardinality the author declared. Census: `relationship` is WRITTEN by `dataset-compiler.ts` (`relationship: 'many_to_one'`, a constant) and read in neither strategy, neither driver, nor the REST layer; the lit control in the same scan is `cube.joins?.[alias]?.name`, which returns the hits cited on the `name` row above. The authoring trap is real — `one_to_many` parses, changes no SQL, and the aggregate silently keeps the many-to-one arithmetic." + }, + "sql": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "The most expensive dead key on this type: it is REQUIRED, documented as 'Join condition (ON clause)', and the ON clause is synthesised without ever consulting it. `native-sql-strategy.ts#qualifyAndRegisterJoin` builds `ON \"\".\"\" = \"\".\"id\"` from the dotted member path alone; `objectql-strategy.ts` resolves joins through `cube.joins?.[alias]?.name` and lowers them to relationship traversals, with no ON clause at all. So an author who writes a non-FK join condition gets a DIFFERENT query than the one they declared, with a 200 and a plausible number — the `#10298` shape. ⛔ Not a mechanical ADR-0049 deletion: the key is required, so removing it edits every authored cube, and the honest fix is a decision (refuse a `sql` the engine will not honour, or teach the strategies to emit it). Recorded here so the decision has a measurement behind it." + } + } + }, + "refreshKey": { + "children": { + "every": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Pre-aggregation / cache refresh cadence with no engine behind it. Census over the whole repository: `refreshKey` appears in `analytics.zod.ts` (the declaration), in `examples/app-showcase/src/data/analytics/showcase.cube.ts` (an AUTHOR writing it), in the generated authorable-surface artifacts, and in the protocol-18 strictness migration note — and in no consumer. There is no refresh scheduler, no pre-aggregation table and no cache keyed on it anywhere in packages/services, packages/drivers or packages/rest. The showcase authoring it is the evidence that the trap is live rather than theoretical." + }, + "sql": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "The 'SQL to check for data changes' probe of the same unimplemented refresh mechanism — same census as `refreshKey.every`. The block's own `strictObject` history note ('a typo'd `sql` probe left the cube refreshing on nothing') describes a cadence that does not exist either way." + } + } + }, + "public": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "An ACCESS-CONTROL key that gates nothing, which is the worst class this ledger records (a security no-op / false compliance, the reason the gate exists). Declared `z.boolean().default(false)` under the comment 'Access Control'. Census: three sites WRITE it — `analytics-service.ts#inferCubeFromQuery`, `cube-registry.ts#inferFromObject` and `dataset-compiler.ts`, each a literal `public: false` — and the showcase example authors `public: false`; nothing anywhere READS `cube.public`. Access to `/api/v1/analytics/query` is decided by the REST layer's own auth plus the per-object RLS/tenant scope `applyReadScope` injects from `cube.sql` and `cube.joins[].name`, never by this flag, so `public: true` grants nothing and `public: false` withholds nothing. ⛔ Do NOT read the dead verdict as 'the cube is exposed': it is not a hole that was opened, it is a knob that was never wired. ADR-0049 wants a DECISION here rather than a sweep — either enforce it at the query door or remove it — and the removal half is not free, because a `.default(false)` key is present on every parsed cube." + } + } +} diff --git a/packages/spec/liveness/connector.json b/packages/spec/liveness/connector.json new file mode 100644 index 00000000000..c042fd735c8 --- /dev/null +++ b/packages/spec/liveness/connector.json @@ -0,0 +1,405 @@ +{ + "type": "connector", + "_note": "DeclarativeConnectorEntrySchema (packages/spec/src/integration/connector.zod.ts). Seeded 2026-09-17 (#18582) together with `analytics_cube`: the last two of the three PENDING_GOVERNANCE debts #18133 declared when PR #18581 widened the governance denominator to `authorableTypes()` (`sharing_rule` was paid first, PR #18587). Their landing empties that map. NOT a registered metadata KIND — bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. WHAT THE WALKER ACTUALLY RESOLVES, measured rather than assumed: the binding names `DeclarativeConnectorEntrySchema`, and that schema is `ConnectorSchema.superRefine(...)`. In Zod 4 a `superRefine` attaches a CHECK to the same object def rather than wrapping it, so `shapeOf()` returns `ConnectorSchema`'s shape unchanged and the key set walked here is byte-identical to the base's — the ADR-0097 cross-field rules add no key and remove none. The gate therefore cannot tell the two schemas apart; what the binding buys is REFUSALS, which are invisible to the walk and visible only in the `authentication` / `actions` / `triggers` rows below, where they are the whole verdict. THE SHAPE FACT THAT DECIDES EVERY ROW: one schema, TWO doors. This ledger's denominator entry exists because of the AUTHORING doors (`defineStack({ connectors })` and `PUT /api/v1/meta/connector/:name`); the same `ConnectorSchema` is ALSO what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code. So a key can have a real consumer and still do nothing when a metadata author writes it, and every row below says WHICH door its consumer is fed from. The keys an authored entry can reach are exactly the `ConnectorProviderContext` fields plus `name` and `enabled`; everything else in an authored entry is stored, served back by `/meta/connector`, and read by no runtime. That asymmetry is the trap this type carries, and it is recorded per key rather than asserted once. PRIOR MEASUREMENTS RE-VERIFIED, not inherited: the ADR-0087 conversion registry's `connector-field-mapping-transform-removed` entry recorded 'Execution: none — `fieldMappings` is spelled only inside packages/spec' (2026-08-06), the `syncConfig.schedule` retirement recorded '`syncConfig` has no reader outside `packages/spec`' (#16320, 2026-09-10), and `ConnectorTriggerSchema`'s own docblock says 'NOT YET ENFORCED — declared but never read by the runtime (#3197)'. All three were re-run on this checkout and all three still hold; the counts are in the rows. One prior claim FAILED re-verification and is corrected here: a comment in packages/spec/src/conversions/registry.ts asserts that `retryConfig` 'and the timeouts beside it are untouched — they are live'. They are not read anywhere; see those three rows. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f3815): `registerBuiltinPreviews()` registers nineteen types and `connector` is NOT one of them — this type has no registered metadata-admin preview. What objectui DOES consume is (a) the whole SHAPE, via `clientValidation.ts`, which maps `connector` to `DeclarativeConnectorEntrySchema` on BOTH the create and the edit door (it is not strict, so it may judge a stored body), and (b) the RUNTIME registry projection `GET /api/v1/automation/connectors`, from which `connectorsToOptions` reads `name`/`label`/`origin`, `connectorActionsToOptions` reads `actions[].key`/`.label`, and `connectorActionInputSchema` reads `actions[].inputSchema`. Those three are the cross-repo citations below. ADR-0054: no row carries a `proof` and none is owed — no high-risk class binds a `connector/*` path.", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — the authored name keys the desired-set map, is the identity the §4 origin-conflict rule is checked against (`engine.getConnectorOrigin(name) === 'plugin'`), and is FORCED onto the provider's def (`const def = { ...materialization.def, name }`) so discovery and dispatch agree with the metadata; packages/services/service-automation/src/engine.ts#getConnectorDescriptors publishes it as the connector id on `GET /api/v1/automation/connectors`, which is what a `connector_action` flow node names in `connectorConfig.connectorId`.", + "producer": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — the ADR-0097 reconcile: it builds the desired set from the declared `connectors:` items, resolves the provider factory and calls `engine.registerConnector(def, handlers, 'declarative')`. This is the ONLY seam by which an authored entry reaches the connector registry, so it is the producer every `live` row below depends on (#4837): without it a declared connector is a stored document and nothing else.", + "note": "Identity, not display. A duplicate declared name is refused by name (`duplicate declarative connector instance name`), so this key also decides whether a stack boots." + }, + "label": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidenceScope": "cross-repo", + "evidence": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — `label: entry.label ?? name` puts the authored label on `ConnectorProviderContext`; packages/connectors/connector-openapi/src/openapi-provider.ts, packages/connectors/connector-rest/src/rest-provider.ts and packages/connectors/connector-mcp/src/mcp-provider.ts each copy `label: ctx.label` onto the def they return; packages/services/service-automation/src/engine.ts#getConnectorDescriptors then publishes `label: def.label`; objectui @dda8f3815 packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx#connectorsToOptions renders it as the connector picker's option text (`${label} (${name})`) in the flow designer.", + "producer": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — the ADR-0097 reconcile: it builds the desired set from the declared `connectors:` items, resolves the provider factory and calls `engine.registerConnector(def, handlers, 'declarative')`. This is the ONLY seam by which an authored entry reaches the connector registry, so it is the producer every `live` row below depends on (#4837): without it a declared connector is a stored document and nothing else.", + "note": "Display-shaped, so the #7131 split settles it: being shown to a human IS the whole claimed effect. All three shipped provider factories carry it, so this is the one authored display key that survives materialization intact — contrast `icon` and `type` below, which reach the same context object and are dropped." + }, + "type": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-automation/src/engine.ts#getConnectorDescriptors — `type: def.type` is published on `GET /api/v1/automation/connectors`; packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors threads the authored value onto the provider context (`type: typeof entry.type === 'string' ? entry.type : 'api'`).", + "producer": "packages/connectors/connector-openapi/src/openapi-connector.ts (`type: 'api'` in the def literal), packages/connectors/connector-rest/src/rest-connector.ts and packages/connectors/connector-mcp/src/mcp-connector.ts — the def's `type` is supplied by the PLUGIN / provider factory, never carried over from the authored entry.", + "note": "⚠️ THE PRODUCER IS NOT THE AUTHOR, and that is the finding on this row. The authored `type` reaches `ConnectorProviderContext.type` and is then dropped: not one of the three shipped provider factories reads `ctx.type`, and `openapi-connector.ts` hard-codes `type: 'api'` into the def it returns. So on the PLUGIN door the key is live (a plugin writes it, `/automation/connectors` publishes it); on the DECLARATIVE door an authored `type: 'database'` on a provider-bound instance is published as whatever the factory chose. `type` is REQUIRED, so every author writes one. ⛔ Not an ADR-0049 candidate — it is enforced on one of its two doors; what is owed is either a factory that honours `ctx.type` or a declaration that it is provider-owned." + }, + "description": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — `description: entry.description` on the provider context; packages/connectors/connector-openapi/src/openapi-provider.ts and packages/connectors/connector-mcp/src/mcp-provider.ts copy `description: ctx.description` onto the def; packages/services/service-automation/src/engine.ts#getConnectorDescriptors publishes `description: def.description`.", + "producer": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — the ADR-0097 reconcile: it builds the desired set from the declared `connectors:` items, resolves the provider factory and calls `engine.registerConnector(def, handlers, 'declarative')`. This is the ONLY seam by which an authored entry reaches the connector registry, so it is the producer every `live` row below depends on (#4837): without it a declared connector is a stored document and nothing else.", + "note": "Display-shaped (#7131). Two of the three shipped factories carry it — `connector-rest`'s does not, and writes its own fixed sentence instead — so an authored description survives on the openapi and mcp providers and is replaced on rest. The ADR-0097 §3 refusal message also points an author here (\"describe the auth scheme in `description`\"), which makes it the documented home for what `authentication` may not carry." + }, + "icon": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-automation/src/engine.ts#getConnectorDescriptors — `icon: def.icon` is published on `GET /api/v1/automation/connectors`; packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors threads the authored value onto the provider context (`icon: entry.icon`).", + "producer": "packages/connectors/connector-openapi/src/openapi-connector.ts — `icon: config.icon ?? 'globe'`, where `config` is the factory's OWN argument object; the def's icon is supplied by the plugin, not by the authored entry.", + "note": "⚠️ Same split as `type`, and measured the same way: `ConnectorProviderContext.icon` is SET by the materializer and read by none of the three shipped provider factories (census: zero `ctx.icon` reads across packages/connectors, with `ctx.label` — four hits — as the lit control in the same scan). So an authored `icon:` on a declarative instance never reaches `/automation/connectors`, while a plugin-registered def's icon does. Optional, unlike `type`, so the trap is quieter." + }, + "authentication": { + "status": "planned", + "verifiedAt": "2026-09-17", + "evidence": "packages/spec/src/integration/connector.zod.ts#DeclarativeConnectorEntrySchema — `if (entry.authentication && entry.authentication.type !== 'none')` adds a custom issue on every authored entry, descriptor and provider-bound instance alike (#7990): the parse FAILS and the message names `auth: { type, credentialRef }` as the mechanism to use instead. Both authoring doors run it — `stack.zod.ts` binds this schema element-wise to `connectors:`, and `/meta/connector/:name` resolves it through `UNREGISTERED_KIND_SCHEMAS`.", + "producer": "packages/connectors/connector-openapi/src/openapi-connector.ts and packages/connectors/connector-rest/src/rest-connector.ts — `authentication: auth` on the def a provider factory returns, built from the RESOLVED credential, never from an authored value.", + "note": "PLANNED, deliberately not `dead` and deliberately not `live`, on the `api.inputMapping.transform` precedent: the only value an author may write is `{ type: 'none' }`, and anything else is LOUDLY REFUSED rather than silently dropped — an author who inlines a token is told so and told what to do instead, because a published connector row lands whole in `sys_metadata` where it would be cleartext at rest. Not `live`: the accepted value does nothing, and `.authentication` is read by NO consumer anywhere outside packages/spec (census across packages/connectors, packages/services, packages/rest and packages/runtime: every hit is a WRITE — `authentication: auth` or `authentication: { type: 'none' }` — or a TYPE reference `Connector['authentication']`). Not `dead`: the refusal is real and is the whole point of the key's presence on this door. ⛔ Not an ADR-0049 enforce-or-remove candidate: it is already enforced, by refusal." + }, + "provider": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — presence of `provider` is what flips an entry from an inert catalog descriptor to an instance declaration (`if (typeof entry.provider !== 'string' || entry.provider.length === 0) continue`), and its value selects the factory (`engine.getConnectorProvider(provider)`); an unknown provider is a hard, named boot failure listing the installed ones. packages/spec/src/integration/connector.zod.ts#DeclarativeConnectorEntrySchema also branches every cross-field rule on it (`const isInstance = typeof entry.provider === 'string' && …`).", + "producer": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — the ADR-0097 reconcile: it builds the desired set from the declared `connectors:` items, resolves the provider factory and calls `engine.registerConnector(def, handlers, 'declarative')`. This is the ONLY seam by which an authored entry reaches the connector registry, so it is the producer every `live` row below depends on (#4837): without it a declared connector is a stored document and nothing else.", + "note": "The load-bearing key of ADR-0097 and the only one on this type whose absence changes the MEANING of every other key: without it `providerConfig` and `auth` are refused, `actions`/`triggers` become legal, and the entry is audited as a descriptor (#2612) instead of materialized." + }, + "providerConfig": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — `providerConfig: entry.providerConfig ?? {}` on the provider context; packages/connectors/connector-openapi/src/openapi-provider.ts reads `ctx.providerConfig` for `spec` and `baseUrl` (and refuses a non-string `baseUrl` by name), packages/connectors/connector-rest/src/rest-provider.ts requires `providerConfig.baseUrl` and validates `defaultHeaders`, packages/connectors/connector-mcp/src/mcp-provider.ts reads `cfg.transport`; packages/spec/src/integration/connector.zod.ts#DeclarativeConnectorEntrySchema refuses it outright on an entry with no `provider`.", + "producer": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — the ADR-0097 reconcile: it builds the desired set from the declared `connectors:` items, resolves the provider factory and calls `engine.registerConnector(def, handlers, 'declarative')`. This is the ONLY seam by which an authored entry reaches the connector registry, so it is the producer every `live` row below depends on (#4837): without it a declared connector is a stored document and nothing else.", + "note": "Deliberately `z.record(z.string(), z.unknown())` — validated by the provider factory, not by this schema (ADR-0023). Not a container as far as this walk is concerned: a record of `unknown` has no child shape, so there is no subtree to drill and nothing is riding on a blanket verdict." + }, + "auth": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — `auth = await this.resolveInstanceAuth(entry.auth, resolver, name, provider)` resolves the declared `credentialRef` through the secrets/env layer and puts the RESOLVED credential on the provider context; a failure there degrades or fails the instance by name (ADR-0097 §3). packages/connectors/connector-rest/src/rest-provider.ts, packages/connectors/connector-openapi/src/openapi-provider.ts and packages/connectors/connector-mcp/src/mcp-provider.ts each read `ctx.auth` and use it to sign the upstream call. packages/spec/src/integration/connector.zod.ts#DeclarativeConnectorEntrySchema refuses it on an entry with no `provider`.", + "producer": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — the ADR-0097 reconcile: it builds the desired set from the declared `connectors:` items, resolves the provider factory and calls `engine.registerConnector(def, handlers, 'declarative')`. This is the ONLY seam by which an authored entry reaches the connector registry, so it is the producer every `live` row below depends on (#4837): without it a declared connector is a stored document and nothing else.", + "note": "The live half of the pair whose other half (`authentication`) is refused: this key is how an authored connector carries a credential at all, and it carries a REFERENCE, never the secret." + }, + "actions": { + "children": { + "key": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidenceScope": "cross-repo", + "evidence": "packages/services/service-automation/src/engine.ts#registerConnector — `if (typeof handlers[action.key] !== 'function') throw` refuses a def whose declared action has no handler, so the key is the contract between declaration and implementation; packages/services/service-automation/src/engine.ts#resolveConnectorActionEffect looks the action up by `a.key === actionId`, and `getConnectorActionHandler` dispatches `handlers[actionId]`; packages/services/service-automation/src/engine.ts#getConnectorDescriptors publishes it; objectui @dda8f3815 packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx (`connectorActionsToOptions`) and .../connector-input-fields.ts#connectorActionInputSchema both resolve an action by `a.key === actionKey`.", + "producer": "packages/connectors/connector-mcp/src/mcp-connector.ts — `actions: selected.map((tool) => ({ key: tool.name, … }))` derives the action set from the upstream `tools/list`; packages/connectors/connector-openapi/src/openapi-connector.ts derives one action per OpenAPI operation. ⚠️ THE METADATA AUTHOR IS NOT A PRODUCER HERE: packages/spec/src/integration/connector.zod.ts#DeclarativeConnectorEntrySchema REFUSES `actions` on a provider-bound instance (ADR-0097 §5 — the provider derives them), and on a descriptor (no `provider`) the entry never reaches the registry at all.", + "note": "Live on the PLUGIN/provider door, inert-and-audited on the authoring door — the two-door fact in the file note, at its sharpest. What an authored `actions[]` on a descriptor DOES do is trigger a boot warning: packages/services/service-automation/src/plugin.ts#findInertDeclaredConnectors returns every declared connector that has at least one action, no runtime registration and no `enabled: false` opt-out, and `auditDeclaredConnectors` warns once per boot naming them and the remedy. That warning is the #2612 contract made audible; it is not dispatch." + }, + "label": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidenceScope": "cross-repo", + "evidence": "packages/services/service-automation/src/engine.ts#getConnectorDescriptors — `label: a.label` on each projected action; objectui @dda8f3815 packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx (`connectorActionsToOptions`, fed by `useConnectorActionOptions` off `GET /api/v1/automation/connectors`) renders it as the action picker's option text in the flow designer.", + "producer": "packages/connectors/connector-mcp/src/mcp-connector.ts — `label: titleize(slugify(tool.name))`; packages/connectors/connector-openapi/src/openapi-connector.ts derives one per operation. Same two-door caveat as `actions.key`.", + "note": "Display-shaped and settled by the #7131 split — the designer's action picker IS the claimed effect. REQUIRED, so there is no empty state." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Projected onto the wire and read by nobody. `engine.ts#getConnectorDescriptors` copies `description: a.description` into the `GET /api/v1/automation/connectors` payload, and no consumer in either repo reads it back: objectui's three connector-descriptor consumers take `name`/`label`/`origin` (`connectorsToOptions`), `key`/`label` (`connectorActionsToOptions`) and `key`/`inputSchema` (`connectorActionInputSchema`), and nothing in this repo reads a projected action's description. The lit control for that scan is `inputSchema` in the same projection, which IS read (see that row). Being on a machine-readable surface is not a consumer — the `view.label` precedent." + }, + "inputSchema": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidenceScope": "cross-repo", + "evidence": "packages/services/service-automation/src/engine.ts#getConnectorDescriptors — `inputSchema: a.inputSchema` on the projected action; objectui @dda8f3815 packages/app-shell/src/views/metadata-admin/inspectors/connector-input-fields.ts#connectorActionInputSchema finds the committed action in that payload and returns its `inputSchema`, and `FlowNodeInspector.tsx` types the connector node's whole Input section from it (`connectorInputFields(connectorActionInputSchema(...))`, objectui #4305) — an action that declares none falls back to the generic key/value repeater, which is the observable difference.", + "producer": "packages/connectors/connector-mcp/src/mcp-connector.ts — the MCP tool's own JSON Schema is passed straight through ('The MCP inputSchema is already JSON Schema'); packages/connectors/connector-openapi/src/openapi-connector.ts derives it from the operation's parameters. Same two-door caveat as `actions.key`.", + "note": "The one action key with a structural (not display) consumer, and it is cross-repo: the designer builds a typed form from it. Declared `z.record(z.string(), z.unknown())` — JSON Schema by convention, unvalidated here — so it has no child shape and nothing rides on a blanket verdict." + }, + "outputSchema": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "The twin of `inputSchema`, projected the same way and consumed by nothing — which is exactly what makes this row falsifiable rather than a guess. `engine.ts#getConnectorDescriptors` publishes `outputSchema: a.outputSchema`; the census over both repos finds no reader, while the identically-projected `inputSchema` one line above returns objectui's `connectorActionInputSchema` in the same scan. A flow node's downstream references are typed from the RUN's actual output (`nodeOutputRefs`), not from this declaration. ⛔ Not an ADR-0049 sweep candidate on this reading: the honest repair is to type the node's output refs from it, which is a feature decision, not a deletion." + }, + "effect": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-automation/src/builtin/connector-nodes.ts — `const effect = engine.resolveConnectorActionEffect(cfg.connectorId, cfg.actionId)` is resolved BEFORE dispatch and decides what the run reports: `write` → `{ acted: 1 }`, `read` → `{ acted: 0 }`, undeclared → `{ unmeasuredEffect: true }`, so the declaration is what lets a connector-driven flow contribute a signal to the broken-sweep alert at all (#4395); packages/services/service-automation/src/engine.ts#resolveConnectorActionEffect reads it off the stored def (`.def.actions?.find((a) => a.key === actionId)?.effect`); packages/services/service-automation/src/engine.ts#getConnectorDescriptors also carries it to the designer.", + "producer": "packages/spec/src/integration/connector.zod.ts#ConnectorActionSchema names the producer itself: 'This is the ONLY producer of the effect' — whoever writes the def writes it, and both the hand-registered path and the ADR-0097 materialization path read it from there. Same two-door caveat as `actions.key`.", + "note": "Optional on purpose: a connector written before the key keeps reporting `unmeasuredEffect`, so declaring it is a strict improvement rather than a migration. The empty state is therefore meaningful and benign, which is why it is not `authorWarn`ed." + } + } + }, + "triggers": { + "children": { + "key": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "The whole trigger sub-surface is declared and unread, and the schema says so itself: `ConnectorTriggerSchema`'s docblock reads '⚠️ NOT YET ENFORCED — declared but never read by the runtime (#3197). AutomationEngine.registerConnector ignores a connector's triggers … No polling loop or webhook receiver is driven by these definitions.' Re-verified on this checkout: `registerConnector` walks `parsed.actions` only, and the `this.triggers` map in engine.ts is the FLOW trigger-type registry (`schedule`/`record`/…), a different vocabulary that no connector trigger ever enters — that near-miss is the reason this row spells the control out. The one runtime touch of the key is a REFUSAL: packages/spec/src/integration/connector.zod.ts#DeclarativeConnectorEntrySchema rejects `triggers` on a provider-bound instance (ADR-0097 §5). On a descriptor it parses and evaporates. ⛔ ADR-0049 is owed a decision here rather than a sweep — either a polling/receiver engine or a retirement of the whole `ConnectorTriggerSchema` shape; the sub-keys below are dead for this one reason and are not repeated per row." + }, + "label": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `triggers.key` — nothing reads a connector trigger." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `triggers.key`." + }, + "type": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `triggers.key`. `polling` and `webhook` name two engines that do not exist for connectors: no polling loop reads `intervalSeconds`, and the webhook dispatcher is driven by `sys_webhook` rows materialized from the TOP-LEVEL `webhooks:` collection (see the `webhooks` subtree below)." + }, + "intervalSeconds": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `triggers.key`. Renamed from `interval` by the protocol-18 conversion (#15680/#14478) so the unit lives in the key name — a rename that made the declaration honest without making it enforced." + }, + "interval": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "REMOVED (#15680, ruling B on #14478) — tombstoned at the schema with `retiredKey`, which carries the prescription and makes authoring it both a tsc error and a parse error; sources are renamed by the protocol-18 conversion. The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Use `intervalSeconds`; the value (seconds) is unchanged. The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorTriggerSchema." + } + } + }, + "syncConfig": { + "children": { + "strategy": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "`syncConfig` has NO reader outside packages/spec, and this ledger re-measured it rather than inheriting the claim: the only non-spec occurrence of the word in packages/ or examples/ is a prose comment in packages/qa/dogfood/test/expression-conformance.ledger.ts. No engine performs a connector-attached sync — no strategy dispatch, no direction dispatch, no batching, no conflict resolution. The same finding is recorded at the schema by the #16320 retirement of `syncConfig.schedule` ('nothing ever evaluated it'), which deleted the cron slot outright and left the rest of the block standing. ⛔ ADR-0049 owes this subtree a DECISION, not a mechanical sweep: it is seven keys of a declared capability the docs advertise (packages/spec/docs/SYNC_ARCHITECTURE.md ticks 'Conflict Resolution: multiple strategies'), so retiring it is a product call. The sub-keys below are dead for this one reason." + }, + "direction": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `syncConfig.strategy` — no connector-attached sync engine exists." + }, + "realtimeSync": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `syncConfig.strategy`. The `webhooks` subtree below is the mechanism this flag claims to enable, and it is dead on this type too." + }, + "timestampField": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `syncConfig.strategy` — nothing tracks a last-sync watermark for a connector." + }, + "conflictResolution": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `syncConfig.strategy`. Its default (`latest_wins`) is the kind of value that reads as a configured policy and resolves nothing." + }, + "batchSize": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `syncConfig.strategy` — no bulk path reads it." + }, + "deleteMode": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `syncConfig.strategy`. `soft_delete` by default, applied by nothing." + }, + "filters": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `syncConfig.strategy`. Declared `z.record(z.string(), z.unknown())`, so it is not a container for this walk and no subtree rides on this verdict." + } + } + }, + "fieldMappings": { + "children": { + "source": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Re-verified, not inherited: the ADR-0087 conversion registry's `connector-field-mapping-transform-removed` entry measured (2026-08-06) that '`fieldMappings` is spelled only inside packages/spec — the four connector packages, the automation engine, REST and objectui never read it'. That still holds on this checkout: zero occurrences of `fieldMappings` anywhere in packages/ or examples/ outside packages/spec. A connector field mapping is parsed, stored, and moves no value. ⛔ Same disposition as `syncConfig`: a decision, not a sweep — the whole L3 sync layer it belongs to is declared and unbuilt. Sub-keys below are dead for this one reason." + }, + "target": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `fieldMappings.source`." + }, + "transform": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "REMOVED (#5552, ADR-0049) — tombstoned on the shared base `FieldMappingSchema` with `retiredKey`, which retires all three authorable spellings at once; the whole five-member `FieldMappingTransform` union went with it, because no runtime executed any of them. The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Value conversion belongs on a surface that runs it: the import mapping's own `transform` (`data/mapping.zod.ts`), applied row by row by the REST import path and recorded live in mapping.json." + }, + "defaultValue": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `fieldMappings.source`." + }, + "dataType": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `fieldMappings.source`. SYNC_ARCHITECTURE.md's corrected note says an L3 mapping 'declares `dataType` and `syncMode` and performs no value transformation' — true about the transform half, and this row records that the declaration half is not read either." + }, + "required": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `fieldMappings.source`. A `.default(false)` that no validation consults." + }, + "syncMode": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `fieldMappings.source`. `read_only` / `write_only` / `bidirectional` gate nothing — there is no sync to gate." + } + } + }, + "webhooks": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "A connector's NESTED webhook array is not the collection the dispatcher reads, and nothing else reads it either. `bootstrapDeclaredWebhooks` (packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts) materializes `sys_webhook` rows from `readDeclared(engine, metadataService, 'webhook')` — metadata ITEMS of type `webhook`, which the decomposition registers from the TOP-LEVEL `webhooks:` stack collection (`METADATA_ARRAY_KEYS` in packages/objectql/src/engine.ts, and `webhooks: 'webhook'` in packages/metadata/src/plugin.ts). `connectors: 'connector'` in that same map registers a connector entry WHOLE, so the array nested inside it never becomes a `webhook` item and never reaches the materializer. Census: the only `.webhooks` reads outside packages/spec are of `stack.webhooks` — packages/lint/src/validate-functional-completeness.ts (`for (const hook of entriesOf(stack.webhooks))`) and the bootstrap's own docblock — with no read of a connector's own array anywhere. ⚠️ That docblock says it materializes each 'stack/connector-authored webhook'; the phrase is not backed by its code on this checkout, and it is what makes this key look live. ⛔ ADR-0049 owes a decision rather than a sweep — either decompose a connector's webhooks into `webhook` items, or retire the key. WHY ONE VERDICT COVERS THE SUBTREE, declared rather than assumed: the coordinate is recorded in scripts/liveness/undrilled-containers.baseline.json. `WebhookConfigSchema` is `WebhookSchema.extend({ events, signatureAlgorithm })`, so a `deferred` row pointing at the governed `webhook` type would be refused by the gate's key-set EQUALITY check — correctly, because the extension adds two keys the target does not classify. Drilling would mean writing 21 child rows of which 8 are the ADR-0010 protection envelope the gate auto-classifies `live` everywhere else and 13 would repeat this one sentence: fabricated granularity over a container that is dead as a whole. The recorded row is the honest form, and it leaves the baseline as soon as the decision above is taken." + }, + "rateLimitConfig": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "REMOVED in @objectstack/spec 17.0.0 (#4911, ADR-0049 D2) — the entire `ConnectorRateLimitConfig` / `RateLimitStrategy` shape went, not just this key, because no outbound rate-limiting engine ever existed: the platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests. Tombstoned with `retiredKey`, which is why the key is still in the walked shape (the `rls.priority` precedent) and why authoring it is both a tsc error and a parse error. ⛔ Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the provider or upstream gateway. The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorSchema." + }, + "retryConfig": { + "children": { + "strategy": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "⚠️ THIS ROW CORRECTS A PRIOR IN-REPO CLAIM. A comment in packages/spec/src/conversions/registry.ts (the `connector-rate-limit-config-removed` entry) states '`retryConfig` and the timeouts beside it are untouched — they are live'. Measured on this checkout: the word `retryConfig` does not occur anywhere in packages/ or examples/ outside packages/spec — zero files. Nothing retries a connector call on a strategy, a backoff, a jitter or a status-code list; a provider factory's handler makes one fetch. The claim was about what the RATE-LIMIT retirement did not touch, and it was read as a liveness verdict it never measured. ⛔ ADR-0049 owes a decision, not a sweep: `retryConfig` is the documented answer for a rate-limited upstream (SYNC_ARCHITECTURE.md points an author here because its `retryableStatusCodes` default includes 429), so retiring it removes the only advice the docs give. Sub-keys below are dead for this one reason." + }, + "maxAttempts": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `retryConfig.strategy` — no retry loop reads it." + }, + "initialDelayMs": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `retryConfig.strategy`." + }, + "maxDelayMs": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `retryConfig.strategy`." + }, + "backoffMultiplier": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `retryConfig.strategy`." + }, + "retryableStatusCodes": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `retryConfig.strategy`. Its `[408, 429, 500, 502, 503, 504]` default is the value SYNC_ARCHITECTURE.md cites when it tells an author this is the answer for a 429 — a default that is read by nothing." + }, + "retryOnNetworkError": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `retryConfig.strategy`." + }, + "jitter": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `retryConfig.strategy`." + } + } + }, + "connectionTimeoutMs": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "⚠️ Write-only, and the same prior claim corrected on `retryConfig.strategy` covers it ('the timeouts beside it … are live'). Every occurrence outside packages/spec is a WRITE of the literal 30000 into a def so the object satisfies the post-parse `Connector` type — packages/connectors/connector-{mcp,openapi,rest,slack} and the degraded-husk literal in packages/services/service-automation/src/plugin.ts — and the comment above each says exactly that ('Defaulted by ConnectorSchema; set explicitly so the literal satisfies the (post-parse) Connector output type'). No fetch, transport or handler reads it, so an authored timeout does not bound any call. The descriptor-only contract (#2612) is the other half of why this is inert on the authoring door: a `connectors:` entry with no `provider` never reaches the registry at all, and a provider-bound one contributes only `name`/`label`/`description`/`icon`/`type`/`providerConfig`/`auth` through `ConnectorProviderContext` — the rest of the entry is not carried anywhere." + }, + "requestTimeoutMs": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Write-only, identical census and identical five write sites as `connectionTimeoutMs` — see that row. The two are declared with the same bounds and the same 30000 default and are read by nothing; a connector call is unbounded whatever an author writes." + }, + "status": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Declared `ConnectorStatusSchema` with a `.default('inactive')`, and dispatched on by nothing — no consumer reads `def.status` in this repo, and the runtime's answer to 'can this connector be dispatched?' is a DIFFERENT field: `RegisteredConnector.state` (`ready` / `degraded`, #3017), which `getConnectorDescriptors` publishes as `state` and which no authored value can set. The two names one letter apart on one wire payload are the trap: `status: 'active'` on an authored entry neither enables nor advertises anything, while `state` — the field a reader will actually see — is computed. The keys that DO decide participation are `enabled` (materialization + the #2612 audit opt-out) and `provider`. ⛔ ADR-0049 owes a decision: retire it, or make it the authored half of the dispatchability answer." + }, + "enabled": { + "status": "live", + "verifiedAt": "2026-09-17", + "evidence": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — `if (bound.enabled === false) continue` keeps the instance out of the desired set, and an instance that WAS materialized and is then disabled is torn down (`dematerializeConnector`) and dropped from the degraded-retry map, so `false` withdraws a live connector rather than merely not creating one; packages/services/service-automation/src/plugin.ts#findInertDeclaredConnectors treats `enabled: false` as the explicit opt-out that marks a deliberate catalog-only descriptor and silences the #2612 boot warning.", + "producer": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — the ADR-0097 reconcile: it builds the desired set from the declared `connectors:` items, resolves the provider factory and calls `engine.registerConnector(def, handlers, 'declarative')`. This is the ONLY seam by which an authored entry reaches the connector registry, so it is the producer every `live` row below depends on (#4837): without it a declared connector is a stored document and nothing else.", + "note": "One of exactly two authored keys (with `provider`) that decide whether an entry participates at all, and the only one whose `false` is load-bearing in TWO directions — it removes a materialized connector AND suppresses an audit warning. Its `.default(true)` is why an omitted value is the participating one." + }, + "errorMapping": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "RETIRED (ADR-0049 enforce-or-remove) — eleven authorable keys (`ErrorMappingConfig` ×4, `ErrorMappingRule` ×7) that nothing in the tree ever read, one of them spelled `userMessage`, the name of the LIVE API-error channel, so writing a rule here validated, published and showed nobody anything. Tombstoned with `retiredKey` because `ConnectorSchema` is not `.strict()` and a plain delete would be a silent strip (ADR-0104); the tombstone is inherited by `DeclarativeConnectorEntrySchema`, so `stack.connectors[]` and `/meta/connector` refuse it too. The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Registered as `integration/Connector:errorMapping` and `integration/DeclarativeConnectorEntry:errorMapping` in `RETIRED_KEYS_BY_MAJOR[18]`; sources are rewritten by the D2 conversion `connector-error-mapping-removed`. The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorSchema." + }, + "health": { + "children": { + "healthCheck": { + "children": { + "enabled": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "No connector health-check loop exists. Census: the only `healthCheck` occurrences outside packages/spec are the KERNEL's own plugin health contract (`packages/core/src/health-monitor.ts`, `PluginHealthCheck`) — a different shape on a different subject — and nothing in packages/connectors or packages/services/service-automation polls a connector endpoint, counts consecutive failures, or acts on a threshold. SYNC_ARCHITECTURE.md ticks '✅ Monitoring: Health checks, metrics, logging' at L3; that tick is not backed on this surface. ⛔ ADR-0049 owes a decision rather than a sweep. The sub-keys below are dead for this one reason." + }, + "intervalMs": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.healthCheck.enabled` — nothing schedules the probe." + }, + "timeoutMs": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.healthCheck.enabled`." + }, + "endpoint": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.healthCheck.enabled` — no request is ever made to it." + }, + "method": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.healthCheck.enabled`." + }, + "expectedStatus": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.healthCheck.enabled`." + }, + "unhealthyThreshold": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.healthCheck.enabled`." + }, + "healthyThreshold": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.healthCheck.enabled`." + } + } + }, + "circuitBreaker": { + "children": { + "enabled": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "No circuit breaker exists for connectors. Census: nothing outside packages/spec reads `circuitBreaker` except the protocol-18 rename conversion in packages/spec/src/conversions/registry.ts, which rewrites the key's NAME and is not a consumer. No state machine opens, half-opens or closes anything, and no call path consults a breaker before dispatching a connector action. The nearest real mechanism is the ADR-0097 DEGRADED instance (#3017) — a husk registered with `state: 'degraded'` and a backoff retry — which is driven by materialization failures and reads none of these keys. ⛔ ADR-0049 owes a decision rather than a sweep. Sub-keys below are dead for this one reason." + }, + "failureThreshold": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.circuitBreaker.enabled`." + }, + "resetTimeoutMs": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.circuitBreaker.enabled`." + }, + "halfOpenMaxRequests": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.circuitBreaker.enabled`." + }, + "monitoringWindowMs": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.circuitBreaker.enabled`. Renamed from `monitoringWindow` by the protocol-18 conversion (#15680/#14478) so the unit lives in the key name — an honesty fix to a declaration that is still unread." + }, + "monitoringWindow": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "REMOVED (#15680, ruling B on #14478) — tombstoned at the schema with `retiredKey`, which carries the prescription and makes authoring it a tsc error and a parse error; sources are renamed by the protocol-18 conversion `connector-duration-unit-suffixes`. The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Use `monitoringWindowMs`; the value (milliseconds) and the 60000 default are unchanged. The tombstone is packages/spec/src/integration/connector.zod.ts#CircuitBreakerConfigSchema." + }, + "fallbackStrategy": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "Dead for the one reason recorded on `health.circuitBreaker.enabled`. `cache` / `default_value` / `error` / `queue` name four behaviours none of which is implemented anywhere — the shape this ledger exists to find." + } + } + } + } + }, + "metadata": { + "status": "dead", + "verifiedAt": "2026-09-17", + "note": "'Custom connector metadata' — an open `z.record(z.string(), z.unknown())` bag that is stored with the entry and read by nothing: it is not in the `getConnectorDescriptors` projection, so it does not reach `GET /api/v1/automation/connectors`, and no provider factory, handler or lint rule consults it. ⛔ NOT an ADR-0049 retirement candidate: an untyped extension bag is a legitimate escape hatch whose whole contract is that the platform does not interpret it, so 'no consumer' is its specification rather than a defect. Recorded so the next sweep does not have to re-decide that. Not a container for this walk (a record of `unknown` has no child shape)." + } + } +} diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index baa1f1b9a47..5e6cf3542a5 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -263,7 +263,7 @@ const ledgerRoot = ledgerRootArg // 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', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'seed', 'translation', 'validation', 'api', 'capability', 'qa', 'manifest', 'crud_endpoints', 'metadata_endpoints', 'batch_endpoints', 'route_generation', 'realtime_subscription', 'sharing_rule']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'seed', 'translation', 'validation', 'api', 'capability', 'qa', 'manifest', 'crud_endpoints', 'metadata_endpoints', 'batch_endpoints', 'route_generation', 'realtime_subscription', 'sharing_rule', 'connector', 'analytics_cube']; // Authorable metadata types that are NOT yet governed — the coverage ratchet. // @@ -301,25 +301,20 @@ const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'po // ⛔ These rows are NOT a licence to leave them: the direction of travel is out // of this map, exactly as it was for the nine. // -// [#18582] One of the three is PAID: `sharing_rule` moved into GOVERNED with -// packages/spec/liveness/sharing_rule.json. The two left are `connector` and -// `analytics_cube`, and the card that owes them is still open — the sentence -// above is the whole reason this note updates the count instead of leaving a -// stale "three" standing over a map of two. -const PENDING_GOVERNANCE: Record = { - connector: - 'Authored via `stack.connectors[]` and `PUT /api/v1/meta/connector/:name` ' - + '(#6245 bound DeclarativeConnectorEntrySchema for exactly those two doors), and ' - + 'enrolled into this gate\'s denominator by #18133 — before which it was invisible ' - + 'to the ratchet rather than covered by it. No ledger walked it yet: seed ' - + 'packages/spec/liveness/connector.json (#18133).', - analytics_cube: - 'Authored via `stack.analyticsCubes[]` and `PUT /api/v1/meta/analytics_cube/:name` ' - + '(#10194). Whether that authoring is live end-to-end is its own measurement ' - + '(#10238) and this row does not prejudge it — the debt recorded here is only that ' - + 'no ledger asks who reads the cube\'s properties. Enrolled by #18133; seed ' - + 'packages/spec/liveness/analytics_cube.json.', -}; +// [#18582] ALL THREE ARE PAID and this map is now EMPTY — its intended steady +// state, reached in two rounds: `sharing_rule` first (PR #18587), then +// `connector` and `analytics_cube` together, each with a ledger under +// packages/spec/liveness/. Kept rather than deleted, for the reason the ratchet +// paragraph above gives: the map is how the NEXT authorable type that arrives +// ungoverned gets a declared debt instead of silence, and `report.stalePending` +// already fails a row that outlives its debt, so an empty map costs nothing and +// removing it would take the channel with it. ⛔ An entry added here is a debt +// with an issue number and a direction of travel, never a way to silence the +// gate on a type somebody just registered — read the #18133 note above before +// writing one, and note that "the map is empty" is a fact about the ratchet, not +// a claim that every governed type is fully `live`: what each ledger measured is +// in the ledger. +const PENDING_GOVERNANCE: Record = {}; // Spec-only override: governed types whose canonical schema is NOT (yet) in the // metadata-type registry, so they can't be resolved via getMetadataTypeSchema. From 25fefdecd7b521f13279601b8897129dd8a16892 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:18:58 +0000 Subject: [PATCH 2/3] feat(spec): govern connector + analytics_cube in the liveness ledger Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- ...nnector-analytics-cube-liveness-ledgers.md | 15 ++++++++++ docs/qa/platform-checklist/coverage.json | 28 ++++++++++++++----- packages/spec/liveness/README.md | 23 +++++++++------ packages/spec/liveness/state-counts.md | 4 ++- .../undrilled-containers.baseline.json | 3 +- 5 files changed, 55 insertions(+), 18 deletions(-) create mode 100644 .changeset/18582-connector-analytics-cube-liveness-ledgers.md diff --git a/.changeset/18582-connector-analytics-cube-liveness-ledgers.md b/.changeset/18582-connector-analytics-cube-liveness-ledgers.md new file mode 100644 index 00000000000..8422f3f3078 --- /dev/null +++ b/.changeset/18582-connector-analytics-cube-liveness-ledgers.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": patch +--- + +`liveness/connector.json` and `liveness/analytics_cube.json` — the last two governance debts the liveness ratchet declared are paid, so `PENDING_GOVERNANCE` is empty and every authorable metadata type now has a ledger (#18582). + +The ledgers ship inside this package, so these are the files an upgrading reader greps to learn whether a key they are about to author does anything. Both types are authored through real doors — `defineStack({ connectors })` / `defineStack({ analyticsCubes })` and `PUT /api/v1/meta/{connector,analytics_cube}/:name` — and neither had ever been walked: they were in neither `GOVERNED` nor `PENDING_GOVERNANCE` until #18133 widened the denominator, so their silence read as "nothing to report". + +- **`connector` — 74 properties: 20 `live`, 1 `planned`, 53 `dead`.** One schema, two doors: the ledger's entry exists for the AUTHORING doors, while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a plugin or an ADR-0097 provider factory builds in code. The keys an authored entry can actually reach are the `ConnectorProviderContext` fields plus `name` and `enabled`; `type` and `icon` reach that context and are dropped by all three shipped provider factories. The 53 dead are four declared subsystems with no engine — `syncConfig`, `fieldMappings`, `retryConfig`, `health` — plus `triggers` (the schema's own docblock already said so, #3197), the connector's nested `webhooks`, `status`, both timeouts, and four `retiredKey` tombstones. `authentication` is `planned`: refused outright by ADR-0097 §3, never ignored. +- **`analytics_cube` — 29 properties: 17 `live`, 12 `dead`.** The query path is genuinely consumed (`sql` is both the FROM table and the object whose RLS read scope is injected; `measures.type` picks the aggregate; `joins[].name` the joined table). What is not: the caching block (`refreshKey`), the `public` access flag that gates nothing, `joins[].relationship` and the REQUIRED `joins[].sql` — the ON clause is synthesised as a foreign-key equality and an authored one is never consulted — and the inner `name` on each of `measures`/`dimensions`, where the record key is the identity. #10238 (is cube authoring live end to end?) is a separate measurement and is not prejudged here. +- **Two prior in-repo claims were falsified and are corrected in the ledgers.** A comment in `src/conversions/registry.ts` says `retryConfig` "and the timeouts beside it are untouched — they are live"; the word does not occur outside `packages/spec` at all. And `bootstrapDeclaredWebhooks` documents itself as materializing each "stack/connector-authored webhook", while its source is `readDeclared(…, 'webhook')` — metadata items the decomposition registers from the top-level `webhooks:` collection, which a connector's nested array never becomes. + +No schema changed and no verdict moved on an existing ledger: `check:liveness` walks two more types and reports the same 583 repo-local evidence paths resolving, with 39 governed types indexed by the README table. + +Clause-②: no diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json index dc49f828c00..b1972dcf38f 100644 --- a/docs/qa/platform-checklist/coverage.json +++ b/docs/qa/platform-checklist/coverage.json @@ -19,6 +19,11 @@ "ai.console-ai-surface-gating" ] }, + "analytics_cube": { + "items": [ + "dashboards.cube-query" + ] + }, "api": { "items": [ "api-backend.declarative-endpoint-execution", @@ -50,6 +55,22 @@ "platform-core.docs-portal-render" ] }, + "capability": { + "items": [ + "access-security.capability-declaration-lifecycle", + "ai.console-ai-surface-gating", + "platform-core.marketplace-install-local-lifecycle" + ] + }, + "connector": { + "items": [ + "integration-system.connector-declarative-boot", + "integration-system.connector-auth-kind-application", + "integration-system.connector-descriptor-audit", + "integration-system.connector-degraded-recovery", + "automation.connector-dispatch-matrix" + ] + }, "crud_endpoints": { "items": [ "api-backend.rest-crud-config-contract", @@ -304,13 +325,6 @@ "items": [ "integration-system.webhook-lifecycle" ] - }, - "capability": { - "items": [ - "access-security.capability-declaration-lifecycle", - "ai.console-ai-surface-gating", - "platform-core.marketplace-install-local-lifecycle" - ] } } } diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index f133b06265a..0a5e566480b 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -816,7 +816,7 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type RecordDetailView had been gating the History tab on it the whole time (#2707). 4. Add the type to `GOVERNED`; confirm the gate is green. -## Current state — 37 governed types (complete registry coverage) +## Current state — 39 governed types (complete registry coverage) > **This heading is now checked** (#7257). `check:liveness` reconciles the table > against `GOVERNED` in both directions — a governed type with no row fails, a row @@ -938,6 +938,8 @@ marker where the Notes cell goes, never a guess at what belongs there. | route_generation | seeded 2026-09-02 (#14369) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is deliberately not enrolled: its consumption seam is still validate-only and is the subject of its own card, so a census of it would record a half that is about to move. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 6 = every key it has, and that is the finding: `routes` is parsed, defaulted and normalized into `this.config.routes`, and nothing ever reads it back. `excludeObjects: ['sys_log']` excludes nothing, `nameTransform: 'plural'` still mounts every route under the raw object name, and the per-object `overrides` record (drilled to `enabled` / `basePath` / `operations`) turns nothing on or off. ⚠️ The `overrides` hits in `packages/rest/src` are a REQUEST BODY and a test builder — different keys with the same name. This is the one member of the family with a customer-visible limb: `RestServerConfigSchema`'s own `@example` advertises `routes: { excludeObjects: ['system_log'] }`, so the published prose promises a capability the runtime does not deliver (Prime Directive #10). Fixing that example belongs to whichever enforce-or-remove limb the key lands on — `routes.*` reads as designed-but-never-wired, so enforcing it is real work in route generation that changes the mounted surface, and no dev agent decides that **#14691 RETIRED all six (2026-09-03, ADR-0049)**: every key is now a `retiredKey()` tombstone and the sub-object is tombstones-only; the rows stay `dead` with a REMOVED note (non-strict schema) and the three `overrides.*` child rows collapse into the one `overrides` row. Triage held `overrides` open as an ENFORCE candidate; the measurement closed it as REMOVE because the capability already exists at its proper seat — per-object exposure is the object's own `enable.apiEnabled` / `enable.apiMethods`, enforced by rest-server.ts#enforceApiAccess (404 / 405) — and `basePath` / `nameTransform` would contradict the one deployment-wide data base and Prime Directive #6 (the object name IS the REST path segment). The `@example` limb is corrected in the same change. `evidenceScope` widened to `cross-repo` (#14796) | | realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`) are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | | sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | +| connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`, and that schema is `ConnectorSchema.superRefine(...)` — in Zod 4 a `superRefine` attaches a check to the same object def rather than wrapping it, so the walked key set is byte-identical to the base's. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the three rows where they are the whole verdict. **ONE SCHEMA, TWO DOORS** is the shape fact behind the 20/1/53 split: the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the `ConnectorProviderContext` fields plus `name` and `enabled`; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`: refused outright by ADR-0097 §3 (#7990) rather than ignored. The 53 `dead` are four declared subsystems with no engine — `syncConfig` (7), `fieldMappings` (7), `retryConfig` (8), `health` (14, both sub-blocks) — plus `triggers` (6, and the schema's own docblock says so: #3197), the connector's nested `webhooks` (one blanket verdict, recorded in the undrilled baseline), `status`, `metadata`, the two timeouts, `actions.description`/`.outputSchema`, and four `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent). **A prior in-repo claim was falsified here**: the conversion registry's note that `retryConfig` "and the timeouts beside it are untouched — they are live" is corrected on those rows — the word does not occur outside `packages/spec` at all | +| analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That is `dimensions.granularities` (read by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead). The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 12 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the access-control flag (`public` — three sites write `false`, nothing reads it: a knob that was never wired, not a hole that was opened), `joins[].relationship` and the REQUIRED `joins[].sql` (the ON clause is SYNTHESISED as an FK equality and the authored one is never consulted), the three `description`s, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | 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 @@ -953,14 +955,17 @@ precisely how the gap survived for a year: prose cannot fail a build. Now the ga compares `GOVERNED` against the denominator in both directions (an ungoverned authorable type fails; so does a stale pending row whose debt is already paid). -⚠️ **The map is no longer empty, and that is #18133's finding rather than a -regression.** Widening the denominator from the registered kinds to the authorable +⚠️ **The map went from empty to three rows and back to empty, and neither move was +a regression.** Widening the denominator from the registered kinds to the authorable set (see the ⛔ note under *Source of truth* above) made three types visible that had been in **neither** `GOVERNED` **nor** `PENDING_GOVERNANCE` — `connector`, `sharing_rule` and `analytics_cube` — and therefore produced no row in any of the -gate's lists while the report read complete. They are now declared debts with a -reason and an issue number apiece, which is the state this ratchet exists to -produce; the direction of travel is out of that map and into `GOVERNED`, exactly -as it was for the nine. ⛔ Their presence is not a licence to leave them there. -**One of the three is paid**: `sharing_rule` is governed as of #18582, and the map -now holds `connector` and `analytics_cube`. +gate's lists while the report read complete (#18133). Declaring them as debts with a +reason and an issue number apiece is what a ratchet is for; paying them is the +direction of travel, exactly as it was for the nine. **All three are now paid**: +`sharing_rule` (PR #18587), then `connector` and `analytics_cube` together, each with +a ledger beside this file — so `PENDING_GOVERNANCE` is empty again, this time with +nothing hidden behind the emptiness. ⛔ The map stays in the gate: it is how the next +authorable type that arrives ungoverned gets a declared debt instead of silence, and +an entry added to it is a debt with an issue number, never a way to silence the gate +on a type somebody has just registered. diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 2657aa59118..e731f55b49d 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -64,4 +64,6 @@ for both corollaries. | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | -| **total** | **878** | **5** | **1** | **96** | **11** | **991** | +| `connector` | 20 | 0 | 0 | 53 | 1 | 74 | +| `analytics_cube` | 17 | 0 | 0 | 12 | 0 | 29 | +| **total** | **915** | **5** | **1** | **161** | **12** | **1094** | diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index cb581b1e331..bf9903c99ac 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -1,6 +1,6 @@ { "_note": "SHRINK-ONLY RATCHET (#4956). A liveness-ledger entry on a CONTAINER property carries ONE blanket verdict for the whole subtree beneath it. That is a legal granularity — inventing per-key verdicts without evidence would be worse — but it must be DECLARED, because silence is indistinguishable from having looked. `dashboard.widgets` asserted in prose that its widget keys were 'classified in the DashboardWidgetSchema subtree', no such subtree had ever existed, and the gate had no way to disagree — so `widgets[].responsive` rode straight through the #3896 inert-key sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` (#4956; retired four days late in #4876 / PR #4995).", - "_containers": "`containers`: the child keys under these coordinates are classified NOWHERE — not in this ledger, not in another file, nowhere. Each row is a recorded, countable gap and a candidate for drilling. The gate prints the total on every run; `check:liveness --undrilled` prints the worklist. A row leaves by being DRILLED (add `children` with a status + evidence per key, the way `view.list` / `view.form` are drilled), never by being deleted for convenience — a row whose container has since been drilled FAILS the gate, so the debt cannot be overstated either. Adding a row is legitimate only when you have no per-key evidence to record, and it is deliberately a visible edit to a file named for the debt, because the alternative it replaced (a reassuring sentence in a `note`) cost nothing to write and could not be checked. ⚠️ DEPTH-TWO MIGRATION (#17424): 51 rows arrived at once when the walk learned to recurse. They are not new debt — every one of them was already riding on a blanket verdict, below a DRILLED container, where the one-level walk could not see it and therefore never counted it. Reading the jump as a regression is the wrong reading: the instrument's denominator grew to match the surface it always covered. Each of these rows is a `/.` coordinate, and each leaves the same way any other does — by being drilled, deferred, or by its property going away.", + "_containers": "`containers`: the child keys under these coordinates are classified NOWHERE — not in this ledger, not in another file, nowhere. Each row is a recorded, countable gap and a candidate for drilling. The gate prints the total on every run; `check:liveness --undrilled` prints the worklist. A row leaves by being DRILLED (add `children` with a status + evidence per key, the way `view.list` / `view.form` are drilled), never by being deleted for convenience — a row whose container has since been drilled FAILS the gate, so the debt cannot be overstated either. Adding a row is legitimate only when you have no per-key evidence to record, and it is deliberately a visible edit to a file named for the debt, because the alternative it replaced (a reassuring sentence in a `note`) cost nothing to write and could not be checked. ⚠️ DEPTH-TWO MIGRATION (#17424): 51 rows arrived at once when the walk learned to recurse. They are not new debt — every one of them was already riding on a blanket verdict, below a DRILLED container, where the one-level walk could not see it and therefore never counted it. Reading the jump as a regression is the wrong reading: the instrument's denominator grew to match the surface it always covered. Each of these rows is a `/.` coordinate, and each leaves the same way any other does — by being drilled, deferred, or by its property going away. [#18582] One row arrived with the `connector` ledger: `connector/webhooks`. It is a container whose blanket verdict is `dead` — a connector's nested webhook array never becomes a `webhook` metadata item, so the `sys_webhook` materializer never sees it (the ledger row carries the census). It is RECORDED rather than deferred because `WebhookConfigSchema` is `WebhookSchema.extend({ events, signatureAlgorithm })`, so the gate's key-set EQUALITY check would correctly refuse a `deferred` row pointing at the governed `webhook` type; and rather than drilled because 8 of its 21 child keys are the ADR-0010 protection envelope this gate auto-classifies `live` everywhere else, and the other 13 would each repeat one sentence — fabricated granularity over a container that is dead as a whole.", "_deferred": "`deferred`: containers whose subtree IS classified elsewhere — and the reference is RESOLVED, not believed. This is the same claim that caused #4956, which is exactly why it may only be made as DATA: the gate requires the target to exist (a governed type root, or a drilled `type/prop` coordinate) and its classified key set to EQUAL the container's child key set. A dangling target fails; so does a drifted one — equality rather than subset, because a container that grows a key its target never classifies is #4956 reappearing one level down. Without this list the file's own header would have been a false claim: these six cover 248 child keys, nearly half the population. A `to` target may itself be a dotted coordinate (`dashboard/widgets.chartConfig`) since the walk recurses; the resolution rule is unchanged.", "_excluded": "ADR-0010 framework overlay fields (`protection`, `_lock*`, `_provenance` — auto-classified live, they never consult the ledger) and container properties with NO ledger row at all (already reported UNCLASSIFIED by the forward pass).", "_issue": "https://github.com/objectstack-ai/objectstack/issues/4956", @@ -70,6 +70,7 @@ "app/branding", "app/contextSelectors.optionsSource", "book/groups.pages", + "connector/webhooks", "dashboard/dateRange", "dashboard/globalFilters", "dashboard/header", From a442b583fbbb40634d9df6d34afd39f0130557cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:39:06 +0000 Subject: [PATCH 3/3] chore(qa): keep the checklist coverage map's existing key order Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- docs/qa/platform-checklist/coverage.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json index b1972dcf38f..60b69e2957e 100644 --- a/docs/qa/platform-checklist/coverage.json +++ b/docs/qa/platform-checklist/coverage.json @@ -55,13 +55,6 @@ "platform-core.docs-portal-render" ] }, - "capability": { - "items": [ - "access-security.capability-declaration-lifecycle", - "ai.console-ai-surface-gating", - "platform-core.marketplace-install-local-lifecycle" - ] - }, "connector": { "items": [ "integration-system.connector-declarative-boot", @@ -325,6 +318,13 @@ "items": [ "integration-system.webhook-lifecycle" ] + }, + "capability": { + "items": [ + "access-security.capability-declaration-lifecycle", + "ai.console-ai-surface-gating", + "platform-core.marketplace-install-local-lifecycle" + ] } } }