From c2f05b6064a968448ca9e471044416bc4f5959bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 19:57:53 +0000 Subject: [PATCH 1/5] fix(spec): drill the dashboard widget subtree and refuse undeclared container inheritance (#4956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The liveness ledger's `dashboard.widgets` entry claimed in prose that its widget-level props were 'classified in the DashboardWidgetSchema subtree'. No such subtree ever existed, the walk drills one level only through an explicit `children`, and `widgets` declared none — so all 22 authorable keys of the strict DashboardWidgetSchema sat outside the map while the gate printed green. - classify all 22 widget keys with closed-call-graph evidence (objectui @91757a7 + this checkout, 2026-08-03): 16 live, 6 dead - scripts/liveness/drill.mts: a container entry riding on inheritance must be DECLARED — drilled, or recorded in the shrink-only undrilled-containers.baseline.json. A new one fails; a stale row fails. - the gate now counts and prints the inheritance population every run, and its success line no longer claims a completeness it does not have Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- packages/spec/liveness/dashboard.json | 143 +++++++++++++++- .../spec/scripts/liveness/check-liveness.mts | 97 ++++++++++- packages/spec/scripts/liveness/drill.mts | 156 ++++++++++++++++++ .../undrilled-containers.baseline.json | 72 ++++++++ 4 files changed, 462 insertions(+), 6 deletions(-) create mode 100644 packages/spec/scripts/liveness/drill.mts create mode 100644 packages/spec/scripts/liveness/undrilled-containers.baseline.json diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index 1333e6ce58..1e063f5a22 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.", + "_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-08-03 (#4956): the widget subtree is DRILLED — `widgets.children` classifies all 22 authorable DashboardWidgetSchema keys. It replaces the sentence that stood here for a release — 'Widget-level props are classified in the DashboardWidgetSchema subtree, not drilled here' — which was FALSE in the only way that mattered: no such subtree existed in any ledger file, the walk drills one level 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 sweep below on that gap alone, not on evidence. The gate now refuses an undeclared container inheritance outright (scripts/liveness/drill.mts), so this class of claim cannot be re-asserted in prose. 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.", "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." + }, + "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." + }, + "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`." + }, + "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." + }, + "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 a follow-up; 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." + } + } }, "columns": { "status": "live", diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 2c90db1e76..db9abb51f2 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,10 @@ 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 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 +369,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 +410,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 +424,24 @@ 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')), +); +const coverage = reconcileContainerCoverage({ observed: observedContainers, baseline: undrilledBaseline }); +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.undrilled = observedContainers + .filter((c) => !coverage.undeclared.some((u) => u.key === c.key)) + .map((c) => ({ key: c.key, childKeys: [...c.childKeys] })); +report.undrilledChildKeys = coverage.inheritedChildKeys; + // ── 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 +449,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 +476,9 @@ 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; if (asJson) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { @@ -491,6 +543,18 @@ 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}` : '')); + } // ── re-verification clock ── const v = report.verification!; if (v.errors.length) { @@ -514,6 +578,22 @@ 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 (recorded in ` + + 'scripts/liveness/undrilled-containers.baseline.json — shrink-only).', + ); + if (showUndrilled) { + console.log('\n undrilled worklist — 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 +602,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..f95bb5249e --- /dev/null +++ b/packages/spec/scripts/liveness/drill.mts @@ -0,0 +1,156 @@ +// 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: +// +// - Every container entry either drills (`children`) or is listed in +// `undrilled-containers.baseline.json`, whose header says in plain words +// that the listed subtrees are NOT classified. A container that is neither +// FAILS, with the child keys it would be covering printed. New undrilled +// surface can no longer arrive silently — the shape that made #4956. +// - A baseline row whose entry now drills (or is no longer a container) also +// FAILS. Same rot as an orphan ledger row, opposite direction: it claims a +// gap that is already closed and overstates how much is left. +// - The gate REPORTS the population every run and its success line stops +// claiming completeness it does not have. +// +// 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[]; +} + +export interface CoverageReconcileInput { + /** Containers the walk observed riding on inheritance, in walk order. */ + observed: readonly ContainerCoverage[]; + /** Coordinates the baseline records as knowingly undrilled. */ + baseline: readonly string[]; +} + +export interface CoverageReconcileResult { + /** Observed but NOT baselined — new undrilled surface. Fails the gate. */ + undeclared: ContainerCoverage[]; + /** Baselined but no longer observed — the debt is paid or the property moved. Fails the gate. */ + stale: string[]; + /** Child keys covered by inheritance across every baselined container — the real size of the gap. */ + inheritedChildKeys: number; +} + +/** + * Reconcile the containers the walk found against the recorded debt, in BOTH + * directions. Pure — every Zod-walking detail stays in the gate, 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, +}: CoverageReconcileInput): CoverageReconcileResult { + const recorded = new Set(baseline); + const seen = new Set(observed.map((o) => o.key)); + + const undeclared = observed.filter((o) => !recorded.has(o.key)); + const stale = baseline.filter((k) => !seen.has(k)).sort(); + const inheritedChildKeys = observed + .filter((o) => recorded.has(o.key)) + .reduce((n, o) => n + o.childKeys.length, 0); + + return { undeclared, stale, inheritedChildKeys }; +} + +/** + * Read the baseline file's parsed JSON into a coordinate list, 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): string[] { + const entries = (json as { containers?: unknown })?.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", + ); + } + return entries as string[]; +} + +/** 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).', + '', + 'Two 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. RECORD it — add the coordinate to', + ' scripts/liveness/undrilled-containers.baseline.json. The file 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.', + '', + 'Do NOT reach for option 2 by 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.', +]; + +/** 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/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json new file mode 100644 index 0000000000..5024569296 --- /dev/null +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -0,0 +1,72 @@ +{ + "_note": "SHRINK-ONLY RATCHET (#4956). Every coordinate here is a liveness-ledger entry that sits on a CONTAINER property and carries ONE blanket verdict for the whole subtree beneath it. Its child keys are classified NOWHERE — not in this ledger, not in another file, nowhere. That sentence is the reason this file exists: `dashboard.json` used to assert 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 its own sibling `widgets[].performance` and its literal namesake `view.responsive` (#4956; retired four days late in #4876 / PR #4995).", + "_note2": "Being listed here is NOT a verdict and NOT an exemption from ADR-0049 — it is a recorded, countable gap. The gate prints how many child keys ride on inheritance on every run, and `check:liveness --undrilled` prints the worklist. A row leaves this file 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 entry 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.", + "_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", + "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/actions", + "object/activityMilestones", + "object/external", + "object/fieldGroups", + "object/fields", + "object/indexes", + "object/lifecycle", + "object/listViews", + "object/publicSharing", + "object/userActions", + "object/validations", + "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", + "view/formViews", + "view/listViews" + ] +} From 09aee35d764b4fbe73cd5fe0763244c5743fa0d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:17:27 +0000 Subject: [PATCH 2/5] test(spec,lint): prove the drill rule fires + close the author-warning loop on dashboards (#4956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/liveness/drill.test.ts: the tree is fully reconciled by construction, so a green check:liveness proves only that the rule is QUIET. These prove it fires — undeclared container, stale baseline row, malformed baseline. - packages/lint: register `dashboard` in TYPE_COLLECTIONS. Drilling widgets produced five authorWarn keys; without the collection the ledger would be newly correct and newly silent, which is the shape that lint exists to prevent. - README: document the third direction + regenerate the dashboard count row (18/2 -> 33/8; +21 classified, all 22 widget keys entering the count at once) - changeset: the five new build-time author warnings, with FROM -> TO per key Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- ...ess-widget-drill-and-container-coverage.md | 55 +++++++ .../lint/src/lint-liveness-properties.test.ts | 73 +++++++++ packages/lint/src/lint-liveness-properties.ts | 9 ++ packages/spec/liveness/README.md | 52 +++++- packages/spec/scripts/liveness/drill.test.ts | 148 ++++++++++++++++++ 5 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 .changeset/liveness-widget-drill-and-container-coverage.md create mode 100644 packages/spec/scripts/liveness/drill.test.ts 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..e163dcc838 --- /dev/null +++ b/.changeset/liveness-widget-drill-and-container-coverage.md @@ -0,0 +1,55 @@ +--- +"@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 either drill (`children`) +or be recorded in the shrink-only +`scripts/liveness/undrilled-containers.baseline.json`; a new undeclared +inheritance fails, and so does a baseline row whose container has since been +drilled. Every run reports how many child keys ride on blanket verdicts (64 +containers / 540 keys today), `--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..5c79b1099f 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,48 @@ 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?* A container entry must either drill +(`children`) or appear in `../scripts/liveness/undrilled-containers.baseline.json`: + +- a container that is **neither** 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 how many child keys ride on inheritance, and the success line no longer + claims a completeness it does not have. `check:liveness --undrilled` prints the worklist. + +The baseline is a **shrink-only ratchet**, not an allowlist: 64 containers / 540 child keys +at landing, each a candidate for drilling. Adding a row is a visible edit to a file named +for the debt it records — which is 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 +481,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 +550,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/scripts/liveness/drill.test.ts b/packages/spec/scripts/liveness/drill.test.ts new file mode 100644 index 0000000000..5269ef3530 --- /dev/null +++ b/packages/spec/scripts/liveness/drill.test.ts @@ -0,0 +1,148 @@ +// 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 both directions', () => { + const r = reconcileContainerCoverage({ observed: [], baseline: [] }); + expect(r).toEqual({ undeclared: [], stale: [], inheritedChildKeys: 0 }); + }); +}); + +describe('parseUndrilledBaseline — a malformed baseline must fail, not disable the ratchet', () => { + it('reads the container list', () => { + expect(parseUndrilledBaseline({ containers: ['a/b', 'c/d'] })).toEqual(['a/b', 'c/d']); + }); + + it('accepts an empty list (the ratchet fully paid down)', () => { + expect(parseUndrilledBaseline({ containers: [] })).toEqual([]); + }); + + 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 both remedies and warns off the one that caused #4956', () => { + const text = UNDRILLED_GUIDANCE.join('\n'); + expect(text).toMatch(/DRILL it/); + expect(text).toMatch(/undrilled-containers\.baseline\.json/); + // The prescription has to say out loud that a reassuring `note` is not a + // third 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/); + }); +}); From 590c72682334d3fd00196effd1904685a533d453 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:25:52 +0000 Subject: [PATCH 3/5] docs(spec): point the widget dead verdicts at their worklist issues (#4956) Five dead keys -> #5010 (ADR-0049 enforce-or-remove); compareTo's one-path-only liveness -> #5011. A dead verdict with no issue behind it is indistinguishable from never having looked. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- packages/spec/liveness/dashboard.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index 1b28f5701f..237e8f76e1 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -56,7 +56,7 @@ "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." + "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", @@ -75,21 +75,21 @@ "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." + "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`." + "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." + "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", @@ -101,7 +101,7 @@ "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 a follow-up; do not read this `live` as 'works on a dataset widget'." + "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", @@ -155,7 +155,7 @@ "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." + "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." } } }, From a78d7da9b8b7ff41dfc29bb9c727627a86e45e4c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:49:41 +0000 Subject: [PATCH 4/5] fix(spec): resolve 'classified elsewhere' instead of asserting it (#4956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by this PR's own --undrilled worklist: the first baseline draft recorded all 64 containers as 'classified NOWHERE', and for six of them that was FALSE — object.fields[] IS FieldSchema (the `field` ledger classifies all 66 keys), object.listViews[] is the ListView surface `view.list` already drills, and so on. 248 of the 540 keys, nearly half. Shipping that note would have been this PR's own version of the defect it fixes. So a container now declares one of THREE dispositions, all data: - drilled (`children`) - deferred ({ container, to }) — RESOLVED by the gate, never believed - recorded (the shrink-only `containers` list) A deferral's target must exist (a governed type root, or a drilled type/prop coordinate) and classify EXACTLY the container's child keys. Dangling fails; drifted fails (equality, not subset — a container that grows a key its target never classifies is #4956 one level down); declared in both lists fails rather than letting the gate silently pick which is true. 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. Pointing a deferral at 'DashboardWidgetSchema' now produces a named build failure, where the same words in a `note` were believed for a release. Counts are honest for the first time: 58 containers / 292 keys classified nowhere, plus 6 deferrals covering 248. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- ...ess-widget-drill-and-container-coverage.md | 26 ++- packages/spec/liveness/README.md | 55 +++-- .../spec/scripts/liveness/check-liveness.mts | 62 +++++- packages/spec/scripts/liveness/drill.mts | 189 ++++++++++++++---- packages/spec/scripts/liveness/drill.test.ts | 151 +++++++++++++- .../undrilled-containers.baseline.json | 45 ++++- 6 files changed, 446 insertions(+), 82 deletions(-) diff --git a/.changeset/liveness-widget-drill-and-container-coverage.md b/.changeset/liveness-widget-drill-and-container-coverage.md index e163dcc838..314740eb59 100644 --- a/.changeset/liveness-widget-drill-and-container-coverage.md +++ b/.changeset/liveness-widget-drill-and-container-coverage.md @@ -46,10 +46,22 @@ 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 either drill (`children`) -or be recorded in the shrink-only -`scripts/liveness/undrilled-containers.baseline.json`; a new undeclared -inheritance fails, and so does a baseline row whose container has since been -drilled. Every run reports how many child keys ride on blanket verdicts (64 -containers / 540 keys today), `--undrilled` prints the worklist, and the success -line no longer claims a completeness it does not have. +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/spec/liveness/README.md b/packages/spec/liveness/README.md index 5c79b1099f..a34b56f0aa 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -360,21 +360,46 @@ worse than an honest coarse one. What is *not* acceptable is inheriting that cov > 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?* A container entry must either drill -(`children`) or appear in `../scripts/liveness/undrilled-containers.baseline.json`: - -- a container that is **neither** 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 how many child keys ride on inheritance, and the success line no longer - claims a completeness it does not have. `check:liveness --undrilled` prints the worklist. - -The baseline is a **shrink-only ratchet**, not an allowlist: 64 containers / 540 child keys -at landing, each a candidate for drilling. Adding a row is a visible edit to a file named -for the debt it records — which is 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. +*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 diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index db9abb51f2..1ade846a13 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -296,6 +296,9 @@ const report: any = { 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 @@ -432,15 +435,46 @@ const undrilledBaselineFile = join(here, 'undrilled-containers.baseline.json'); const undrilledBaseline = parseUndrilledBaseline( JSON.parse(readFileSync(undrilledBaselineFile, 'utf8')), ); -const coverage = reconcileContainerCoverage({ observed: observedContainers, baseline: undrilledBaseline }); + +/** + * 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)) + .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. @@ -478,7 +512,8 @@ const failed = report.ungoverned.length > 0 || report.stalePending.length > 0 || report.undrilledNew.length > 0 || - report.undrilledStale.length > 0; + report.undrilledStale.length > 0 || + report.brokenDeferrals.length > 0; if (asJson) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { @@ -555,6 +590,17 @@ if (asJson) { 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) { @@ -584,11 +630,15 @@ if (asJson) { // 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 (recorded in ` + - 'scripts/liveness/undrilled-containers.baseline.json — shrink-only).', + `${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 undrilled worklist — drill the divergent ones first:'); + 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) { diff --git a/packages/spec/scripts/liveness/drill.mts b/packages/spec/scripts/liveness/drill.mts index f95bb5249e..ca1cb675b3 100644 --- a/packages/spec/scripts/liveness/drill.mts +++ b/packages/spec/scripts/liveness/drill.mts @@ -34,18 +34,41 @@ // 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: +// honest coarse one). So inheritance stays legal and becomes DECLARED, in one +// of exactly three dispositions: // -// - Every container entry either drills (`children`) or is listed in -// `undrilled-containers.baseline.json`, whose header says in plain words -// that the listed subtrees are NOT classified. A container that is neither -// FAILS, with the child keys it would be covering printed. New undrilled -// surface can no longer arrive silently — the shape that made #4956. -// - A baseline row whose entry now drills (or is no longer a container) also -// FAILS. Same rot as an orphan ledger row, opposite direction: it claims a -// gap that is already closed and overstates how much is left. -// - The gate REPORTS the population every run and its success line stops -// claiming completeness it does not have. +// - 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 @@ -68,58 +91,143 @@ export interface ContainerCoverage { 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 undrilled. */ + /** 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 NOT baselined — new undrilled surface. Fails the gate. */ + /** Observed but neither baselined nor deferred — new undrilled surface. Fails the gate. */ undeclared: ContainerCoverage[]; - /** Baselined but no longer observed — the debt is paid or the property moved. Fails the gate. */ + /** Baselined/deferred but no longer observed — the debt is paid or the property moved. Fails the gate. */ stale: string[]; - /** Child keys covered by inheritance across every baselined container — the real size of the gap. */ + /** 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 the recorded debt, in BOTH - * directions. Pure — every Zod-walking detail stays in the gate, mirroring + * 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 seen = new Set(observed.map((o) => o.key)); + 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) { + // Declared in BOTH lists — "classified nowhere" AND "classified at `to`" + // cannot both be true, and whichever the gate silently preferred would + // decide whether the 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 here = seen.get(container); + if (!here) continue; // already reported as stale + 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 undeclared = observed.filter((o) => !recorded.has(o.key)); - const stale = baseline.filter((k) => !seen.has(k)).sort(); const inheritedChildKeys = observed .filter((o) => recorded.has(o.key)) .reduce((n, o) => n + o.childKeys.length, 0); - return { undeclared, stale, inheritedChildKeys }; + 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 into a coordinate list, 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`. + * 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): string[] { - const entries = (json as { containers?: unknown })?.containers; +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", ); } - return entries as string[]; + 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. */ @@ -130,21 +238,28 @@ export const UNDRILLED_GUIDANCE = [ 'inherited by default. Silence is what let `dashboard.widgets[].responsive` sit', 'outside the map through an entire inert-key sweep (#4956).', '', - 'Two ways forward, and the evidence decides which:', + '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. RECORD it — add the coordinate to', - ' scripts/liveness/undrilled-containers.baseline.json. The file 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.', + ' 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.', '', - 'Do NOT reach for option 2 by 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.', + '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. */ diff --git a/packages/spec/scripts/liveness/drill.test.ts b/packages/spec/scripts/liveness/drill.test.ts index 5269ef3530..1a2bc88b9f 100644 --- a/packages/spec/scripts/liveness/drill.test.ts +++ b/packages/spec/scripts/liveness/drill.test.ts @@ -102,19 +102,149 @@ describe('reconcileContainerCoverage — cases it must stay quiet on', () => { expect(r.undeclared.map((u) => u.key)).toEqual(['dashboard/widgets']); }); - it('is silent on an empty tree in both directions', () => { + it('is silent on an empty tree in every direction', () => { const r = reconcileContainerCoverage({ observed: [], baseline: [] }); - expect(r).toEqual({ undeclared: [], stale: [], inheritedChildKeys: 0 }); + 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 stale coordinate once even when both lists name it', () => { + const r = reconcileContainerCoverage({ + observed: [], + baseline: ['object/fields'], + deferred: [{ container: 'object/fields', to: 'field' }], + classifiedKeysAt: resolver, + }); + expect(r.stale).toEqual(['object/fields']); + }); + + 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(['a/b', 'c/d']); + 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([]); + 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([ @@ -132,12 +262,17 @@ describe('parseUndrilledBaseline — a malformed baseline must fail, not disable }); describe('guidance', () => { - it('names both remedies and warns off the one that caused #4956', () => { - const text = UNDRILLED_GUIDANCE.join('\n'); + 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/); - // The prescription has to say out loud that a reassuring `note` is not a - // third option — writing one is the original defect. + // 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/); }); diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index 5024569296..4ab32f3f26 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -1,8 +1,41 @@ { - "_note": "SHRINK-ONLY RATCHET (#4956). Every coordinate here is a liveness-ledger entry that sits on a CONTAINER property and carries ONE blanket verdict for the whole subtree beneath it. Its child keys are classified NOWHERE — not in this ledger, not in another file, nowhere. That sentence is the reason this file exists: `dashboard.json` used to assert 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 its own sibling `widgets[].performance` and its literal namesake `view.responsive` (#4956; retired four days late in #4876 / PR #4995).", - "_note2": "Being listed here is NOT a verdict and NOT an exemption from ADR-0049 — it is a recorded, countable gap. The gate prints how many child keys ride on inheritance on every run, and `check:liveness --undrilled` prints the worklist. A row leaves this file 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 entry 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.", + "_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", @@ -34,17 +67,13 @@ "job/retryPolicy", "mapping/fieldMapping", "object/access", - "object/actions", "object/activityMilestones", "object/external", "object/fieldGroups", - "object/fields", "object/indexes", "object/lifecycle", - "object/listViews", "object/publicSharing", "object/userActions", - "object/validations", "page/aria", "page/interfaceConfig", "page/regions", @@ -65,8 +94,6 @@ "translation/objects", "translation/pages", "translation/settings", - "translation/settingsCommon", - "view/formViews", - "view/listViews" + "translation/settingsCommon" ] } From 44e3fe7e4e5225413d28a9aacc7db38799633fc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:55:02 +0000 Subject: [PATCH 5/5] refactor(spec): one finding per situation in the container-coverage report (#4956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A coordinate that is BOTH double-declared and gone was reporting twice — as a stale row and as a contradiction. The single fix is to delete the row(s), and a second heading about a coordinate that no longer exists obscures it. Same rule orphans.mts already follows for its one deliberately-silent case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- packages/spec/scripts/liveness/drill.mts | 10 +++++++--- packages/spec/scripts/liveness/drill.test.ts | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/spec/scripts/liveness/drill.mts b/packages/spec/scripts/liveness/drill.mts index ca1cb675b3..7772ee877b 100644 --- a/packages/spec/scripts/liveness/drill.mts +++ b/packages/spec/scripts/liveness/drill.mts @@ -153,9 +153,15 @@ export function reconcileContainerCoverage({ 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 the keys count as a gap. Refuse instead of picking. + // 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, ` + @@ -163,8 +169,6 @@ export function reconcileContainerCoverage({ ); continue; } - const here = seen.get(container); - if (!here) continue; // already reported as stale const target = classifiedKeysAt(to); if (!target) { brokenDeferrals.push( diff --git a/packages/spec/scripts/liveness/drill.test.ts b/packages/spec/scripts/liveness/drill.test.ts index 1a2bc88b9f..433ad74f76 100644 --- a/packages/spec/scripts/liveness/drill.test.ts +++ b/packages/spec/scripts/liveness/drill.test.ts @@ -200,7 +200,10 @@ describe('reconcileContainerCoverage — deferrals are resolved, never believed' expect(r.deferredChildKeys).toBe(0); }); - it('reports a stale coordinate once even when both lists name it', () => { + 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'], @@ -208,6 +211,7 @@ describe('reconcileContainerCoverage — deferrals are resolved, never believed' 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', () => {