Skip to content

Audit spec enum ↔ renderer coverage: names that validate but render nothing, and renderer-local dialect #2901

Description

@os-zhuang

The pattern

@objectstack/spec declares a vocabulary as z.enum([...]). A renderer consumes it as a switch, a ternary chain, or a Record lookup. When the two drift, neither side reports anything:

  • the author's metadata validates, because the schema accepts the name;
  • the renderer hits its default branch and draws nothing, or silently ignores the prop.

The only symptom is a blank cell that nobody knows should be full. No test fails, no console warning fires, no type error appears — the renderer's own union type is usually hand-written, so TypeScript agrees with the narrower set.

This is not hypothetical. #2897 found it in useColumnSummary: ColumnSummarySchema accepts eleven aggregation names, the hook computed five, and configuring count_unique passed validation and then rendered an empty footer cell. That one is fixed and guarded. The question this issue asks is where else the same shape exists.

Both directions matter

A. Spec wider than renderer — a published name renders nothing. This is the useColumnSummary case.

B. Renderer wider than spec — the renderer accepts names the protocol does not define. This is renderer-local dialect, the second de-facto contract Commandment #0.1 warns about. #2897 found this too: @object-ui/react exported a hook with a distinct aggregation that is not in the spec vocabulary at all (the spec calls it count_unique).

A third, milder shape is worth recording where it appears: the renderer restates the spec's values as a hand-written TS union instead of deriving them. Currently in sync means nothing — there is no mechanism keeping it that way.

Leads from a shallow pass

These came out of a quick grep, not an investigation. Treat them as starting points to verify, not as confirmed defects.

Enum Spec values Observation
ChartTypeSchema (ui/chart.zod.ts) bar column line area pie donut funnel (7) plugin-charts/src/AdvancedChartImpl.tsx:95 declares 13, including horizontal-bar, radar, scatter, combo, treemap, sankey — none in the spec enum. Also note horizontal-bar is kebab-case where the spec uses snake_case. Direction B lead. Worth checking whether this surface is meant to be spec-driven at all before concluding anything.
RowHeightSchema (ui/view.zod.ts) compact short medium tall extra_tall (5) ObjectGrid.tsx:272 restates all five as a hand-written union; the rowHeightCellClass chain at :1564 does cover all five (medium via the else branch). In sync today, but mirrored rather than derived.
VisualizationTypeSchema grid kanban gallery calendar timeline gantt map chart tree (9) Not yet checked against the view-type dispatch.
NavigationModeSchema page drawer modal split popover new_window none (7) Not yet checked. popover and new_window are the ones to look at first.

Other named enum schemas worth sweeping, roughly in descending likelihood of having a renderer counterpart: ui/view.zod.ts (32 enum declarations — the densest file), ui/component.zod.ts (22), ui/dashboard.zod.ts, ui/animation.zod.ts, ui/notification.zod.ts, ui/offline.zod.ts, ui/page.zod.ts. Filter operators are also worth including even though they live outside this list.

Suggested approach

  1. Enumerate the pairs. Find spec z.enum exports that have a renderer counterpart. grep -rn "export const [A-Za-z]*Schema = lazySchema(() => z.enum" over packages/spec/src/ gives the left side; the right side is a switch/ternary/Record keyed on the same concept.
  2. Diff each pair in both directions. Produce a table: enum, spec values, renderer values, missing, extra.
  3. Triage before fixing. Direction A splits into "should render, implement it" and "genuinely not applicable here, and the schema should say so". Direction B splits into "promote into the spec" and "delete the dialect". Neither is mechanical — count vs count_filled in feat(grid): compute all eleven spec column summary aggregations (#2890) #2897 needed an actual semantic decision about what the two names mean.
  4. Guard what gets fixed. packages/plugin-grid/src/__tests__/summary-spec-parity.test.ts is the template: it asserts the renderer's supported set equals the spec enum in both directions and fails the moment either side moves. It requires @objectstack/spec as a devDependency of the consuming package — six plugin-* packages already have it.

The deliverable for a first pass is the table, not the fixes. Coverage gaps and dialect are different problems with different owners, and the triage in step 3 is where the judgment is.

Why it is worth doing now

These gaps are invisible by construction — they will not surface through normal testing or bug reports, only through someone configuring the affected value and quietly getting nothing. Two instances turned up in a single PR that was not looking for them. Spec 16 is still RC, which makes both fixing coverage and retiring dialect cheap; after GA, removing a renderer-local name is a breaking change.

Related: #2890, #2897, #2231.

Activity

  1. self-assigned this
    on Jul 28, 2026
  2. os-zhuang commented on Jul 28, 2026

    @os-zhuang
    ContributorAuthor

    First pass done. Full audit doc in #2937; the deliverable table is below.

    Five parallel auditors covered ui/view, ui/chart+dashboard/report, ui/page+ui/component, field types + filter operators, and the peripheral UI enums. Every row was read off the dispatch code; the highest-stakes claims were then independently re-verified in source. Nothing was exercised at runtime, so symptoms are read off the code path, not observed.

    The premise was inverted

    This issue described ChartTypeSchema as a 7-value spec enum that plugin-charts had outgrown with 13 renderer-local names. It's the reverse. The spec enum has 19 values (ui/chart.zod.ts:18). The 7-value list is an objectui fork — packages/types/src/zod/data-display.zod.ts:209 — re-exported from @object-ui/types under the same symbol name the spec uses. Two packages export ChartTypeSchema; they differ by twelve values.

    That inversion turned out to be the audit's most useful result, because it generalises: the failure is rarely "renderer forgot a name". It is usually "a second definition of the vocabulary exists, and the renderer is faithful to the wrong one." Five such forks found — tracked in #2944.

    Numbers

    Named enum exports in spec ui/ 30
    …with a real consumer outside packages/types 11
    …named only in a packages/types deny-list or local fork 14
    …with zero mentions anywhere in the repo 5
    Direction A gaps with a user-visible defect 24
    …plus inert ones (validate, change nothing) 11
    Surfaces carrying renderer-local dialect 10
    Enum-value parity guards in the repo 1

    The middle number is the structural one: 19 of 30 UI vocabularies have nothing mechanically tying them to the spec. That measures derivation, not coverage — VisualizationTypeSchema obviously has a renderer, it just restates the nine values instead of importing them. Drift there is unpoliced by construction, which explains why these went unreported rather than why they exist.

    Triage: by runtime symptom, not by direction

    Direction A/B turned out to be the wrong axis for sequencing. What the user sees orders the work by how long a defect survives undetected.

    Tier Count Symptom Issue
    1 6 Silently wrong output — worse than #2897. PivotTable.tsx:114 returns a sum for count_distinct; DatasetReportRenderer.tsx:370 silently draws a bar chart for 10 of 19 types. A blank cell is detectable; a plausible wrong number is not. #2941
    2 15 Silently absent — the #2897 shape. Includes element: 'toggle' deleting the entire filter bar, and 6 of 9 animation presets doing nothing because useAnimation.ts:64 keys its lookup in hyphens where the spec enum uses underscores. #2942
    3 3 Loud red box. Cheapest to triage precisely because it cannot hide — includes ai:chat_window, which Studio offers in the palette with a config panel and which has no renderer. #2943
    4 11 Inert — validates, changes nothing. Live the moment anyone wires the surface up. #2945

    Two upstream tracks: #2944 (retire the five forks — all inert today, and already-settled #2231 policy) and #2945 (consolidate the spec-side vocabularies: nine disagreeing filter-operator enums, two chart-type enums, ActionType vs WidgetActionTypeSchema). #2945 is upstream of most of the renderer work — no filter-builder fix is durable until there is one answer to "what is a filter operator".

    The sharpest single finding

    ui/view.zod.ts:315 says of toggle: "Kept in the enum so existing configs keep rendering; do not author new toggle filters."

    plugin-list/src/UserFilters.tsx:162 returns null for it — the whole filter bar vanishes. The component's own docstring at :122 advertises "toggle: on/off toggle buttons per field".

    Three artifacts describe one value. The only one that executes is the one that says nothing.

    Already fixed

    #2940 ships the two items that needed no design decision:

    • $ncontains — FilterConditionField.tsx:114 emitted a token appearing zero times in the spec, which objectui's own convertFiltersToAST throws on while naming the correct spelling ($notContains) in its error message. Every "does not contain" sharing rule validated in the UI and was rejected downstream.
    • secret inline-editable as plaintext — in neither EDIT_WIDGETS nor INLINE_EXCLUDED_FIELD_TYPES, two lines from password, which is correctly excluded. Per ADR-0100 a secret is encrypted-at-rest and fail-closed.

    Adding secret tripped the exclusion-set staleness guard, which is itself mis-specified in the way this issue predicts: it treats Object.keys(fieldWidgetMap) as the set of real field types, but spec spellings reaching a widget through the alias table (secret → field:password) aren't keys.

    Why the existing guards can't see any of this

    The repo has a mature drift-guard doctrine for schema shape (#2231 — three tests in packages/types) and exactly one guard for enum-value renderer coverage (summary-spec-parity.test.ts, from #2897). list-view-spec-parity.test.ts asserts field keys, not enum values, so none of the drift above trips it.

    Two tests that look like coverage guards aren't:

    • fields/src/field-type-coverage.test.ts:19 hard-codes its expected list instead of importing FieldType — which is exactly why the autonumber display-map gap survived.
    • block-config.test.ts:26 asserts palette exclusions by hand rather than deriving inclusion from PageComponentType, locking drift in instead of detecting it.

    The #2897 template ports without new machinery — .options reads cleanly through the lazySchema wrapper on every enum tested. One prerequisite: of the 13 packages that would need a guard, 6 cannot import the spec today (plugin-list, plugin-charts, plugin-dashboard, plugin-report, components, mobile lack the @objectstack/spec devDependency).

    Suggested sequencing

    1. The two clear bugs — fix(fields): emit the spec's $notContains, and keep secret out of inline edit (#2901) #2940.
    2. Vocabulary consolidation (#2901): what shipped, and why Track C items 1–3 are not planned #2945 — consolidate spec-side vocabularies while 16 is still RC. After GA, retiring a name is breaking.
    3. Retire the five objectui forks that shadow a @objectstack/spec symbol name (#2901) #2944 — retire the five forks. Mechanical, and inert today.
    4. Tier 1 (#2901): spec values that render silently WRONG output #2941 (Tier 1), then Tier 2 (#2901): spec values that validate and then render nothing #2942 (Tier 2), guarding each pair as it's fixed.
    5. Tier 3 (#2901): unregistered PageComponentType values, and the ai:chat_window palette trap #2943 and the enforce-or-remove calls in Vocabulary consolidation (#2901): what shipped, and why Track C items 1–3 are not planned #2945.
  3. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    The consolidation issue this audit spawned is closed — #2945

    Leaving a pointer here since #2945 was the vehicle for acting on this audit's findings, and it is now fully dispositioned.

    What the audit's findings turned into:

    Finding class Outcome
    Orphan vocabularies with zero consumers deleted — objectstack-ai/objectstack#4061
    Enums narrower than what the renderers implement widened — objectstack-ai/objectstack#4070 (combo, WidgetActionType = ActionType, list-chart subset .extracted, lint's chart set derived)
    Renderer-local operator dialects all three tables pinned to the spec with parity guards — #2974, #2940, #2989, #3022
    A renderer prop the spec never declared InlineActionSchema — objectstack-ai/objectstack#4135, producers migrated in objectstack-ai/cloud#926, Studio can author it in #3028
    One vocabulary in three unguarded places derived + guarded — objectstack-ai/objectstack#4153
    The silent-drop this audit found while tracing operators fixed and verified, objectstack-ai/objectstack#3948 closed
    Vocabulary narrowing (Track C items 1–3) ⛔ not planned — the harms they were preconditions for are all fixed by cheaper means

    The audit's own most useful output, in hindsight, was not the enum inventory — it was the discovery that a name validating but not rendering is only half the problem. The other half, which cost more to fix, was names the renderer implements that the spec never admits (combo, form on widget actions, element:button.action), because those fail in the direction nothing checks: valid metadata rejected at save, or authored metadata silently stripped.

    Two follow-ups still open, both narrower than this audit: objectstack-ai/objectstack#4121 and objectstack-ai/objectstack#4157.

    Whether this audit issue should stay open is a call for whoever owns it — the enum ↔ renderer sweep it describes is broader than what #2945 acted on.

  4. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    分诊:关闭(completed)—— 本单的交付物已经交付,且全部后继单已闭环。 维护者可否决重开(否决窗口,同 step 8 模式)。

    依据:本单自己定义的交付物是「表,不是修复」

    正文原话:「The deliverable for a first pass is the table, not the fixes」。那张表已在 2026-07-28 的评论里交付(审计文档 #2937,五个并行审计员覆盖 ui/view、ui/chart+dashboard/report、ui/page+ui/component、字段类型+过滤算子、外围 UI 枚举),并给出了完整读数:30 个 spec ui/ 具名枚举、11 个有 packages/types 之外的真实消费方、19/30 与 spec 之间没有任何机械绑定、24 条有用户可见缺陷的 Direction A 缺口、10 个带渲染器方言的面。

    正文第 3 步要求的分级(triage before fixing)也做了,而且换了更好的轴:按运行期症状(错值 / 静默缺失 / 红框 / 惰性)而不是按 A/B 方向排序。

    后继单状态(本轮逐条核过 —— ⛔ 不取最后一条评论的字面,自己查了 state)

    单 内容 状态
    #2941 Tier 1 静默错值(6 条) closed / completed
    #2942 Tier 2 静默缺失(15 条) closed / completed
    #2943 Tier 3 红框(3 条) closed / completed
    #2944 退役五个影子 spec 符号名的 objectui fork closed / completed
    #2945 spec 侧词汇收敛(含 Track C 1–3 的 not planned 裁决) closed / completed
    #2997 / #3014 审计衍生的两条独立缺陷 closed / completed

    ⇒ 七条后继全部闭环,无一悬空。#2940 早已带走两个无需设计决策的即时修复($ncontains 拼写、secret 内联明文可编辑)。

    为什么现在关而不是继续持有

    最后一条评论把这个问题明确交给了 owner:「Whether this audit issue should stay open is a call for whoever owner it — the enum ↔ renderer sweep it describes is broader than what #2945 acted on」。分诊的判断是关,理由是发现分诊轮的纪律(#4949:发现的准确性半衰期以天计,囤积是负利息存款):

    本单最有价值的一条结论(留档,不随关单丢失)

    审计推翻了本单自己的前提:ChartTypeSchema 不是「7 值 spec 被 13 值渲染器超出」,而是反过来 —— spec 有 19 值,那份 7 值清单是 objectui 的 fork(packages/types/src/zod/data-display.zod.ts:209),用与 spec 相同的符号名再导出。由此得出的一般化结论值得比这张表活得更久:

    失效模式很少是「渲染器忘了一个名字」,通常是「这套词汇存在第二份定义,而渲染器忠实于错的那一份」。

    以及那条最锋利的观察:toggle 一个值被三份工件描述(spec 说保留兼容、组件 docstring 说提供开关按钮、代码 return null),唯一会执行的是什么都不说的那一份。

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions