diff --git a/.changeset/liveness-widget-drill-and-container-coverage.md b/.changeset/liveness-widget-drill-and-container-coverage.md new file mode 100644 index 0000000000..314740eb59 --- /dev/null +++ b/.changeset/liveness-widget-drill-and-container-coverage.md @@ -0,0 +1,67 @@ +--- +"@objectstack/spec": patch +"@objectstack/lint": patch +--- + +fix(spec): classify the 22 dashboard widget keys and refuse undeclared container inheritance (#4956) + +The spec liveness ledger's `dashboard.widgets` entry carried one blanket `live` +verdict plus a `note` asserting that the per-widget props were *"classified in +the DashboardWidgetSchema subtree"*. **No such subtree ever existed.** The gate's +walk drills one level and only through an explicit `children`, and `widgets` +declared none — so all 22 authorable keys of the strict `DashboardWidgetSchema` +were never classified, never counted as unclassified, and every run printed +"all governed-type properties are classified" anyway. + +That gap — not evidence — is what carried `widgets[].responsive` through the +#3896 inert-key sweep that removed both its sibling `widgets[].performance` and +its literal namesake `view.responsive`. `view` is drilled through `children`, so +`list.responsive` got asked and went out; `widgets` was never asked. It was +finally retired in #4876 / PR #4995, by hand, four days late. + +**What changed for authors** + +The `objectstack build` / `objectstack lint` advisory now covers dashboards, so +five widget keys warn at build time (they never did before — `dashboard` was not +in the lint's type collections, because until now its ledger warned on nothing): + +| Widget key | Why it warns | What to do instead | +| :--- | :--- | :--- | +| `widgets[].colorVariant` | no render path reads the top-level key — only the authoring panels do | move it under `options` (the inline metric card reads it there); the dataset-bound path has no colour affordance | +| `widgets[].actionUrl` | no renderer draws a per-widget action button; every `actionUrl` the dashboard renderer reads belongs to `header.actions[]` | use `dashboard.header.actions[]` | +| `widgets[].actionType` | pairs with the above | as above | +| `widgets[].actionIcon` | zero readers in either repo | as above | +| `widgets[].aria` | declared ARIA attributes never reach the DOM — the same false-compliance shape as the dashboard-level `aria` removed in 17.0.0 | delete it; the renderer emits its own `aria-*` | + +Advisory only — the build never fails on these. **Nothing is removed and no +runtime behaviour changes**: this records verdicts, it does not act on them. +Enforce-or-remove (ADR-0049) for the five is tracked separately. + +Two verdicts worth knowing because they cut the other way: `requiresService` is +**live** — it reads as inert in the renderer repo but the REST layer strips +widgets whose service is unregistered (ADR-0057 D10) — and `compareTo` is live +on the inline chart path only; on the ADR-0021 dataset path the string arms are +dropped and `{ offset }` fails in the analytics executor. + +**What changed for the gate** + +`pnpm --filter @objectstack/spec check:liveness` gains a third direction. A +ledger entry sitting on a container property must now declare one of exactly +three dispositions, all of them data: **drilled** (`children`), **deferred** (a +`{ container, to }` row naming the coordinate that does classify the subtree), +or **recorded** (a row in the shrink-only +`scripts/liveness/undrilled-containers.baseline.json`). A container in none of +the three fails, and so does a baseline row whose container has since been +drilled. + +A deferral is **resolved, not believed** — the target must exist (a governed +type root, or a drilled `type/prop` coordinate) and classify exactly the +container's child keys; a dangling or drifted target fails. That is the #4956 +claim itself, made checkable: pointing a deferral at `DashboardWidgetSchema` +now produces a build failure naming it, where the same words in a `note` were +believed for a release. + +Every run reports both populations (today: 58 containers / 292 child keys +classified nowhere, plus 6 resolved deferrals covering 248), `--undrilled` +prints the worklist, and the success line no longer claims a completeness it +does not have. diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 169c188fea..ece6e724fc 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -400,4 +400,77 @@ describe('lintLivenessProperties', () => { }); expect(findings).toEqual([]); }); + + // ── #4956: the dashboard widget subtree ──────────────────────────────────── + // + // These assertions are what make the drill worth doing on the AUTHOR side. + // Until #4956 the ledger classified `dashboard.widgets` with one blanket + // `live` and claimed in prose that the per-widget keys were classified in a + // "DashboardWidgetSchema subtree" that never existed — so no widget key had a + // verdict, and this lint (which is ledger-driven by design) had nothing to + // say about any of them. Two things had to change together: the ledger gained + // 22 child verdicts, and `dashboard` was registered in TYPE_COLLECTIONS. + // Registering the type is the half that is easy to forget and impossible to + // notice — the ledger would read correct and warn nobody. + describe('dashboard widgets (#4956)', () => { + const dash = (widget: Record) => ({ + dashboards: [{ + name: 'sales_overview', + label: 'Sales', + widgets: [{ id: 'total_pipe', type: 'metric', dataset: 'orders', values: ['total'], ...widget }], + }], + }); + + it('warns on a widget action button that no renderer draws (`actionUrl`)', () => { + const findings = lintLivenessProperties(dash({ actionUrl: '/apps/sales/orders' })); + const hit = findings.find((f) => f.message.includes('widgets.actionUrl')); + expect(hit).toBeDefined(); + expect(hit!.where).toContain('sales_overview'); + expect(hit!.hint).toMatch(/header\.actions/); + }); + + it('warns on `colorVariant`, the key this repo\'s own system dashboard authors 7 times', () => { + const findings = lintLivenessProperties(dash({ colorVariant: 'teal' })); + const hit = findings.find((f) => f.message.includes('widgets.colorVariant')); + expect(hit).toBeDefined(); + // The hint has to name the surviving home, or the author reads it as + // "widgets cannot be coloured". + expect(hit!.hint).toMatch(/options/); + }); + + it('warns on a widget `aria` block that never reaches the DOM', () => { + const findings = lintLivenessProperties(dash({ aria: { ariaLabel: 'Total pipeline' } })); + expect(findings.map((f) => f.message).some((m) => m.includes('widgets.aria'))).toBe(true); + }); + + it('fans out over EVERY widget, not just the first', () => { + const findings = lintLivenessProperties({ + dashboards: [{ + name: 'ops', + widgets: [ + { id: 'a', type: 'metric', dataset: 'd', values: ['v'] }, + { id: 'b', type: 'metric', dataset: 'd', values: ['v'], actionIcon: 'plus' }, + ], + }], + }); + // The dead key is on the SECOND widget — a walk that only looked at + // `widgets[0]` would be silently half-blind on every real dashboard. + expect(findings.map((f) => f.message).some((m) => m.includes('widgets.actionIcon'))).toBe(true); + }); + + it('stays silent on a widget built entirely from live keys', () => { + const findings = lintLivenessProperties(dash({ + title: 'Total Pipe', + dimensions: ['region'], + filter: { stage: 'closed_won' }, + layout: { x: 0, y: 0, w: 3, h: 2 }, + options: { limit: 10, sortBy: 'total' }, + requiresObject: 'order', + requiresService: 'analytics', + filterBindings: { dateRange: 'closed_at' }, + suppressWarnings: ['table-count-only'], + })); + expect(findings).toEqual([]); + }); + }); }); diff --git a/packages/lint/src/lint-liveness-properties.ts b/packages/lint/src/lint-liveness-properties.ts index f6091f263f..11dec98c1c 100644 --- a/packages/lint/src/lint-liveness-properties.ts +++ b/packages/lint/src/lint-liveness-properties.ts @@ -197,6 +197,15 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [ { type: 'email_template', key: 'emailTemplates' }, { type: 'mapping', key: 'mappings' }, { type: 'translation', key: 'translations' }, + // #4956 — dashboard joins the list the moment its ledger first warns on + // anything, which is exactly the rule the comment above states. Drilling + // `widgets` produced five warned keys (`colorVariant`, `actionUrl`, + // `actionType`, `actionIcon`, `aria`), all under `widgets[]`; `getNested` + // fans a dotted path out over an array level, so `widgets.colorVariant` + // checks every widget on the dashboard. Registering it here is not optional + // bookkeeping: without it the ledger would be newly correct and newly + // silent, which is the shape this lint exists to prevent. + { type: 'dashboard', key: 'dashboards' }, ]; /** diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 84820e3b4f..a34b56f0aa 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -325,8 +325,9 @@ properties fan out over arrays (each flow node, each dataset measure). A property is classified at the top level by default. A **container** property (object / record / array-of-object) may be drilled one level via `"children"` to keep sub-properties distinguishable — e.g. `permission.objects.allowCreate` (live) vs `allowTransfer` (experimental), -or `flow.errorHandling.fallbackNodeId` (dead) vs the rest (live). Drill only where the -audit gives divergent sub-statuses; otherwise the top-level entry covers the whole subtree. +or `flow.errorHandling.fallbackNodeId` (dead) vs the rest (live). Drill where the +audit gives divergent sub-statuses; otherwise the top-level entry covers the whole subtree — +but that inheritance must now be **declared**, not assumed (below). ```jsonc // packages/spec/liveness/permission.json @@ -339,6 +340,73 @@ audit gives divergent sub-statuses; otherwise the top-level entry covers the who } } ``` +### Undrilled containers must be DECLARED (#4956) + +"The top-level entry covers the whole subtree" is a real granularity, and forcing every +container to drill would mean inventing hundreds of per-key verdicts with no evidence — +worse than an honest coarse one. What is *not* acceptable is inheriting that coverage +**silently**, because silence is indistinguishable from having looked: + +> `dashboard.widgets` carried `{"status": "live"}` and a `note` saying the per-widget +> props were *"classified in the DashboardWidgetSchema subtree"*. **No such subtree has +> ever existed** — `DashboardWidget` appeared in exactly two files, this README and that +> claim. The walk drills one level and only through an explicit `children`, so all 22 keys +> of the strict `DashboardWidgetSchema` were never asked about, the `unclassified` count +> never mentioned them, and the run printed *"all governed-type properties are +> classified"*. That is how `widgets[].responsive` survived the #3896 sweep that removed +> its own sibling `widgets[].performance` **and its literal namesake `view.responsive`** — +> `view` is drilled through `children`, so `list.responsive` got a verdict and went out. +> The ledger could not say why it kept the key, because it had never had an opinion. +> Retired four days late in #4876 / PR #4995, by hand. + +So the gate now asks a **third** question, alongside schema → ledger and ledger → schema: +*is every blanket container verdict a declared one?* Exactly three dispositions are legal, +all of them data: + +| Disposition | Meaning | +|---|---| +| **drilled** — `children` on the entry | per-key verdicts, as before | +| **deferred** — a `{ container, to }` row in `../scripts/liveness/undrilled-containers.baseline.json` | the subtree is classified at another coordinate, and the gate **resolves** the reference | +| **recorded** — a coordinate in that file's `containers` list | genuinely classified nowhere; a counted, shrink-only debt | + +A container in **none** of the three fails, printing the child keys its verdict silently +covers. A baseline row whose container **now drills** also fails — the same rot as an orphan +row, opposite direction (an overstated debt misleads as much as an unrecorded one). Every +run prints both populations, the success line no longer claims a completeness it does not +have, and `check:liveness --undrilled` prints the worklist. + +**Why `deferred` exists, and why it is resolved rather than believed.** "Classified +elsewhere" is the exact sentence that caused #4956 — but it is sometimes *true*: +`object.fields[]` really is `FieldSchema`, which the `field` ledger classifies in full, and +`object.listViews[]` is the same ListView surface `view.list` already drills. Six containers +(248 child keys, nearly half the population) are in that position, so recording them as +"classified nowhere" would have been this file's own false claim. What separates a legal +deferral from the #4956 defect is not the claim but **who checks it**: a deferral names its +target as data, and the gate requires that target to exist (a governed type root, or a +drilled `type/prop` coordinate) and to classify **exactly** this container's child keys. +A dangling target fails; so does a drifted one — equality, not subset, because a container +that grows a key its target never classifies is #4956 one level down. Pointing a deferral at +`DashboardWidgetSchema` today produces: + +``` +✗ 1 broken deferral(s) — a "classified elsewhere" claim that does not resolve: + object/fields defers to 'DashboardWidgetSchema', which does not exist — no governed + type and no drilled ledger coordinate of that name +``` + +At landing: **58 containers / 292 child keys classified nowhere**, plus 6 resolved +deferrals covering 248. Adding a row is a visible edit to a file named for the debt it +records — the point, since the thing it replaced (a reassuring sentence in a `note`) cost +nothing to write and could not be checked. Logic and rationale live in +`../scripts/liveness/drill.mts`; it is pure and unit-tested, because a tree that is fully +reconciled by construction would otherwise prove only that the check is quiet. + +**Drilling a container is EVIDENCE work, not bookkeeping.** The 22 widget keys took a +call-graph pass across both repos and produced six dead verdicts and one one-path-only +`live` — and `requiresService`, which every objectui measurement calls dead, is enforced +server-side (`filterDashboardForUser`, ADR-0057 D10). Do not drill by fanning a parent's +status out over its children; that manufactures verdicts, which is worse than the gap. + ## Empty-state semantics — the sibling gate (#3896) This ledger asks **"does the property do anything?"** A second, smaller gate asks @@ -438,6 +506,9 @@ over-share. - `../scripts/liveness/orphans.mts` — the reverse (ledger → schema) scan: rows whose property is gone. Pure + unit-tested, because the tree was orphan-free when it landed, so a green gate proves nothing about whether the scan can fire. +- `../scripts/liveness/drill.mts` + `undrilled-containers.baseline.json` — the third + direction (#4956): a container entry may not inherit coverage for its subtree + silently. Same "pure + unit-tested" reasoning as `orphans.mts`, for the same reason. - `../scripts/liveness/check-empty-state.mts` — the empty-state gate (above); `empty-state-registry.mts` is its source of truth. @@ -504,7 +575,7 @@ for t, v in r['types'].items(): | view | 79 | 0 | 4 | – | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification; level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) noted on parents — one drill level only | | report | 21 | 0 | 0 | – | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) | -| dashboard | 18 | 0 | 2 | – | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) | +| dashboard | 33 | 0 | 8 | – | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) | **#4956**: `widgets` DRILLED — the row jumps 20 → 41 classified because all 22 widget-level keys enter the count at once. They had never been classified at all: the entry carried one blanket `live` plus a `note` asserting they were classified "in the DashboardWidgetSchema subtree", and no such subtree existed in any of the 28 ledger files. That gap, not any evidence, is what carried `widgets[].responsive` through the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` — `view` is drilled, so `list.responsive` got asked and went out. New dead 6 = `responsive` (retired #4876/#4995, tombstone keeps the row) + `colorVariant` + `actionUrl`/`actionType`/`actionIcon` + `aria`. The action trio is the sharpest: no renderer draws a per-widget action button at all (every `actionUrl` read in DashboardRenderer is scoped to `header.actions[]`), yet `validate-dashboard-action-refs.ts` enforces reference integrity on it and its docblock calls it "the per-widget button" — a lint guarding an affordance that does not exist. `requiresService` is the counter-example worth remembering: dead by every objectui measurement, and LIVE server-side (`filterDashboardForUser`, ADR-0057 D10) — judging a widget key from the renderer repo alone would have retired an enforced gate. `compareTo` is `live` on ONE path only (inline object-provider charts); on the ADR-0021 dataset path the string arms are dropped and `{ offset }` throws in the executor | | query | 16 | 1 | 4 | – | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The gate's one-level walk resolves 1 experimental; the 7 marker-experimental search affordances sit one level deeper, below the walk — resolved from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2) | | datasource | 30 | 0 | 0 | 0 | seeded 2026-08-01 (#4487) — the **highest dead ratio of any governed type** (20 of 43), and it was ungoverned until now, which is not a coincidence: #4410/#4465/#4481 found six inert keys here by hand, two security-shaped (`schemaMode` left an external DB constructible as `managed` with DDL ungated; `ssl` configured nothing while looking configured). Dead set = `capabilities.*` (all 11 — the engine gates pushdown on the runtime driver's `supports.*` object, a non-overlapping vocabulary), `healthCheck.*` (3 — nothing schedules a datasource probe; the 20 `healthCheck` hits in the repo all belong to the PLUGIN health monitor and other surfaces), `retryPolicy.*` (4 — `retryPolicy` IS enforced on `hook` and `job`, which is what makes this one read alive; the shapes differ), `external.label`, `external.requirePermission`. **`capabilities.readOnly` is the one to know**: it reads as a safety switch, gates nothing, and two shipped prescriptions pointed authors at it until #4487 — `external.allowWrites: false` is the enforced write gate. `config` is a `z.record`, so its per-driver keys sit outside the walk (recorded in the entry's note, not silently skipped) **批 A CLOSED 2026-08-02 (#4583)**: the `capabilities` block — 11 flags, every one dead and authorWarn'd — was REMOVED rather than bridged; pushdown comes from the runtime driver's own `supports.*`, so there was nothing to connect it to. Its rows are deleted (strict-removal route), which is why dead falls 20 → 9. `readOnly` was the reason the audit was worth doing: it read as a safety switch, gated nothing, and had already been MOVED twice toward somewhere it might be enforced (#4410, #4465) — the shipped CRM example called a datasource a read replica on the strength of it while the datasource took writes. Removing it does NOT hand the author a working alternative: `external.allowWrites` only gates FEDERATED datasources, so a managed one has no read-only gate at all (#4584). Remaining 9 = healthCheck ×3 + retryPolicy ×4 + external ×2, batches B/C/D of #4583 **BATCHES B/C/D CLOSED 2026-08-02 — datasource now has ZERO dead properties**, down from the 20 it was seeded with (the highest dead ratio of any governed type). `retryPolicy` ×4 and `healthCheck` ×3 went as whole blocks, `external.label` / `external.requirePermission` as keys. None was bridgeable: each already had a different LIVE mechanism doing the job — the boot policy, the driver handle's on-demand `ping()`/`checkHealth()`, the top-level `label`, and ordinary permission sets + RLS. The `retryPolicy` rejection deliberately refuses to offer a rename: `hook`/`job` retryPolicy ARE enforced but spell the delay `backoffMs`, and that inconsistency is itself the evidence nothing read the datasource one (#4488's sharpest trap) | | webhook | 11 | 0 | 0 | – | **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema`; folding it onto the registry is the #3490 reassessment. This row once read 0/1/16 ("the ENTIRE authoring surface is dead", #3461) and both halves of that were CLOSED same-quarter: #3489 built the materializer bridge (authored `webhooks:` entries now land as `sys_webhook` dispatcher rows) and #3494 pruned the aspirational props outright — so the surviving surface is fully live. Kept in the table as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING | diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index c539786c85..237e8f76e1 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -1,6 +1,6 @@ { "type": "dashboard", - "_note": "DashboardSchema (UI, ADR-0021 dataset-bound). Live path: objectui DashboardView → DashboardRenderer → DatasetWidget. Seeded from docs/audits/2026-06-dashboardschema-property-liveness.md and re-verified against objectui HEAD — several audit-era findings are superseded: the ADR-0021 widget migration shipped (Studio WidgetConfigPanel + DashboardRenderer on dataset/dimensions/values, framework#3251; DashboardWidgetSchema is now `.strict()`); `globalFilters`/`dateRange` are LIVE (dashboard-level filters, framework#2501); the `title`↔`label` drift is fixed (renderer falls back to `label`, objectui#2806); the undeclared widget props were reconciled (#1894). objectui paths cited as prose in `note` (not `evidence`). Framework provenance/lock fields auto-classify live (ADR-0010). Widget-level props are classified in the DashboardWidgetSchema subtree, not drilled here. 2026-07-30 (#3896 close-out sweep): the dead authoring keys were REMOVED — tombstoned at the schema with prescriptions (retiredKey) and stripped by the protocol-17 close-out conversions; entries deleted per the #3715 precedent. 2026-08-03 (#4876): `widgets[].responsive` REMOVED — tombstoned (retiredKey) and stripped by the protocol-17 `dashboard-widget-responsive-removed` conversion. It carries NO row here, deliberately, exactly like `widgets[].performance` in the #3896 sweep: the walk drills only one level through an explicit `children`, and `widgets` declares none, so a widget-level row would be an ORPHAN rather than a classification. That gap — not evidence of liveness — is why this key outlived the sweep; it is filed as #4956 and fixed there, and closing it is what will finally bring the 22 widget-level keys under the ratchet.", + "_note": "DashboardSchema (UI, ADR-0021 dataset-bound). Live path: objectui DashboardView → DashboardRenderer → DatasetWidget. Seeded from docs/audits/2026-06-dashboardschema-property-liveness.md and re-verified against objectui HEAD — several audit-era findings are superseded: the ADR-0021 widget migration shipped (Studio WidgetConfigPanel + DashboardRenderer on dataset/dimensions/values, framework#3251; DashboardWidgetSchema is now `.strict()`); `globalFilters`/`dateRange` are LIVE (dashboard-level filters, framework#2501); the `title`↔`label` drift is fixed (renderer falls back to `label`, objectui#2806); the undeclared widget props were reconciled (#1894). objectui paths cited as prose in `note` (not `evidence`) on the dashboard-level entries; the widget children added in #4956 use the realm-marked `evidence` form (`objectui @91757a7: …`) that the gate can attribute. Framework provenance/lock fields auto-classify live (ADR-0010). 2026-07-30 (#3896 close-out sweep): the dead authoring keys were REMOVED — tombstoned at the schema with prescriptions (retiredKey) and stripped by the protocol-17 close-out conversions; entries deleted per the #3715 precedent. 2026-08-03 (#4876): `widgets[].responsive` REMOVED — tombstoned (retiredKey) and stripped by the protocol-17 `dashboard-widget-responsive-removed` conversion. 2026-08-03 (#4956, landed after #4876): the widget subtree is DRILLED — `widgets.children` classifies all 22 authorable DashboardWidgetSchema keys. This SUPERSEDES two sentences that stood here. The first, for a release: 'Widget-level props are classified in the DashboardWidgetSchema subtree, not drilled here' — FALSE in the only way that mattered, because no such subtree existed in any ledger file, the walk drills one level and only through an explicit `children`, and `widgets` declared none, so all 22 keys sat outside the map while the gate printed green; `widgets[].responsive` survived the #3896 sweep on that gap alone, not on evidence. The second, from #4876 itself: that `responsive` deliberately carries NO row here because one would be an ORPHAN. That was correct only while `widgets` was undrilled — the retiredKey tombstone KEEPS the key in the walked shape, so now that the drill has landed the row is REQUIRED (omitting it reports UNCLASSIFIED), and it is present below with the dead verdict the sweep never got to record. The gate now refuses an undeclared container inheritance outright (scripts/liveness/drill.mts), so this class of claim cannot be re-asserted in prose.", "props": { "name": { "status": "live", @@ -19,8 +19,145 @@ "note": "objectui: DashboardRenderer.tsx:778-796 — showTitle/showDescription gates + header.actions row." }, "widgets": { - "status": "live", - "note": "objectui: the widget grid — DashboardRenderer maps each to DatasetWidget/metric/etc. Per-widget props live in the DashboardWidgetSchema subtree (strict, ADR-0021)." + "note": "objectui: the widget grid — DashboardRenderer maps each to DatasetWidget/metric/etc. DRILLED since #4956: the 22 authorable keys of the strict DashboardWidgetSchema (ADR-0021) are classified below, one verdict + evidence each, re-measured against objectui @91757a7 and this checkout on 2026-08-03. Before that this entry carried a single blanket `live` and a note claiming the keys were classified 'in the DashboardWidgetSchema subtree' — a subtree that never existed in any of the 28 ledger files — so no widget key had ever been asked the question. That is how `widgets[].responsive` survived the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` (drilled via `children`, judged dead, removed).", + "children": { + "id": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/DashboardRenderer.tsx:298-299 (widget-title i18n key), :401-402 (drag reorder match), :656-667 (render key + design-mode selection / data-widget-id), :733 (sortable id)", + "note": "widget identity — the key every per-widget affordance (i18n, reorder, selection, drill) is looked up by." + }, + "title": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/DashboardRenderer.tsx:297 + :525 (metric label fallback); packages/plugin-dashboard/src/DatasetWidget.tsx:383,389 (drill-drawer title), :418 (CSV export filename)", + "note": "rendered as the widget card heading; also the drill-drawer title and the export filename." + }, + "description": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/DashboardRenderer.tsx:306-308 (tWidgetDescription → card sub-heading)", + "note": "rendered under the widget title, localized per dashboard+widget id." + }, + "type": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/widgetDispatch.ts (classifyWidgetType — series/metric/table/custom families + spec aliases); objectui: packages/plugin-dashboard/src/DashboardRenderer.tsx:419 and :438; objectui: packages/plugin-dashboard/src/DatasetWidget.tsx:169-175", + "note": "the render dispatch key — chooses the renderer family and, with it, the grid span." + }, + "chartConfig": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/DatasetWidget.tsx:602-603 (chart-config bag forwarded to the chart renderer)", + "note": "chart presentation config; read on the dataset-bound chart path." + }, + "colorVariant": { + "status": "dead", + "authorWarn": true, + "verifiedAt": "2026-08-03", + "authorHint": "No dashboard render path reads the top-level `colorVariant`. The inline metric card takes its accent from `options` (MetricWidget's `colorVariant` prop is fed by the `...options` spread in DashboardRenderer), and the ADR-0021 dataset-bound path (DatasetWidget) has no colour affordance at all — so move it under `options`, or drop it.", + "note": "CALL GRAPH CLOSED BY HAND 2026-08-03 (objectui @91757a7). Every read of `widget.colorVariant` is an AUTHORING surface — WidgetConfigPanel.tsx:407, plugin-designer/DashboardEditor.tsx:266-267, metadata-admin/inspectors/DashboardWidgetInspector.tsx:380, DashboardWithConfig.tsx:113 (config-panel draft) — i.e. the designers write the key back out and no renderer consults it. DashboardRenderer builds the metric/object-metric component schema explicitly (`{ type: 'metric', ...options, label, value }`), so only `options.colorVariant` ever reaches MetricWidget. This repo's own packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts authors it 7 times, which is the cost of the gap being invisible. ADR-0049 enforce-or-remove tracked in #5010." + }, + "requiresObject": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/app-shell/src/views/DashboardView.tsx:143-147 (prunes widgets whose requiresObject is not in the runtime SchemaRegistry)", + "note": "client-side capability gate. NOT gated server-side — packages/rest/src/rest-server.ts:1818 names `requiresObject` as deliberately not filtered there, unlike its `requiresService` sibling." + }, + "requiresService": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "packages/rest/src/rest-server.ts:1921-1931 (filterDashboardForUser — ADR-0057 D10), called at :3037 and :3476; packages/rest/src/rest.test.ts:3271-3305", + "note": "server-side capability gate: the widget is stripped from the payload when the named kernel service is not registered (fail-open when the kernel cannot be probed). The authoritative half of the pair — read it as the counter-example to judging a widget key from the renderer repo alone." + }, + "actionUrl": { + "status": "dead", + "authorWarn": true, + "verifiedAt": "2026-08-03", + "authorHint": "No renderer draws a per-widget action button — only `dashboard.header.actions[]` is dispatched. Move the affordance to a header action, or drop the key.", + "note": "CALL GRAPH CLOSED BY HAND 2026-08-03 (objectui @91757a7). All 14 `actionUrl` occurrences in DashboardRenderer.tsx are scoped to `schema.header.actions[]` (:242-245 ActionDef build, :282-284 label i18n, :767-792 dispatch) — that is the DashboardHeaderAction schema, a different shape. Nothing anywhere reads `widget.actionUrl`. Note the second-order cost: packages/lint/src/validate-dashboard-action-refs.ts:328-333 enforces reference integrity on this key and its docblock calls it 'the per-widget button', mirroring a runtime dispatch that does not exist — so a dangling target fails the build for an affordance that never renders. ADR-0049 enforce-or-remove tracked in #5010." + }, + "actionType": { + "status": "dead", + "authorWarn": true, + "verifiedAt": "2026-08-03", + "authorHint": "Pairs with the dead `actionUrl` — no per-widget action button exists. Use `dashboard.header.actions[]`.", + "note": "Same absence as `actionUrl`, same measurement (objectui @91757a7): every `actionType` read in the dashboard renderer belongs to `header.actions[]`. Read only by packages/lint/src/validate-dashboard-action-refs.ts:331 to pick which resolution rule to apply to the (unrendered) `actionUrl`. ADR-0049 enforce-or-remove tracked in #5010." + }, + "actionIcon": { + "status": "dead", + "authorWarn": true, + "verifiedAt": "2026-08-03", + "authorHint": "No per-widget action button renders, so its icon reaches nothing. Use `dashboard.header.actions[].icon`.", + "note": "The starkest of the three: zero references in either repo outside this schema declaration and one objectui type comment listing spec-derived keys (packages/types/src/complex.ts:676). Not even the action-ref lint looks at it. ADR-0049 enforce-or-remove tracked in #5010." + }, + "filter": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/DatasetWidget.tsx:221 (→ DatasetSelection.runtimeFilter); packages/plugin-dashboard/src/DashboardRenderer.tsx:484,539,567 (inline paths) and :646 (AND-merged with the dashboard-scoped filter)", + "note": "presentation-scope filter, ANDed into the dataset query; also the base a dashboard global filter merges into." + }, + "compareTo": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/DashboardRenderer.tsx:495 (passed into the object-chart schema); objectui: packages/core/src/utils/compare-to.ts:23-25 — shiftFilterByCompareTo honours all three declared arms; objectui: packages/plugin-charts/src/ObjectChart.tsx:468-477", + "note": "LIVE ON ONE PATH ONLY — recorded rather than smoothed over, the `action.disabled` precedent. The inline object-provider chart path honours all three arms ('previousPeriod' / 'previousYear' / { offset }) via shiftFilterByCompareTo. The ADR-0021 dataset-bound path — which the spec calls the single author-facing analytics shape — does NOT: DatasetWidget.tsx:163-168 deliberately DROPS the two string arms, and forwards the `{ offset }` object into DatasetSelection.compareTo, whose contract (packages/spec/src/contracts/analytics-service.ts:104-109) is `{ kind, dimension }` — so packages/services/service-analytics/src/dataset-executor.ts:870-876 throws 'compareTo requires a timeDimension \"undefined\"'. Filed as #5011; do not read this `live` as 'works on a dataset widget'." + }, + "dataset": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/DatasetWidget.tsx:160 (dataset name → queryDataset)", + "note": "ADR-0021 binding — the semantic-layer dataset the widget selects from." + }, + "dimensions": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/DatasetWidget.tsx:161, :170-175 (also decides metric vs matrix shape), :267 (posted selection)", + "note": "ADR-0021 dimension selection by name." + }, + "values": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/DatasetWidget.tsx:162 (measure names → DatasetSelection.measures)", + "note": "ADR-0021 measure selection by name." + }, + "layout": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/DashboardRenderer.tsx:210-212 — column-count inference, and :423 the effective grid span with an auto-flow fallback when absent; objectui: packages/plugin-dashboard/src/DashboardGridLayout.tsx", + "note": "grid position; absence means auto-place, which is the Studio designer's default path." + }, + "options": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/plugin-dashboard/src/DashboardRenderer.tsx:439 — spread into every component schema; objectui: packages/plugin-dashboard/src/DatasetWidget.tsx:190-191 — declared query keys lowered into the DatasetSelection", + "note": "the renderer-extras bag; its four DECLARED keys (dateGranularity / sortBy / sortOrder / limit, framework#3588) reach the analytics query, the rest pass through to the renderer." + }, + "filterBindings": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "objectui @91757a7: packages/core/src/utils/dashboard-filters.ts:343 (resolveBoundField), :380-386 (explicit binding honoured, unbound reported); framework: packages/lint/src/validate-widget-bindings.ts:280", + "note": "per-widget binding of a dashboard-level filter to one of this widget's fields, or `false` to opt out (framework#2501)." + }, + "suppressWarnings": { + "status": "live", + "verifiedAt": "2026-08-03", + "evidence": "packages/lint/src/validate-widget-bindings.ts:367 (rule-id suppression consulted by every widget-binding diagnostic; ids emitted at :346, :417, :610, :640)", + "note": "build-time only, and that IS its contract — the key exists to silence a named build diagnostic, so the lint reading it is the whole feature (contrast `actionUrl`, where a lint reads the key but the promised runtime affordance does not exist)." + }, + "responsive": { + "status": "dead", + "verifiedAt": "2026-08-03", + "note": "The key this drill exists for. Nothing in either repo ever read `widget.responsive` — DashboardRenderer / DashboardEditor / plugin-designer name it only in comments, and objectui's one real per-breakpoint consumer (useResponsiveConfig) is fed by `page.components[].responsive`. Its literal namesake `view.list.responsive` was judged dead and removed in the 2026-07-30 #3896 sweep precisely because `view` IS drilled through `children`; this one was never asked, and survived on that silence alone. Retired 2026-08-03 via #4876 / PR #4995 (retiredKey tombstone + the protocol-17 `dashboard-widget-responsive-removed` conversion). The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error." + }, + "aria": { + "status": "dead", + "authorWarn": true, + "verifiedAt": "2026-08-03", + "authorHint": "Declared ARIA attributes never reach the DOM on a dashboard widget — no renderer applies them. Delete the key; the renderer emits its own aria-* attributes.", + "note": "CALL GRAPH CLOSED BY HAND 2026-08-03 across both repos: no consumer of `widget.aria` anywhere. The `aria-*` attributes in DashboardRenderer / DatasetWidget are the renderer's own DOM attributes, and objectui's one `.aria` read (plugin-view/src/ObjectView.tsx:989) is the VIEW's. Same false-compliance shape as the dashboard-level `aria` removed in the #3896 sweep — an accessibility guarantee an author can declare and nothing honours. ADR-0049 enforce-or-remove tracked in #5010." + } + } }, "columns": { "status": "live", diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 2c90db1e76..1ade846a13 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -19,6 +19,15 @@ // (object / record / array-of-object) may be drilled into via `"children"` so e.g. // `permission.objects.allowCreate` stays distinguishable from a blanket `objects`. // +// A container that is NOT drilled inherits its parent's single verdict for every +// key beneath it, and that inheritance must be DECLARED, not assumed: it is +// recorded in the shrink-only `drill.mts` baseline, counted in every run, and a +// new one fails the gate. `dashboard.widgets` rode on an undeclared inheritance +// for 22 keys while asserting in prose that they were "classified in the +// DashboardWidgetSchema subtree" — a subtree that never existed — which is how +// `widgets[].responsive` survived an inert-key sweep that removed its own +// sibling. See drill.mts (#4956). +// // BOTH DIRECTIONS. Schema → ledger catches an undeclared property (UNCLASSIFIED). // Ledger → schema catches the reverse: a row that outlived its property, which // went unchecked until #4080 mapped the asymmetry (a strict removal takes the key @@ -46,6 +55,7 @@ // tsx check-liveness.mts --json # machine-readable report // tsx check-liveness.mts --stale-verification # print the re-verification worklist // tsx check-liveness.mts --stale-verification=90 # ...with a custom staleness threshold +// tsx check-liveness.mts --undrilled # print the undrilled-container worklist process.env.OS_EAGER_SCHEMAS = '1'; @@ -72,6 +82,13 @@ import { } from './verification.mts'; import { checkEvidence } from './evidence.mts'; import { ORPHAN_GUIDANCE, findOrphanEntries, type Orphan } from './orphans.mts'; +import { + STALE_UNDRILLED_GUIDANCE, + UNDRILLED_GUIDANCE, + parseUndrilledBaseline, + reconcileContainerCoverage, + type ContainerCoverage, +} from './drill.mts'; const here = dirname(fileURLToPath(import.meta.url)); const specRoot = resolve(here, '../..'); // packages/spec @@ -275,6 +292,13 @@ const report: any = { orphanEntries: [] as string[], // a ledger row whose property is gone from the schema (the reverse direction) ungoverned: [] as string[], // a REGISTERED metadata type absent from both GOVERNED and PENDING_GOVERNANCE stalePending: [] as string[], // a PENDING_GOVERNANCE row for a type that is now governed / no longer registered + undrilledNew: [] as string[], // a container riding on inheritance that the baseline does not record (see drill.mts) + undrilledStale: [] as string[], // a baseline row whose container now drills / is no longer a container + undrilled: [] as Array<{ key: string; childKeys: string[] }>, // the recorded inheritance population — a worklist, not a failure + undrilledChildKeys: 0, // how many child keys ride on those blanket verdicts + brokenDeferrals: [] as string[], // a declared deferral whose target is missing, drifted, or double-declared + deferredContainers: [] as string[], // containers whose subtree IS classified elsewhere — resolved, not believed + deferredChildKeys: 0, // how many child keys those resolved deferrals actually cover verification: null as VerificationReport | null, // `verifiedAt` ages — the re-verification worklist evidenceLocal: 0, // repo-rooted evidence paths actually resolved against this checkout evidenceForeign: 0, // evidence paths attributed to objectui / cloud — not resolvable here @@ -348,6 +372,10 @@ function scanOrphanProofs() { } } +// Containers the walk classified with ONE blanket verdict — reconciled against +// the shrink-only baseline after the walk (drill.mts). +const observedContainers: ContainerCoverage[] = []; + for (const type of GOVERNED) { const ledger = loadLedger(type); const props = ledger.props || {}; @@ -385,6 +413,12 @@ for (const type of GOVERNED) { } else { const status = led?.status || markerStatus(description); if (!status) { cat.unclassified++; report.unclassified.push(`${type}/${key}`); continue; } + // One verdict standing in for a whole subtree. Legal, but it must be + // declared rather than inherited by default — record it for the + // post-walk reconcile (drill.mts, #4956). + const cs = childShape(node); + const childKeys = cs ? Object.keys(cs) : []; + if (childKeys.length > 0) observedContainers.push({ key: `${type}/${key}`, childKeys }); classify(type, key, status, led, cat); } } @@ -393,6 +427,55 @@ for (const type of GOVERNED) { scanOrphanProofs(); +// ── container coverage: is every blanket verdict a DECLARED one? ── +// The gate's third direction (#4956). Schema → ledger catches an undeclared +// property; ledger → schema catches a row that outlived its property; this +// catches a row that silently covers a subtree nobody classified. +const undrilledBaselineFile = join(here, 'undrilled-containers.baseline.json'); +const undrilledBaseline = parseUndrilledBaseline( + JSON.parse(readFileSync(undrilledBaselineFile, 'utf8')), +); + +/** + * Resolve a deferral target to the keys CLASSIFIED there, or `null` if it does + * not exist. Two forms, both real coordinates rather than prose: + * `field` — a governed type root; its walked top-level keys all carry a + * verdict (the type is governed, so the forward pass proved it). + * `view/list` — a drilled ledger coordinate; its `children` keys are verdicts. + * Anything else dangles, which is the failure this resolution exists to produce. + */ +function classifiedKeysAt(target: string): readonly string[] | null { + if (!target.includes('/')) { + if (!GOVERNED.includes(target)) return null; + try { + return topProps(target).map((p) => p.key); + } catch { return null; } + } + const [type, prop] = target.split('/'); + if (!GOVERNED.includes(type)) return null; + const children = loadLedger(type).props?.[prop]?.children; + return children ? Object.keys(children) : null; +} + +const coverage = reconcileContainerCoverage({ + observed: observedContainers, + baseline: undrilledBaseline.containers, + deferred: undrilledBaseline.deferred, + classifiedKeysAt, +}); +report.undrilledNew = coverage.undeclared.map( + (c) => `${c.key} — one verdict covers ${c.childKeys.length} unclassified child key(s): ${c.childKeys.join(', ')}`, +); +report.undrilledStale = coverage.stale; +report.brokenDeferrals = coverage.brokenDeferrals; +const deferredKeys = new Set(undrilledBaseline.deferred.map((d) => d.container)); +report.undrilled = observedContainers + .filter((c) => !coverage.undeclared.some((u) => u.key === c.key) && !deferredKeys.has(c.key)) + .map((c) => ({ key: c.key, childKeys: [...c.childKeys] })); +report.undrilledChildKeys = coverage.inheritedChildKeys; +report.deferredContainers = undrilledBaseline.deferred.map((d) => `${d.container} → ${d.to}`); +report.deferredChildKeys = coverage.deferredChildKeys; + // ── verifiedAt: how old is each claim? ── // Age never fails the gate — re-verification is a worklist, not a merge gate. // A MALFORMED value does fail: it silently disables the staleness check for @@ -400,6 +483,7 @@ scanOrphanProofs(); const staleDaysArg = args.find((a) => a.startsWith('--stale-verification')); const staleDays = Number(staleDaysArg?.split('=')[1]) || DEFAULT_STALE_DAYS; const showWorklist = staleDaysArg !== undefined; +const showUndrilled = args.includes('--undrilled'); report.verification = buildVerificationReport(verificationEntries, { staleDays }); // ── coverage: is every REGISTERED metadata type accounted for? ── @@ -426,7 +510,10 @@ const failed = report.orphanEntries.length > 0 || report.verification.errors.length > 0 || report.ungoverned.length > 0 || - report.stalePending.length > 0; + report.stalePending.length > 0 || + report.undrilledNew.length > 0 || + report.undrilledStale.length > 0 || + report.brokenDeferrals.length > 0; if (asJson) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { @@ -491,6 +578,29 @@ if (asJson) { console.log(''); ORPHAN_GUIDANCE.forEach((line) => console.log(line ? ` ${line}` : '')); } + if (report.undrilledNew.length) { + console.log(`\n✗ ${report.undrilledNew.length} UNDECLARED container inheritance — a blanket verdict covers keys nothing classified:`); + report.undrilledNew.forEach((s: string) => console.log(` ${s}`)); + console.log(''); + UNDRILLED_GUIDANCE.forEach((line) => console.log(line ? ` ${line}` : '')); + } + if (report.undrilledStale.length) { + console.log(`\n✗ ${report.undrilledStale.length} stale undrilled-container row(s) — the gap is already closed:`); + report.undrilledStale.forEach((s: string) => console.log(` ${s}`)); + console.log(''); + STALE_UNDRILLED_GUIDANCE.forEach((line) => console.log(line ? ` ${line}` : '')); + } + if (report.brokenDeferrals.length) { + console.log(`\n✗ ${report.brokenDeferrals.length} broken deferral(s) — a "classified elsewhere" claim that does not resolve:`); + report.brokenDeferrals.forEach((s: string) => console.log(` ${s}`)); + console.log( + '\n This is the #4956 claim itself, caught. A deferral is only allowed because\n' + + ' the gate RESOLVES it: the target must exist (a governed type, or a drilled\n' + + ' `type/prop` coordinate) and classify exactly this container\'s child keys.\n' + + ' Point it at the real coordinate, drill the container, or move it to the\n' + + ' `containers` list and admit the keys are classified nowhere.', + ); + } // ── re-verification clock ── const v = report.verification!; if (v.errors.length) { @@ -514,6 +624,26 @@ if (asJson) { } else if (v.stale.length || v.unverified.length) { console.log(' run with --stale-verification[=days] for the worklist.'); } + // ── container coverage: how much rides on inheritance? ── + // Printed every run, pass or fail. The gate used to say "all properties are + // classified" while hundreds of child keys had never been asked about; a + // count nobody can see is the same silence that produced #4956. + console.log( + `\ncontainer coverage: ${report.undrilled.length} container entr(ies) carry a blanket verdict over ` + + `${report.undrilledChildKeys} child key(s) that are classified NOWHERE; ` + + `${report.deferredContainers.length} more defer ${report.deferredChildKeys} key(s) to a coordinate that ` + + 'DOES classify them (resolved, not asserted). Both recorded in ' + + 'scripts/liveness/undrilled-containers.baseline.json — shrink-only.', + ); + if (showUndrilled) { + console.log('\n resolved deferrals (classified, just not here):'); + report.deferredContainers.forEach((s: string) => console.log(` ${s}`)); + console.log('\n undrilled worklist — classified nowhere; drill the divergent ones first:'); + report.undrilled.forEach((c: { key: string; childKeys: string[] }) => + console.log(` ${c.key.padEnd(34)} ${c.childKeys.length} key(s): ${c.childKeys.join(', ')}`)); + } else if (report.undrilled.length) { + console.log(' run with --undrilled for the worklist.'); + } const pendingCount = Object.keys(PENDING_GOVERNANCE).length; if (pendingCount) { console.log( @@ -522,10 +652,21 @@ if (asJson) { ); } if (!failed) { + // Deliberately qualified. The old wording — "all governed-type properties + // are classified" — was the instrument's own false claim: it counted a + // blanket container verdict as one classified property and said nothing + // about the keys underneath, which is exactly how #4956 stayed invisible. console.log( - '\n✓ all governed-type properties are classified, every registered type is governed or ' + - 'explicitly pending, no ledger row outlives its property, and all bound high-risk proofs resolve.', + '\n✓ every governed-type property at the walk\'s one-level granularity is classified, every ' + + 'registered type is governed or explicitly pending, no ledger row outlives its property, ' + + 'every container inheritance is declared, and all bound high-risk proofs resolve.', ); + if (report.undrilledChildKeys) { + console.log( + ` (not a completeness claim about the ${report.undrilledChildKeys} child key(s) under the ` + + 'declared blanket verdicts above — those are recorded, not classified.)', + ); + } } } process.exit(failed ? 1 : 0); diff --git a/packages/spec/scripts/liveness/drill.mts b/packages/spec/scripts/liveness/drill.mts new file mode 100644 index 0000000000..7772ee877b --- /dev/null +++ b/packages/spec/scripts/liveness/drill.mts @@ -0,0 +1,275 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Container coverage — the third direction of the liveness gate, and the one +// that let a key hide in plain sight. +// +// WHY THIS EXISTS. The gate classifies a property at ONE level. A container +// property (object / record / array-of-object) may be drilled via `children`; +// without one, the ledger README's rule is that "the top-level entry covers the +// whole subtree". That inheritance is invisible in three ways at once: +// +// 1. The entry looks complete. `{ "status": "live" }` on `dashboard.widgets` +// reads as a finished classification, not as a blanket claim standing in +// for 22 unexamined keys. +// 2. The COUNTS look complete. The gate credited `widgets` as ONE classified +// property and the run printed "✓ all governed-type properties are +// classified" — a sentence that was false for 562 child keys across the +// tree, and the instrument said it every single run. +// 3. Nothing could contradict it. The entry's own `note` said "Per-widget +// props live in the DashboardWidgetSchema subtree", and the file `_note` +// repeated it — a claim about a subtree that has never existed in any of +// the 28 ledger files. Prose cannot fail a build, so the gate had no +// opinion, and every later reader (human and AI) inherited the claim. +// +// That is not hypothetical: `dashboard.widgets[].responsive` survived the #3896 +// inert-key sweep that removed its sibling `widgets[].performance` and its +// literal namesake `view.responsive`. `view` is drilled through `children`, so +// `list.responsive` got a `dead` verdict and went out. `widgets` was not, so +// nobody was ever asked. The ledger could not say why it kept the key, because +// it had never had a verdict on it. It was finally retired in #4876/#4995 — on +// a hand measurement, four days late, and only because a human happened to +// notice the asymmetry. Filed as #4956. +// +// WHAT THIS FIXES, AND WHAT IT DELIBERATELY DOES NOT. Inheritance is a +// legitimate granularity — drilling all 65 container entries would mean +// inventing 562 per-key verdicts with no evidence behind them, which is the +// opposite of what this ledger is for (a fabricated `live` is worse than an +// honest coarse one). So inheritance stays legal and becomes DECLARED, in one +// of exactly three dispositions: +// +// - DRILLED (`children` on the entry) — per-key verdicts, as before. +// - DEFERRED — the container embeds a surface that IS classified elsewhere, +// with the reference RESOLVED rather than asserted (see below). +// - RECORDED — genuinely unclassified, listed in +// `undrilled-containers.baseline.json`, whose header says so in plain words. +// +// A container in none of the three FAILS, with the child keys it would be +// covering printed — new undrilled surface can no longer arrive silently, which +// is the shape that made #4956. A baseline row whose container now drills (or is +// no longer a container) also FAILS: same rot as an orphan ledger row, opposite +// direction, claiming a gap that is already closed. And the gate REPORTS the +// population every run, its success line no longer claiming completeness it does +// not have. +// +// WHY A DEFERRAL IS RESOLVED AND NOT BELIEVED. "Classified elsewhere" is the +// exact sentence that caused #4956, so it must never come back as prose. But it +// is sometimes TRUE: `object.fields[]` really is `FieldSchema`, and the `field` +// ledger classifies all 66 of its keys; `object.listViews[]` is the same +// ListView surface `view.list` already drills. Six containers — 248 child keys, +// nearly half the population — are in that position, so recording them as +// "classified NOWHERE" would have been this module's own false claim, in a file +// written to end false claims. +// +// The difference from #4956 is not the sentence, it is who checks it. A deferral +// names its target as DATA (`{ container, to }`) and the gate RESOLVES it: the +// target must exist — a governed type root, or a drilled `type/prop` coordinate +// — and its classified key set must EQUAL the container's child key set. A +// dangling target fails. A drifted one fails too: equality rather than "subset" +// on purpose, because a container that grows a key its target never classifies +// is #4956 reappearing one level down. This is the issue's own second option +// ("let the checker PARSE such a reference and error when it dangles"), and it +// is what lets the first option be honest. +// +// The baseline is a shrink-only RATCHET, not an allowlist to grow — the same +// posture as PENDING_GOVERNANCE and the `scripts/*.baseline.json` files. Adding +// a row is a visible edit to a file named for the debt it records, which is the +// point: prose in a `note` cost nothing to write and could not be checked, and +// that asymmetry is exactly what this module removes. +// +// Two exclusions, both deliberate: +// - ADR-0010 framework overlay fields (`protection`, `_lock*`, `_provenance`) +// are auto-classified `live` by the gate and never consulted the ledger, so +// they are outside this rule for the same reason they are outside the walk. +// - A container with no ledger row at all already reports UNCLASSIFIED. This +// rule only asks about entries the gate credited. + +/** One container property the gate classified with a single blanket verdict. */ +export interface ContainerCoverage { + /** `/` — the ledger coordinate carrying the blanket verdict. */ + key: string; + /** The child keys the walk can see under it — the surface the verdict silently covers. */ + childKeys: readonly string[]; +} + +/** A declared cross-reference: this container's subtree is classified at `to`. */ +export interface DeferredContainer { + /** `/` — the container riding on someone else's classification. */ + container: string; + /** Target coordinate: a governed type root (`field`) or a drilled coordinate (`view/list`). */ + to: string; +} + +export interface CoverageReconcileInput { + /** Containers the walk observed riding on inheritance, in walk order. */ + observed: readonly ContainerCoverage[]; + /** Coordinates the baseline records as knowingly unclassified. */ + baseline: readonly string[]; + /** Declared cross-references, each resolved against `classifiedKeysAt`. */ + deferred?: readonly DeferredContainer[]; + /** + * The set of keys CLASSIFIED at a target coordinate, or `null` when the + * target does not exist. Injected so every Zod/ledger detail stays in the + * gate and this module stays pure (the `orphans.mts` contract). + */ + classifiedKeysAt?: (target: string) => readonly string[] | null; +} + +export interface CoverageReconcileResult { + /** Observed but neither baselined nor deferred — new undrilled surface. Fails the gate. */ + undeclared: ContainerCoverage[]; + /** Baselined/deferred but no longer observed — the debt is paid or the property moved. Fails the gate. */ + stale: string[]; + /** A deferral whose target is missing or whose key set has drifted. Fails the gate. */ + brokenDeferrals: string[]; + /** Child keys with NO verdict anywhere — the honest size of the gap. */ + inheritedChildKeys: number; + /** Child keys covered by a RESOLVED deferral — classified, just not here. */ + deferredChildKeys: number; +} + +/** + * Reconcile the containers the walk found against their declared dispositions, + * in BOTH directions, resolving every deferral rather than believing it. + * + * Pure — all Zod/ledger lookups arrive through `classifiedKeysAt`, mirroring + * `orphans.mts`, so this stays unit-testable against a tree that is (by + * construction) fully reconciled and would otherwise prove nothing. + */ +export function reconcileContainerCoverage({ + observed, + baseline, + deferred = [], + classifiedKeysAt = () => null, +}: CoverageReconcileInput): CoverageReconcileResult { + const recorded = new Set(baseline); + const deferredBy = new Map(deferred.map((d) => [d.container, d.to])); + const seen = new Map(observed.map((o) => [o.key, o])); + + const undeclared = observed.filter((o) => !recorded.has(o.key) && !deferredBy.has(o.key)); + // Deduped: a coordinate could be named by both lists, and reporting it twice + // would obscure the single fix. + const stale = [...new Set([...recorded, ...deferredBy.keys()])].filter((k) => !seen.has(k)).sort(); + + const brokenDeferrals: string[] = []; + let deferredChildKeys = 0; + for (const { container, to } of deferred) { + const here = seen.get(container); + // Already reported as stale. Say nothing more about it — including the + // both-lists contradiction below — because the single fix is to delete the + // row(s), and a second heading about a coordinate that no longer exists + // would obscure it (the `orphans.mts` rule, same reasoning). + if (!here) continue; + // Declared in BOTH lists — "classified nowhere" AND "classified at `to`" + // cannot both be true, and whichever the gate silently preferred would + // decide whether these keys count as a gap. Refuse instead of picking. + if (recorded.has(container)) { + brokenDeferrals.push( + `${container} is declared in BOTH lists — 'containers' says its child keys are classified nowhere, ` + + `'deferred' says they are classified at '${to}'. Delete whichever is wrong.`, + ); + continue; + } + const target = classifiedKeysAt(to); + if (!target) { + brokenDeferrals.push( + `${container} defers to '${to}', which does not exist — no governed type and no drilled ledger coordinate of that name`, + ); + continue; + } + // EQUALITY, not subset: a key on either side the other does not know about + // is the #4956 hole one level down. + const targetSet = new Set(target); + const missing = here.childKeys.filter((k) => !targetSet.has(k)); + const extra = target.filter((k) => !here.childKeys.includes(k)); + if (missing.length || extra.length) { + brokenDeferrals.push( + `${container} defers to '${to}' but the key sets have drifted — ` + + `${missing.length} key(s) here that '${to}' does not classify` + + `${missing.length ? ` (${missing.join(', ')})` : ''}, ` + + `${extra.length} classified there but absent here` + + `${extra.length ? ` (${extra.join(', ')})` : ''}`, + ); + continue; + } + deferredChildKeys += here.childKeys.length; + } + + const inheritedChildKeys = observed + .filter((o) => recorded.has(o.key)) + .reduce((n, o) => n + o.childKeys.length, 0); + + return { undeclared, stale, brokenDeferrals, inheritedChildKeys, deferredChildKeys }; +} + +export interface UndrilledBaseline { + /** Genuinely unclassified containers — the shrink-only debt. */ + containers: string[]; + /** Declared cross-references, resolved by the gate. */ + deferred: DeferredContainer[]; +} + +/** + * Read the baseline file's parsed JSON, rejecting a shape the gate would + * otherwise read as "no debt recorded". A malformed baseline must fail loudly + * rather than silently disable the ratchet — the same reasoning as a malformed + * `verifiedAt`. + */ +export function parseUndrilledBaseline(json: unknown): UndrilledBaseline { + const doc = json as { containers?: unknown; deferred?: unknown } | null; + const entries = doc?.containers; + if (!Array.isArray(entries) || entries.some((e) => typeof e !== 'string')) { + throw new Error( + "undrilled-containers.baseline.json must have a `containers` array of '/' strings", + ); + } + const deferred = doc?.deferred ?? []; + if ( + !Array.isArray(deferred) || + deferred.some((d) => !d || typeof (d as DeferredContainer).container !== 'string' || typeof (d as DeferredContainer).to !== 'string') + ) { + throw new Error( + 'undrilled-containers.baseline.json `deferred` must be an array of { container, to } objects', + ); + } + return { containers: entries as string[], deferred: deferred as DeferredContainer[] }; +} + +/** Prescription printed under newly-undrilled containers. */ +export const UNDRILLED_GUIDANCE = [ + 'A container property classified by ONE blanket verdict covers every key beneath', + 'it, unasked. That is legal — inheritance is a real granularity, and inventing', + 'per-key verdicts without evidence would be worse — but it must be DECLARED, not', + 'inherited by default. Silence is what let `dashboard.widgets[].responsive` sit', + 'outside the map through an entire inert-key sweep (#4956).', + '', + 'Three ways forward, and the evidence decides which:', + '', + ' 1. DRILL it — add `"children": { … }` with a status + evidence per key, the', + ' way `view.list` / `view.form` are drilled. Do this when you can actually', + ' close the call graph for those keys; divergent sub-statuses are the', + ' signal (one dead key under a live container is the whole point).', + ' 2. DEFER it — if the container embeds a surface that is ALREADY classified', + ' (a governed type, or a coordinate someone else drilled), add', + ' `{ "container": …, "to": … }` to the `deferred` list in', + ' scripts/liveness/undrilled-containers.baseline.json. The gate RESOLVES', + ' it: the target must exist and its classified key set must EQUAL this', + ' container\'s. A dangling or drifted target fails.', + ' 3. RECORD it — add the coordinate to the `containers` list in that same', + ' file, scripts/liveness/undrilled-containers.baseline.json. It is', + ' shrink-only: a row says "these child keys are classified NOWHERE",', + ' which is honest, greppable, and a worklist. It is not a way to get', + ' green — a reviewer sees the row arrive.', + '', + 'Note what option 2 is NOT: writing a `note` that says the subtree is classified', + 'elsewhere. That sentence is what #4956 was — a claim no checker could cash,', + 'believed for a release by everyone who read the file. Same claim, as data the', + 'gate resolves, is fine; as prose it is the defect.', +]; + +/** Prescription printed under stale baseline rows. */ +export const STALE_UNDRILLED_GUIDANCE = [ + 'The container now drills (or its property is gone / no longer a container), so', + 'the row records a gap that no longer exists. Delete it — a shrink-only ratchet', + 'that never shrinks is just an allowlist, and an overstated debt is as', + 'misleading as an unrecorded one.', +]; diff --git a/packages/spec/scripts/liveness/drill.test.ts b/packages/spec/scripts/liveness/drill.test.ts new file mode 100644 index 0000000000..433ad74f76 --- /dev/null +++ b/packages/spec/scripts/liveness/drill.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Unit tests for container-coverage reconciliation — the liveness gate's third +// direction (see drill.mts for why it exists). +// +// Same reasoning as orphans.test.ts, one turn sharper. The tree is fully +// reconciled by construction the moment this lands (every undrilled container is +// baselined), so a green `check:liveness` proves only that the check is quiet — +// never that it can FIRE. And "quiet" is precisely the failure mode this rule +// exists to end: the gate was quiet about 22 unclassified widget keys for a +// release while printing a completeness claim. The proof that it now speaks up +// has to live here. + +import { describe, it, expect } from 'vitest'; +import { + STALE_UNDRILLED_GUIDANCE, + UNDRILLED_GUIDANCE, + parseUndrilledBaseline, + reconcileContainerCoverage, +} from './drill.mts'; + +describe('reconcileContainerCoverage — inheritance it must catch', () => { + it('fails an undeclared container and names the keys the blanket verdict covers', () => { + // The #4956 shape exactly: one `live` verdict standing in for the whole + // DashboardWidgetSchema, with `responsive` among the keys nobody asked about. + const { undeclared } = reconcileContainerCoverage({ + observed: [{ key: 'dashboard/widgets', childKeys: ['id', 'dataset', 'responsive'] }], + baseline: [], + }); + expect(undeclared).toEqual([ + { key: 'dashboard/widgets', childKeys: ['id', 'dataset', 'responsive'] }, + ]); + // The child keys travel with the finding — a bare coordinate would make the + // author go looking for the surface they are being asked to account for. + expect(undeclared[0].childKeys).toContain('responsive'); + }); + + it('reports every undeclared container, not just the first', () => { + const { undeclared } = reconcileContainerCoverage({ + observed: [ + { key: 'report/chart', childKeys: ['type'] }, + { key: 'page/slots', childKeys: ['header', 'tabs'] }, + ], + baseline: [], + }); + expect(undeclared.map((u) => u.key)).toEqual(['report/chart', 'page/slots']); + }); + + it('fails a STALE baseline row once its container is drilled away', () => { + // Drilling removes the container from `observed` (the gate only records the + // blanket branch), so the row now claims a gap that is closed. Same rot as + // an orphan ledger row, opposite direction. + const { stale, undeclared } = reconcileContainerCoverage({ + observed: [], + baseline: ['dashboard/widgets'], + }); + expect(stale).toEqual(['dashboard/widgets']); + expect(undeclared).toEqual([]); + }); + + it('sorts stale rows so the failure output is stable across runs', () => { + const { stale } = reconcileContainerCoverage({ + observed: [], + baseline: ['view/listViews', 'action/ai', 'object/fields'], + }); + expect(stale).toEqual(['action/ai', 'object/fields', 'view/listViews']); + }); +}); + +describe('reconcileContainerCoverage — cases it must stay quiet on', () => { + it('passes a container that is recorded in the baseline', () => { + const r = reconcileContainerCoverage({ + observed: [{ key: 'field/options', childKeys: ['label', 'value', 'color'] }], + baseline: ['field/options'], + }); + expect(r.undeclared).toEqual([]); + expect(r.stale).toEqual([]); + }); + + it('counts the child keys riding on declared inheritance — the size of the gap', () => { + const r = reconcileContainerCoverage({ + observed: [ + { key: 'field/options', childKeys: ['label', 'value'] }, + { key: 'report/order', childKeys: ['by', 'direction'] }, + ], + baseline: ['field/options', 'report/order'], + }); + expect(r.inheritedChildKeys).toBe(4); + }); + + it('does NOT count an undeclared container toward the recorded population', () => { + // Otherwise a failing container would inflate the "recorded debt" number and + // the two readings of the tree would disagree about what is accounted for. + const r = reconcileContainerCoverage({ + observed: [ + { key: 'field/options', childKeys: ['label', 'value'] }, + { key: 'dashboard/widgets', childKeys: ['id', 'responsive'] }, + ], + baseline: ['field/options'], + }); + expect(r.inheritedChildKeys).toBe(2); + expect(r.undeclared.map((u) => u.key)).toEqual(['dashboard/widgets']); + }); + + it('is silent on an empty tree in every direction', () => { + const r = reconcileContainerCoverage({ observed: [], baseline: [] }); + expect(r).toEqual({ + undeclared: [], + stale: [], + brokenDeferrals: [], + inheritedChildKeys: 0, + deferredChildKeys: 0, + }); + }); +}); + +// ── deferrals: the #4956 claim, allowed ONLY because it is resolved ────────── +// +// "classified elsewhere" is the exact sentence that caused the bug, and it is +// also sometimes true (`object.fields[]` really is `FieldSchema`). What makes +// the difference is not the sentence but who checks it, so every one of these +// asserts the checking, not the claim. +describe('reconcileContainerCoverage — deferrals are resolved, never believed', () => { + const fieldKeys = ['name', 'label', 'type']; + const resolver = (t: string) => (t === 'field' ? fieldKeys : t === 'view/list' ? ['columns'] : null); + + it('accepts a deferral whose target exists and classifies exactly these keys', () => { + const r = reconcileContainerCoverage({ + observed: [{ key: 'object/fields', childKeys: ['name', 'label', 'type'] }], + baseline: [], + deferred: [{ container: 'object/fields', to: 'field' }], + classifiedKeysAt: resolver, + }); + expect(r.brokenDeferrals).toEqual([]); + expect(r.undeclared).toEqual([]); + // Deferred keys are classified, so they must NOT be counted as the gap. + expect(r.deferredChildKeys).toBe(3); + expect(r.inheritedChildKeys).toBe(0); + }); + + it('FAILS a dangling target — the literal #4956 claim', () => { + const r = reconcileContainerCoverage({ + observed: [{ key: 'dashboard/widgets', childKeys: ['id', 'responsive'] }], + baseline: [], + deferred: [{ container: 'dashboard/widgets', to: 'DashboardWidgetSchema' }], + classifiedKeysAt: resolver, + }); + expect(r.brokenDeferrals).toHaveLength(1); + expect(r.brokenDeferrals[0]).toMatch(/DashboardWidgetSchema/); + expect(r.brokenDeferrals[0]).toMatch(/does not exist/); + expect(r.deferredChildKeys).toBe(0); + }); + + it('FAILS when the container has a key the target does not classify (drift, the #4956 hole one level down)', () => { + const r = reconcileContainerCoverage({ + observed: [{ key: 'object/fields', childKeys: ['name', 'label', 'type', 'newKey'] }], + baseline: [], + deferred: [{ container: 'object/fields', to: 'field' }], + classifiedKeysAt: resolver, + }); + expect(r.brokenDeferrals).toHaveLength(1); + expect(r.brokenDeferrals[0]).toMatch(/newKey/); + }); + + it('FAILS when the target classifies a key the container no longer has (equality, not subset)', () => { + const r = reconcileContainerCoverage({ + observed: [{ key: 'object/fields', childKeys: ['name', 'label'] }], + baseline: [], + deferred: [{ container: 'object/fields', to: 'field' }], + classifiedKeysAt: resolver, + }); + expect(r.brokenDeferrals).toHaveLength(1); + expect(r.brokenDeferrals[0]).toMatch(/classified there but absent here/); + expect(r.brokenDeferrals[0]).toMatch(/type/); + }); + + it('resolves a drilled coordinate target, not just a type root', () => { + const r = reconcileContainerCoverage({ + observed: [{ key: 'view/listViews', childKeys: ['columns'] }], + baseline: [], + deferred: [{ container: 'view/listViews', to: 'view/list' }], + classifiedKeysAt: resolver, + }); + expect(r.brokenDeferrals).toEqual([]); + expect(r.deferredChildKeys).toBe(1); + }); + + it('FAILS a container declared in BOTH lists rather than silently preferring one', () => { + // "classified nowhere" and "classified at `field`" cannot both be true, and + // whichever the gate preferred would decide whether those keys count as a + // gap. Refusing is the only answer that does not quietly pick. + const r = reconcileContainerCoverage({ + observed: [{ key: 'object/fields', childKeys: ['name', 'label', 'type'] }], + baseline: ['object/fields'], + deferred: [{ container: 'object/fields', to: 'field' }], + classifiedKeysAt: resolver, + }); + expect(r.brokenDeferrals).toHaveLength(1); + expect(r.brokenDeferrals[0]).toMatch(/BOTH lists/); + expect(r.deferredChildKeys).toBe(0); + }); + + it('reports a gone-and-double-declared coordinate ONLY as stale', () => { + // Both rows are wrong, but the single fix is to delete them, and a second + // heading about a coordinate that no longer exists would obscure that — + // the same "do not report it twice" rule orphans.mts follows. + const r = reconcileContainerCoverage({ + observed: [], + baseline: ['object/fields'], + deferred: [{ container: 'object/fields', to: 'field' }], + classifiedKeysAt: resolver, + }); + expect(r.stale).toEqual(['object/fields']); + expect(r.brokenDeferrals).toEqual([]); + }); + + it('reports a deferral for a container that no longer exists as STALE, not broken', () => { + const r = reconcileContainerCoverage({ + observed: [], + baseline: [], + deferred: [{ container: 'object/fields', to: 'field' }], + classifiedKeysAt: resolver, + }); + expect(r.stale).toEqual(['object/fields']); + expect(r.brokenDeferrals).toEqual([]); + }); +}); + +describe('parseUndrilledBaseline — a malformed baseline must fail, not disable the ratchet', () => { + it('reads the container list', () => { + expect(parseUndrilledBaseline({ containers: ['a/b', 'c/d'] })).toEqual({ + containers: ['a/b', 'c/d'], + deferred: [], + }); + }); + + it('reads the deferral list', () => { + const d = [{ container: 'object/fields', to: 'field' }]; + expect(parseUndrilledBaseline({ containers: [], deferred: d }).deferred).toEqual(d); + }); + + it('accepts an empty list (the ratchet fully paid down)', () => { + expect(parseUndrilledBaseline({ containers: [] })).toEqual({ containers: [], deferred: [] }); + }); + + it.each([ + ['a non-array `deferred`', { containers: [], deferred: 'object/fields' }], + ['a deferral with no target', { containers: [], deferred: [{ container: 'object/fields' }] }], + ['a deferral with no container', { containers: [], deferred: [{ to: 'field' }] }], + ])('throws on %s', (_label, doc) => { + expect(() => parseUndrilledBaseline(doc)).toThrow(/deferred/); + }); + + it.each([ + ['a missing `containers` key', { note: 'oops' }], + ['a non-array `containers`', { containers: 'a/b' }], + ['a non-string row', { containers: ['a/b', 42] }], + ['a null document', null], + ])('throws on %s rather than reading it as "no debt recorded"', (_label, doc) => { + // Silently reading a broken baseline as `[]` would turn every recorded + // container into a NEW failure — or, with the reconcile inverted, silently + // exempt the whole tree. Both are the malformed-`verifiedAt` shape: a bad + // value that quietly switches a check off. + expect(() => parseUndrilledBaseline(doc)).toThrow(/containers/); + }); +}); + +describe('guidance', () => { + it('names all three remedies and warns off the one that caused #4956', () => { + // Wrapped for terminal output, so collapse the line breaks before matching — + // otherwise this asserts the line-wrapping, not the prescription. + const text = UNDRILLED_GUIDANCE.join(' ').replace(/\s+/g, ' '); + expect(text).toMatch(/DRILL it/); + expect(text).toMatch(/DEFER it/); + expect(text).toMatch(/RECORD it/); + // An author who is told to record a row must be told WHERE. + expect(text).toMatch(/undrilled-containers\.baseline\.json/); + // And it has to say out loud that a reassuring `note` is not a fourth + // option — writing one is the original defect. + expect(text).toMatch(/classified elsewhere/); + expect(text).toMatch(/#4956/); + }); + + it('tells a stale row to be deleted', () => { + expect(STALE_UNDRILLED_GUIDANCE.join('\n')).toMatch(/Delete it/); + }); +}); diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json new file mode 100644 index 0000000000..4ab32f3f26 --- /dev/null +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -0,0 +1,99 @@ +{ + "_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.", + "_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.", + "_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", + "deferred": [ + { + "container": "object/fields", + "to": "field", + "why": "object.fields[] IS FieldSchema — the `field` type is separately governed and classifies all of its keys." + }, + { + "container": "object/actions", + "to": "action", + "why": "object.actions[] IS ActionSchema — the `action` type is separately governed." + }, + { + "container": "object/validations", + "to": "validation", + "why": "object.validations[] IS ValidationRuleSchema — governed via SPEC_ONLY_SCHEMAS since #4509 retired the standalone kind; the rule vocabulary is exactly what `object.validations[]` carries." + }, + { + "container": "object/listViews", + "to": "view/list", + "why": "the same ListView surface `view.list` already drills, embedded on the object." + }, + { + "container": "view/listViews", + "to": "view/list", + "why": "the ViewItem container's list surface — `view.list` is the drilled coordinate for it." + }, + { + "container": "view/formViews", + "to": "view/form", + "why": "the ViewItem container's form surface — `view.form` is the drilled coordinate for it." + } + ], + "containers": [ + "action/ai", + "action/aria", + "action/params", + "action/resultDialog", + "agent/guardrails", + "agent/lifecycle", + "agent/memory", + "agent/model", + "agent/planning", + "agent/structuredOutput", + "app/branding", + "dashboard/dateRange", + "dashboard/globalFilters", + "dashboard/header", + "doc/translations", + "email_template/fromOverride", + "email_template/variables", + "field/currencyConfig", + "field/dependsOn", + "field/lookupColumns", + "field/lookupFilters", + "field/options", + "field/storage", + "field/summaryOperations", + "flow/edges", + "flow/variables", + "hook/retryPolicy", + "job/retryPolicy", + "mapping/fieldMapping", + "object/access", + "object/activityMilestones", + "object/external", + "object/fieldGroups", + "object/indexes", + "object/lifecycle", + "object/publicSharing", + "object/userActions", + "page/aria", + "page/interfaceConfig", + "page/regions", + "page/slots", + "page/variables", + "permission/adminScope", + "query/expand", + "query/groupBy", + "query/orderBy", + "report/blocks", + "report/chart", + "report/order", + "skill/triggerConditions", + "translation/apps", + "translation/dashboards", + "translation/globalActions", + "translation/metadataForms", + "translation/objects", + "translation/pages", + "translation/settings", + "translation/settingsCommon" + ] +}