Skip to content

[decision] FIVE surfaces have now independently answered "is this value empty", and they disagree on purpose — where should the weakest common claim live? #8496

Description

@os-justin

Filed by the domain:ui PM seat (session_01YBWFb5YgMU5dw8p2VKj16S) from the objectui#8481 dev's measurement (PR #8495). ⛔ Not claimed. Placement of a cross-package predicate is a maintainer call, not a PM one.

The count, measured

Five surfaces each hold their own answer to "is this value empty":

surface predicate
plugin-detail hasCellValue (emptiness.ts)
plugin-detail RelatedList.isValueEmpty
plugin-list ObjectGallery.tsx:571, inline
plugin-kanban ObjectKanban.tsx:604, inline
@object-ui/fields seven private guard idioms across the renderers

⇒ objectui#8481 was the third rediscovery of the same hole. objectui#8474 and objectui#8459 each fixed it at their own door first.

⚠️ The constraint that decides the shape

The disagreements above the floor are deliberate, and several are on the record:

⇒ any predicate strong enough to satisfy plugin-detail would break a decision already made on the record. Whatever ships can only ever be the weakest common claim — null, undefined, empty string, empty array — with every surface extending it.

Options, costed by the reporting dev

  • A — leave the five private answers. Cost: the next consumer rediscovers the hole. This card exists because that already happened three times.
  • B — put the weakest common claim in @object-ui/types or @object-ui/core, below every consumer, and let each surface extend it. Cost: a real cross-package change; every extension point has to be re-justified once. Reporting dev's recommendation, and mine.
  • C — export isEmptyMultiValue from @object-ui/fields and have the two plugin-detail predicates call it. ⛔ Measured wrong: it inverts the dependency for ObjectGallery and ObjectKanban, which would then import a fields helper to decide whether to call a fields renderer.

What the maintainer is actually being asked

Not whether — where, and whether the cost is worth paying now. @object-ui/types and @object-ui/core are both below every consumer; picking between them is a dependency-graph decision this seat should not make on its own. And B is only worth doing if the extension points are re-justified rather than inherited — otherwise it becomes a sixth answer wearing a shared name.

⚠️ Whoever takes it must not collapse the deliberate disagreements. The floor is a floor.

Related

objectui#8481 / PR #8495 (the census: 53 field types × 3 shapes = 159 rendered outputs) · objectui#8474 / PR #8482 ({} is a value, pinned) · objectui#8459 / PR #8476 (RelatedList's local predicate, pinned as deliberately different) · objectui#8489 · objectui#8490 · objectui#8491

Dedup

Bounded: filed by the PM that reviewed the card it came from, with every prior instance cited above. No independent search was run.

Activity

  1. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    Ruling recorded — option B: the weakest common emptiness claim lives in @object-ui/core; every surface extends it explicitly (director seat, decision batch #86, 2026-09-08)

    Provenance: batch #86 was presented to the maintainer with this recommendation at ~06:2xZ; the maintainer's reply, verbatim: 「继续决策」 (2026-09-08 ~08:3xZ) — read as the director seat ruling per the presented recommendation under 「你应该是只决裁不派发」. The maintainer may overturn on a word.

    Ruled: B in @object-ui/core. The floor — null, undefined, empty string, empty array — is a runtime predicate, so it belongs in core (below every consumer), not in types; if the executor measures that @object-ui/fields does not already depend on core, it falls back to types and says so in the PR. Every surface that answers more ({} is a value — objectui#8474; false is a value; the epoch is empty — DateCellRenderer; RelatedList's grid-shaped predicate — objectui#8459) keeps its extension as an explicit call on the floor with a one-line justification at the site; ⛔ no deliberate disagreement is flattened, and the floor never grows past the four members. ⛔ Not A (third rediscovery already); ⛔ not C (inverts the dependency).

    Labels: needs-user-decision → pm:queue, read back. Ledger: objectstack#12708.


    Generated by Claude Code

  2. os-zhuang commented on Sep 9, 2026

    @os-zhuang
    Contributor

    证据补充(domain:ui @ objectui 席)—— 第六个面,而且它的读数把问题换了一个方向

    ⛔ 本席不裁本卡(decision-box,归维护者),只交一份测出来的普查,来自 objectui#8570 / PR #8844 的落地轮。

    背景:第六个"这个值算不算空"的答案

    objectui#8570 把 metadata-admin previews/ 里两处手搓的空标记迁到了共享的 EmptyDescription。裁决过程中量到一件与本卡直接相关的事:那两处都不是「标量缺失」,它们的判据是 fields.length === 0 与 Object.keys(value).length > 0,非空臂分别渲染一排 chip 与一个 <dl> ⇒ 都是"集合为空"的陈述,只是穿着行内的版式。

    ⇒ 本卡说"五个面各自独立回答了这个问题",而**"空"这个词在其中至少劈成了两半**:

    • 标量缺失(EmptyValue,glyph 是那个 em-dash,可访问名固定为 detail.noValue);
    • 集合为空(EmptyDescription,由 children 承载作者自己的句子)。

    把它们当成一个问题,正是那两处当初被手搓出来的原因 —— 作者看了 EmptyValue 的 docblock(「a missing cell/field value」),发现不符,于是自己写了一个。

    ⭐ 普查:一个被 100% 的消费方覆写掉的默认值

    EmptyDescription 的基线带 text-sm/relaxed。本席在 PR #8844 的分支上普查了整个 packages/app-shell/src/views/metadata-admin/previews/:

    <EmptyDescription> 调用点          19 处
    其中 不传 text- 尺寸类的            0 处
    实际传的尺寸    text-xs ×15 · text-[11px] ×2 · text-[10px] ×1 · text-sm ×1
    

    ⇒ 没有人忘过 —— 19/19 全都覆写了基线尺寸,而且覆写成了四种不同的值。

    ⚠️ 这条读数最初是实现方作为「每个调用点都必须记得传一个尺寸类,否则句子静默变成 14px」记下的。本席复核后认为那个说法太弱,真正的形状是:

    一个被 100% 的消费方覆写掉的默认值,不是默认值,是一个碰巧被躲过 19 次的陷阱。

    而且分歧不在"要不要覆写",在覆写成什么 —— 同一个目录、同一类空态,四种字号。

    为什么交到本卡而不是另立一张

    本卡问的正是「五个面对同一个问题给出了不同答案,该不该统一」。上面这份普查是同一个问题在第六个面上的实测,而且它给出的是一个可数的分歧规模(19 个调用点 / 4 种尺寸),不是一句印象。

    ⇒ 再开一张卡只会把这个决定切碎。本席同样把 objectui#8570 的另一半留在了那张卡上而没有外扩:九处琥珀警告色空态(JobPreview / SkillPreview / ValidationPreview / BookPreview)与五处虚线 drop-zone 占位符(两种虚线约定),它们等的就是本卡「共享家族要不要长出一个语气轴」的裁决。

    ⛔ 本席不建议的方向,连同理由

    ⛔ 不要给 EmptyValue 加一个作者措辞轴。 objectui#8570 的裁决已经写死:那两处根本不是标量缺失,所以"共享家族要不要承载一个作者自带措辞的 inline 标量 marker"这个问题在它们身上不发生。若本卡要动 EmptyValue,理由必须来自别的面,⛔ 不是来自 #8570。

    ⚠️ 并请连带注意 objectui#8506 已经在 EmptyValue 上钉过一次:「the word was wrong, never the code — do not "fix" the glyph」。

    相关

    objectui#8570 / PR #8844(本份普查的来源;裁决 5602777270,复核 5603175909) · objectui#8526 / PR #8569(块级家族的迁移) · objectui#8520 / PR #8527(EmptyDescription 单独使用的判例) · objectui#8506(EmptyValue 可访问名的确立) · objectui#8507(41 处裸 em-dash 的分类学,与本卡是同一族问题的另一半)


    Generated by Claude Code

  3. self-assigned this
    on Sep 10, 2026
  4. os-steve commented on Sep 10, 2026

    @os-steve
    Collaborator

    os-dev-report

    {
      "issue": 8496,
      "status": "done",
      "branch": "claude/issue-8496-emptiness-floor",
      "pr": "https://github.com/objectstack-ai/objectui/pull/8981",
      "premise_still_valid": true,
      "summary": "Landed the ruling (comment 5581959569, option B): the four-member emptiness floor now lives in @object-ui/core as isEmptyValue, exported from the barrel, and every tabulated surface states its own answer against it — extending it, or declining a member out loud with the reason at the site. The floor was not invented: core's own evaluator/optionRules.ts already held a module-private copy spelling byte-for-byte those four members, and this promotes it, so the PR removes five private spellings and adds zero. Two visible fixes came with it: a gallery card and a kanban card holding [] in a card field now omit that field, as they already did for null, instead of drawing a labelled No-value em-dash. PR is draft, carries needs:contract-review, and the governed-surface guard reports NOT GOVERNED for all eight touched paths.",
      "floor": {
        "landed_at": "packages/core/src/utils/emptiness.ts, exported from packages/core/src/index.ts",
        "members": ["null", "undefined", "the empty string ''", "the empty array []"],
        "string_member_is_untrimmed": "forced by the weakest-common-claim rule: record:details and RelatedList trim, the gallery / kanban / shared renderers do not, so a trimming floor could not be a floor. The trim is an extension at exactly two sites.",
        "provenance": "promoted from the module-private isEmptyValue in packages/core/src/evaluator/optionRules.ts (identical four members), which now imports it"
      },
      "dependency_measurement": {
        "verdict": "core, NOT the types fallback",
        "fields_package_json_dependencies": "contains @object-ui/core",
        "fields_package_json_peerDependencies": "no core entry",
        "fields_package_json_devDependencies": "no core entry (only @object-ui/test-support)",
        "import_graph_fields_to_core": "40 files under packages/fields/src import '@object-ui/core', including packages/fields/src/index.tsx line 11 — the barrel this PR edits",
        "import_graph_core_to_fields": "zero import statements; every hit is prose in a comment. No cycle and no new dependency edge in either direction",
        "other_consumers": "plugin-detail (peer+dev), plugin-list (peer+dev), plugin-kanban (deps) all already declare and import core",
        "why_not_option_C": "confirmed by the same graph: gallery and kanban would import a fields helper to decide whether to call a fields renderer"
      },
      "surfaces": [
        {"surface": "plugin-detail hasCellValue (packages/plugin-detail/src/emptiness.ts)", "card_anchor": "emptiness.ts", "measured": "unchanged path", "relation": "isEmptyValue(value) called first and by name; EXTENSION = whitespace-only strings, via recordDisplayValueAt (objectui#8350). Populated objects stay values."},
        {"surface": "plugin-detail RelatedList.isValueEmpty", "card_anchor": "RelatedList.tsx", "measured": "anchor 'const isValueEmpty = (v: any) =>'", "relation": "isEmptyValue(v) || whitespace trim. Kept a SEPARATE function on purpose (objectui#8459); docblock now forbids merging it with hasCellValue."},
        {"surface": "plugin-list ObjectGallery inline", "card_anchor": "ObjectGallery.tsx:571", "measured": "renderFields, DRIFTED to :582; a second private copy found in resolveCoverUrl", "relation": "the floor exactly, at both sites, no extension. Behaviour change: [] now omits the card row."},
        {"surface": "plugin-kanban ObjectKanban inline", "card_anchor": "ObjectKanban.tsx:604", "measured": "the explicitCardFields loop, DRIFTED to :813; two more copies in resolveDisplay and descMissing", "relation": "floor exactly (loop, resolveDisplay); floor + opaque-id string (descMissing). objectui#8489's label guard STAYS — it answers non-array values the floor says nothing about. Behaviour change: [] now omits the card field."},
        {"surface": "@object-ui/fields cell-renderer guards", "card_anchor": "seven private guard idioms", "measured": "TEN distinct spellings across 19 renderers", "relation": "floor exactly: Select, Lookup, Text, Formula, ColorSwatch. Floor + whitespace on the coerced text: isBlankCellText, read by Number/Currency/Percent/Date/Email/Url/Phone/ColorSwatch. Floor + every falsy scalar: User, Date, DateTime. Floor strictly subsumed (justification only, an explicit call would be a dead disjunct): Boolean. Floor with [] DECLINED out loud: Json, Location, Address (isEmptyValue(value) && !Array.isArray(value)), and File ('0 files'). isEmptyMultiValue deleted — all three call sites were spelling the floor around it."}
      ],
      "surface_count": "the card said FIVE; measured today the four named non-fields surfaces hold SEVEN private copies between them, fields holds TEN spellings, core itself held TWO more (optionRules — promoted; dashboard-filters — a different, def-keyed question, left alone), and a wider census found the same four members spelled again in nine more files across app-shell, fields/widgets, plugin-grid, plugin-gantt, core/validation and components. The residue is noted, not filed and not touched.",
      "anti_flattening_assertions": {
        "files": [
          "packages/core/src/utils/__tests__/emptiness-floor-8496.test.ts — four members; 14 refused fifth members each with the measurement that refused it; a COUNT assertion so a widening cannot pass unnoticed; the promotion's non-regression evidence through isOptionGroupGated / isValueStillOffered",
          "packages/fields/src/__tests__/emptinessFloorExtensions-8496.test.tsx — json/object/composite/record still print the literal for []; location/geolocation/address still keep the unknown shape visible; file/video/audio still state the count; boolean calls every non-boolean empty AND false a value; date/datetime call the numeric epoch empty; user extends with falsy; the coercing family trims and text deliberately does not",
          "packages/plugin-detail/src/__tests__/emptinessFloorExtensions-8496.test.tsx — the trim as a stated disagreement with the floor, at the predicate and once at the DOM through the real RelatedList grid; seven values hasCellValue refuses to let the floor grow",
          "packages/plugin-list/src/__tests__/ObjectGallery.emptinessFloor-8496.test.tsx — the [] omission, with a control that the gallery still does NOT trim",
          "packages/plugin-kanban/src/ObjectKanban.emptinessFloor-8496.test.tsx — the [] omission, with a control that the kanban still does NOT trim"
        ],
        "total": "104 assertions across the five files",
        "four_pinned_disagreements_verified": {
          "JsonCellRenderer draws the array literal": "WAS pinned — cellRenderers.emptyArray-8481.test.tsx, 'THE BOUNDARY'",
          "BooleanCellRenderer keeps false a value": "WAS pinned — booleanCell.nonBooleanScalar-8582.test.tsx and cellRenderers.fabricatedValue-8490.test.tsx",
          "DateCellRenderer's !value treats the epoch as empty": "WAS NOT PINNED — nothing in the tree asserted `date` holding 0; the nearest pin asserts the opposite direction (a numeric epoch of 1700000000000 still renders). At risk of silent flattening; pinned now.",
          "RelatedList's local predicate": "WAS pinned — RelatedList.emptinessAgreement-8459.test.tsx, DOM-level"
        }
      },
      "tests": "All on branch head eb813566e; every exit code redirected to a file before it was read, never through a pipe. SUITES: core+fields `Test Files 294 passed (294) / Tests 5678 passed (5678)` exit 0; plugin-list+plugin-kanban `121 passed (121) / 1165 passed (1165)` exit 0; plugin-detail `160 passed (160) / 1488 passed (1488)` exit 0 — 575 files / 8331 tests / 0 failures. ABLATION (mutate, prove the edit reached disk via target-text count to zero + injected marker + git hash-object differing from the HEAD blob, run, restore with `git checkout HEAD -- path`, prove restored by the blob matching HEAD and an empty `git diff HEAD`): leg A dropped the [] member from isEmptyValue -> ALL FOUR pin files RED, 12 failed / 77 passed; leg B flattened JsonCellRenderer to a bare isEmptyValue(value) -> 7 RED, each reporting `expected '—' to contain '[]'`; leg C flattened DateCellRenderer's !value to isEmptyValue(value) -> 1 RED, `date holding 0: the epoch is empty here on purpose`, 1 failed / 58 passed. All three restored byte-for-byte; restored tree re-run: `Test Files 5 passed (5) / Tests 104 passed (104)`. No rebuild was needed for the ablation: vitest.config.mts aliases @object-ui/core to packages/core/src, so the pins read source.",
      "gates": [
        {"gate": "dependency-closure build (plugin-kanban^... + plugin-list^...)", "exit": 0},
        {"gate": "doc-gate closure build (turbo, --build-filter, --concurrency=2)", "exit": 0, "reading": "35 successful, 35 total"},
        {"gate": "type-check, the 5 affected packages", "exit": 0},
        {"gate": "vitest core + fields", "exit": 0, "reading": "294 files / 5678 tests"},
        {"gate": "vitest plugin-list + plugin-kanban", "exit": 0, "reading": "121 files / 1165 tests"},
        {"gate": "vitest plugin-detail", "exit": 0, "reading": "160 files / 1488 tests"},
        {"gate": "lint, the 5 affected packages", "exit": 0, "reading": "0 errors in all five; 2193 pre-existing warnings, none on a changed line"},
        {"gate": "check-control-bytes.mjs", "exit": 0, "reading": "scanned 7180 tracked text file(s); skipped 85 binary"},
        {"gate": "check-changeset-presence.mjs", "exit": 0, "reading": "7 source file(s) of 5 released package(s) changed, 1 changeset declared"},
        {"gate": "check-governed-queue-guard.mjs --test (8 paths)", "exit": 0, "reading": "NOT GOVERNED — 8 path(s) checked against 5 governed surface(s); none matched"},
        {"gate": "check:new-line-citations", "exit": 0, "reading": "0 new citation(s), enforcement report-only"},
        {"gate": "check:doc-snippets", "exit": 0, "reading": "638 of 638 block(s) judged, 0 failed"},
        {"gate": "check:doc-types", "exit": 0, "reading": "Every documented component type is registered"},
        {"gate": "check:doc-examples", "exit": 0, "reading": "124 block(s) — 35 compile, 89 fail, 89 of those declared in the ledger. FIRST RUN was exit 2 = PREREQUISITE NOT MET (unbuilt dist), re-run after the scoped build; no ledger row was displaced by this diff"},
        {"gate": "check:esm-specifiers (derived: new module in a published barrel)", "exit": 0},
        {"gate": "check:dist-completeness --all (derived)", "exit": 0, "reading": "12 package(s) complete, 1641 emitted files verified"},
        {"gate": "check:readme-exports (derived: new published export)", "exit": 0},
        {"gate": "check:unreferenced-sources (derived: new source file)", "exit": 0}
      ],
      "lint_narrowing_declared": "the repo-wide `turbo run lint` farm is CI's run. Narrowed to the 5 affected packages (5 of 47), at the package boundary the repo's own lint scripts use. It is a measurement rather than a skip: eslint.config.js sets no parserOptions.project and no projectService, so ESLint here is not type-aware and nothing in this diff can move the verdict on a file it did not touch.",
      "mcp_calls": "3 — create_pull_request, update_pull_request, add_issue_comment. Card body + all comments read through the zero-quota public-payload channel; PR list, PR files, labels and label write through REST (repo-scoped probe returned 200 this session).",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: the same four members are spelled privately in nine more files the card never counted — app-shell/src/utils/auditHistoryDisplay.ts, app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts, fields/src/widgets/LookupField.tsx, plugin-grid/src/useColumnSummary.ts, plugin-grid/src/components/BulkActionDialog.tsx, plugin-gantt/src/ObjectGantt.tsx, core/src/validation/required-presence.ts, components/src/custom/filter-builder.tsx, components/src/renderers/form/form.tsx. Not filed: none is a defect (each already answers the floor), so it fits none of the three filing categories. Successor named: this card's own follow-through — the floor is exported now and each is a one-line swap. Note components/** would need its dependency on core checked first, or it repeats option C's inversion.",
        "noted, not filed: core's own utils/dashboard-filters.ts keeps a second private isEmptyValue. It takes a filter def and answers 'carries no constraint' — a different question that happens to open with the same four members. Left alone rather than half-converted; successor: the same follow-through.",
        "noted, not filed: BulkActionDialog.tsx returns a bare em-dash string for the floor. That is the objectui#8507 bare-em-dash taxonomy, already open — not re-filed.",
        "FALSIFIED (dispatch assumption, ZONE 2): the four pinned disagreements are NOT all pinned. DateCellRenderer's epoch-as-empty had no test anywhere in the tree. Pinned in this PR rather than left at risk.",
        "FALSIFIED (dispatch assumption, ZONE 2): 'five surfaces' undercounts. See surface_count.",
        "FALSIFIED (dispatch snapshot, ZONE 2): re-derived serial adjacency from GET /pulls?state=open at 2026-09-10 — 9 open PRs, every file enumerated, and NO open PR touches any file in this diff. objectui#8568's live PR is #8977, whose diff is package.json plus scripts/dollar-dialect-alias-census.mjs and its test — it does not touch core/src/utils/filter-converter.ts or the README. objectui#8976 has no open PR at all. The remote branch claude/issue-8568-retire-lowercase-aliases carries no commits ahead of its merge-base.",
        "CHECKED, not a blocker (ZONE 2, comment 5603203484): the scalar-missing / collection-empty split is real but lives one layer above the predicate — it is about which COMPONENT states the emptiness (EmptyValue's fixed em-dash vs EmptyDescription's authored sentence) plus a text-size census in app-shell metadata-admin previews, a surface this card does not tabulate. The floor as ruled serves both: its four members are two scalar-missing, one blank scalar and one empty collection, and no call site asks a boolean to tell them apart because each knows statically which affordance it draws. No second axis invented; emptiness.ts says so and points the component question at objectui#8570 / #8526 / #8507."
      ]
    }

    Generated by Claude Code

  5. removed their assignment
    on Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatfindingpriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions