Skip to content

fix(fields): no empty-string member for "is empty" on a numeric or boolean column (objectui#10813) - #10834

Closed
objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/issue-10813-is-empty-one-meaning
Closed

objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/issue-10813-is-empty-one-meaning

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #10813
Clause-②: no

This lands step 2 of the card's execution notes and stops at the card's stop valve, so it leaves the card open: step 3 (one meaning of 「is empty」 across FilterConditionField, the dataset bridge and the spec) waits for triage's routing with the readings below. datasetFilterCondition.ts is untouched.

What changed

  • FilterConditionField (@object-ui/fields): 「is empty」 / 「is not empty」 on a field whose type is in @objectstack/spec's NUMERIC_VALUE_TYPES or BOOLEAN_VALUE_TYPES now write { FIELD: { $null: true } } / { FIELD: { $null: false } }, with no '' member. The classes are imported from @objectstack/spec/data, not hand-listed.
  • Every other column keeps the objectui#10790 bytes ({ $or: [{ FIELD: { $in: [''] } }, { FIELD: { $null: true } }] } / { FIELD: { $nin: [''], $null: false } }): the string-stored types (text, select, lookup, …), date / datetime / time (see the deviation below), and a field whose type the widget does not know (schema not loaded, or a hidden field).
  • Read-back is unchanged. Every stored shape the builder opened before still opens on the same operator, and opening a rule rewrites nothing. An old-shape rule on a numeric/boolean column takes the new shape only when an admin edits the criteria. The new shapes are the ones is_null / is_not_null write, so a reopened rule shows 「Is null」 / 「Is not null」, which is the same predicate on such a column.
  • Why not NON_TEXT_STORED_VALUE_TYPES: in the installed spec it is exactly numeric ∪ boolean, but it belongs to the text-operator gate, and the spec's main line has widened it to the temporal classes by ruling. Adopting it would silently move temporal columns onto $null on a spec upgrade.

Readings (a) (b) (c)

(a) Cleared text is stored as '', and the objectstack write path does not normalise it.

  • objectui, runtime probe (a throwaway test, not committed): an ObjectForm edit of a record with stage: 'open', text input cleared and submitted. Each of the flat, modal and drawer routes called dataSource.update('deal', 'd1', {"stage":""}, {ifMatch}). The hops, by symbol: TextField onChange(e.target.value) → formWritePayload → sanitizeFormData (no '' arm) → dirtyEditPayload / isSameStoredValue ("'' is NOT a blank here") → ObjectStackAdapter.update forwards data verbatim.
  • The same probe on typed inputs sent {"due_on":""} for a cleared date, {"met_at":""} for datetime, {"starts":""} for time, and {"amount":null} for number.
  • objectstack @ 17bd3187 (read only, no probe: the shared checkout has no built objectql / driver dist):
    • record-validator isMissing counts '' as missing for validation only and never rewrites it;
    • the engine's only write-side value rewrites are the secret/password arms (encryptSecretFields, refuseEmptyPasswordFields);
    • driver-sql formatInput has no '' arm, and temporalStorageForm returns '' unchanged by design ("Total, on purpose");
    • the spec's own round-trip fixture VALUE_ROUNDTRIP_CASES row str_empty pins "empty string must not become null", and the memory / sql / sqlite-wasm / turso conformance suites run it.
  • ⇒ The valve trips on (a).

(b) numeric_col IN ('') / date_col IN ('') on Postgres: not measured, because no Postgres is reachable.

  • Nothing listens on 5432. The psql client and PG 16 server binaries (/usr/lib/postgresql/16/bin) are present but not running, and the dispatch forbids starting one.
  • Read-only, the driver does bind '' into those columns. $in → whereIn(field, coerced), and $nin → applyNullSafeNegative(… orWhereNotIn …).
  • coerceFilterValue returns a non-temporal value unchanged, and a temporal one through temporalStorageForm, which hands '' back unchanged. The engine's temporal comparand door scopes '' out by ruling (isUninterpretableTemporalComparand: "The empty-string cell is its own card"). assertOperatorAppliesToColumn refuses only on JSON columns.
  • The column DDL: the numeric classes become integer / decimal (numericColumnFor), boolean / toggle become boolean, date becomes date, datetime becomes timestamp, and time becomes time.
  • An analogous live reading is on record, but it is not this filter: driver-sql's changelog records PostgreSQL 16.13 answering invalid input syntax for type integer for a non-numeric string bound into an integer column on insert.
  • Whether this step is a p1 on its own therefore stays open. If a probe shows (b) raises, it is.

(c) Stored sharing rules / roll-up filters carrying the widget's shape.

  • Readable stores: 0 in objectui @ 33e58d8 and 0 in objectstack @ 17bd3187.
    • Every objectui hit is the widget itself, its tests, the objectui#10790 changeset or the census test.
    • The objectstack hits are two changesets and one spec test.
    • examples/ has 0 hits in both repos: the example sharing rules use a CEL condition, and no example roll-up has a filter.
  • Production: not readable.
  • The command, printed so it can be re-run as is (the pattern hits both stored shapes on a two-line control):
RE='\$n?in\\?"?\s*:\s*\[\s*(null\s*,\s*)?(\\"\\"|'"''"'|"")\s*\]'
git -C objectui grep -c -E "$RE" 33e58d8 -- .
git -C objectstack grep -c -E "$RE" origin/main -- .

Valve verdict: it trips on (a), and on (c) under ruling P2 (production is unreadable, so it counts as not cleared). The PR stops after step 2.

Deviation from the dispatch's example list (measured)

  • lookup keeps ''. REFERENCE_VALUE_TYPES is stored as "a record-id string". Driver-sql makes lookup / user a varchar (table.string), so no cast is involved. The engine's isEmptyReferenceValue reads '' as "no link" and stores it unchanged, so dropping '' there would re-scope stored rules.
  • date / datetime / time keep ''. The form sends a cleared temporal box as '' (probe above). The spec calls their stored form a dialect question (ISO TEXT on SQLite), and on SQLite the '' is kept. Dropping the member would change which rows a rule matches there, which is the P2 access-boundary change. This goes back to the card as an open question in the report. The tests pin it as a CONTROL row, so moving them later is a visible, deliberate change.

Verification (at 3654509)

  • pnpm --filter @object-ui/fields type-check: exit 0. The output echoes @object-ui/fields@17.6.0 type-check. tsc -p tsconfig.test.json --listFiles includes both touched test files.
  • pnpm exec vitest run --maxWorkers=2 packages/fields/ packages/app-shell/src/views/metadata-admin/inspectors/filter-builder-protocol-ids-census-9306.test.ts: Test Files 217 passed | 1 skipped (218), Tests 3662 passed | 7 skipped (3669). The census file is the only test outside packages/fields that calls the emitter.
  • Narrowed check:spec-floors: the gate's own analyze() with the population set to @object-ui/fields, after pnpm --filter @object-ui/fields build. It judged 7 (subpath, symbol) pairs against @objectstack/spec@17.0.0 ./data, with 0 findings, and the built dist/index.js imports NUMERIC_VALUE_TYPES / BOOLEAN_VALUE_TYPES. The full gate needs every workspace package built, so its repo-wide run is left to CI. Every other package's floor is judged against its own dist, which this diff does not touch.
  • Exit 0 at this HEAD: check:spec-symbols, check:phantom-deps, check:new-line-citations (0 new), check:control-bytes, check:test-path-roots, check:vi-mock-specifiers / -inherit / -override-shape, check:pending-changeset-literals, check:changeset-claims, check:installed-pin-claims, changeset:check, and check-changeset-presence.mjs (1 changeset for 3 published source files).
  • Narrowed eslint (--no-inline-config) on the 3 touched source/test files: --format json counts 3 files, 0 errors and 33 warnings. All 33 are pre-existing: the base version of the widget file gives the identical 33 by rule. Type-aware linting is not enabled in eslint.config.js, so this diff cannot move the verdict of an untouched file. The repo-wide pnpm lint is left to CI.

Ablation (one-off, no permanent file)

  • The two emitting arms were reverted to the base shapes with ablation-replace.mjs. The anchor hit exactly once (1 → 0), and the blob went 2e41853e… → 65d3c08c….
  • Result on FilterConditionField.typedEmpty-10813.test.tsx: 15 failed | 39 passed (54), against a baseline of 54/54. The 15 were predicted before the run and match: 4 WRITER rows (number and toggle × 2 operators), 9 CLASSES rows (numeric ∪ boolean), and 2 RE-SAVE rows.
  • Restore was proven: blob == HEAD (2e41853e…) and git diff HEAD is empty.

Acceptance notes

  • Same class, not fixed here (reported for the card): multi-value columns (multiselect / checkboxes / tags, and multiple: true fields) are JSON columns in driver-sql. Its column-type gate refuses $in / $nin there (JSON_COLUMN_INCOMPATIBLE_OPERATORS, pinned by sql-driver-compile-refusal-seam.test.ts), yet the widget still writes the '' member for them. $null alone is not the fix either, because a cleared multi-value field may store []. That is a meaning question for the card.
  • formula keeps today's shape: its value class is its declared returnType, which this widget does not read.
  • The pending .changeset/10790-empty-operators-accepted-shape.md says every column stores the $or / $nin shapes. After this change that holds for every column except numeric and boolean. Both changesets publish together, and this one names the narrowing. The 10790 file is left untouched.
  • filter-builder.tsx's operator comment ("which also treat '' as empty") is now true only of columns that can store ''. That file is PR objectui#10823's, so it is left untouched.

Session for this change: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY


Generated by Claude Code

…ean or temporal column (objectui#10813)

Step 2 of objectui#10813. FilterConditionField wrote "is empty" as
`{ $or: [{ F: { $in: [''] } }, { F: { $null: true } }] }` and "is not empty"
as `{ F: { $nin: [''], $null: false } }` for every field type, so a number,
date or boolean column received `IN ('')` / `NOT IN ('')`, which the SQL
driver binds as-is. A field in the spec's numeric, boolean, calendar-day,
instant or time-of-day value class now gets the `$null` half alone; every
string-stored column, and a field of unknown type, keeps today's bytes.

Reading back is unchanged: every stored shape the builder opened before
still opens on the same operator, and nothing is rewritten on read.

Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY
Co-authored-by: Claude <noreply@anthropic.com>
…i#10813)

vitest reads `$null` in an `it.each` title as the row's `null` key and
printed "writes the undefined half alone".

Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY
Co-authored-by: Claude <noreply@anthropic.com>
…(objectui#10813)

Measured: an edit form sends a cleared date, date-time or time box as `''`
(a cleared number box as `null`), and the platform stores `''` unchanged
where a temporal column is text, which the spec names a dialect question.
Dropping the `''` member on those columns would change which records a
stored rule matches on such a backend, which the card's stop valve rules
out. The `$null`-only shape now covers the numeric and boolean value
classes alone; the temporal classes are carried back to the card as an
open question.

Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3078.7 KB 3104.5 KB
Main entry chunk (gzip) 148.3 KB 350 KB
Entry file index-CGCiy7li.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.57KB 6.15KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.17KB 10.58KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 556.80KB 133.32KB
core (index.js) 9.92KB 3.93KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 224.95KB 62.49KB
fields (index.js) 260.99KB 66.28KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.28KB 11.09KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 51.67KB 14.70KB
plugin-charts (index.js) 83.58KB 22.75KB
plugin-chatbot (index.js) 197.67KB 46.90KB
plugin-dashboard (index.js) 134.64KB 35.75KB
plugin-designer (index.js) 216.25KB 44.39KB
plugin-detail (index.js) 233.12KB 61.68KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 158.35KB 40.47KB
plugin-gantt (index.js) 169.83KB 41.99KB
plugin-grid (index.js) 218.66KB 59.90KB
plugin-kanban (index.js) 48.43KB 15.11KB
plugin-list (index.js) 114.78KB 28.44KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.91KB 9.05KB
plugin-tree (index.js) 10.64KB 3.75KB
plugin-view (index.js) 87.89KB 22.03KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 119.16KB 39.05KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 7.50KB 3.05KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.16KB 2.71KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 187/187 CONTRACT_REVIEW_TIER
Head-sha: 3654509b3bcddd67559641b190341522ad1395a8

Read: PR objectui#10834 (body, 4-file list, diff; head 3654509b3b, base 610819c40d = origin/main, merge-base 33e58d81a1; 3 commits 2070174, 098dcdc, 3654509); the card objectui#10813 body and its thread (triage 5858050222, claim 5858305607; the os-dev-report 5858799523 treated as claims only); objectui#10790 and merged PR objectui#10807 (97672bae18, its 5-file list); the head sources of packages/fields/src/widgets/FilterConditionField.tsx, both test files, packages/components/src/custom/filter-builder.tsx (operatorsForFieldType and its buckets, unchanged between merge-base and origin/main), packages/fields/src/widgets/{Number,Currency,Percent,Rating,Slider,Boolean,Date,DateTime,Time}Field.tsx, packages/plugin-form/src/sanitize.ts, the app-shell census test; objectui AGENTS.md §9 (changeset-presence rule, 版本号策略, 测试纪律), .changeset/config.json (fixed group of 39, @object-ui/fields in it), the 2,199 pending .changeset/*.md at the head (9 name FilterConditionField; 10790-empty-operators-accepted-shape.md and the new 10813-typed-empty-null-only.md read in full), scripts/check-changeset-claims.mjs and scripts/check-pending-changeset-literals.mjs headers; the installed @objectstack/spec 17.4.0 (the lockfile pin at the head; value classes read by importing its published dist/data/index.js from a pnpm-store copy, and again from my own throwaway install); objectstack origin/main 17bd318771: packages/spec/src/data/field-value.zod.ts, filter-comparand-shape.ts, value-roundtrip-conformance.ts, packages/objectql/src/validation/record-validator.ts, packages/objectql/src/engine.ts insert/update seams, packages/drivers/driver-sql/src/sql-driver.ts (DDL, formatInput, coerceFilterValue, assertOperatorAppliesToColumn, the $in / $nin / $null arms), packages/drivers/driver-memory/src/{memory-driver,memory-matcher}.ts, packages/drivers/driver-mongodb/src/mongodb-driver.ts, driver-sqlite-wasm and driver-turso class declarations, packages/core/src/utils/temporal-storage-form.ts, packages/rest/src/import-coerce.ts / import-runner.ts; the head's check-runs twice; one run of the two touched test files at the head in my own throwaway worktree (installed offline from the pnpm store, run through os-verify-lock.sh with slot review-10834, worktree removed afterwards without --force). No GitHub write, no MCP tool, no edit of either checkout.

① Derived judgments

1. No stored rule re-scoped (the card's hard line) — WRONG. A numeric or boolean column CAN hold '' through the platform write path, on more than one shipped driver, so a stored rule's record set moves when the widget re-saves it in the new shape.

  • The write door: packages/objectql/src/validation/record-validator.ts isMissing(v) is v === undefined || v === null || (typeof v === 'string' && v.trim() === ''), and validateOne returns at if (isMissing(value)) return null; // nothing else to check BEFORE the number branch (invalid_number, which covers only number / currency / percent / rating / slider), before the boolean branch (invalid_boolean) and before the strict value-shape door (valueShapeStrictEffective, further down the same function). So { amount: '' } or { is_hot: '' } on a non-required field passes validation unchanged. The engine's insert and update seams (engine.ts around the normalizeMultiValueFields / validateRecord / evaluateValidationRules calls) rewrite nothing else on the way to the driver; refuseEmptyPasswordFields / encryptSecretFields are password/secret arms only. No blank-to-null normaliser exists in packages/rest/src, packages/runtime/src or packages/objectql/src (grep: none); packages/rest/src/import-coerce.ts turns a blank CSV cell into undefined (dropped), so the import lane is safe, but the ordinary record API (data.insert / data.update with a JSON body) is not.
  • driver-sql formatInput (sql-driver.ts, the method at the protected formatInput(object, data) line) has arms for the NOW() literal, datetime/date/time canonicalisation (each guarded if (v == null) continue, so '' is handed to storageDatetimeValue / toDateOnly / canonicalTimeOfDay, all of which return '' unchanged — canonicalUtcDatetime in temporal-storage-form.ts: if (s === '') return value), JSON stringify, media, and the SQLite object stringify; there is NO numeric or boolean arm. The DDL maps the numeric class to table.integer / table.decimal (numericColumnFor) and boolean / toggle to table.boolean; no STRICT table is created (grep: none). On SQLite (SqlDriver with the sqlite dialect, and SqliteWasmDriver extends SqlDriver, TursoDriver extends SqlDriver), a bound '' in an INTEGER / REAL / NUMERIC-affinity column is kept as TEXT '' because it is not a well-formed numeric literal. On Postgres / MySQL the bind is refused at insert, so those backends cannot hold it.
  • driver-memory create pushes toStoredRecord(...) (temporal canonicalisation only) and stores '' verbatim; driver-mongodb create does insertOne after temporal coercion only.
  • The read side then splits: driver-sql $in compiles to whereIn(field, coerced) with coerceFilterValue returning a non-temporal value unchanged, so amount IN ('') matches the TEXT row, while $null: true compiles to whereNull and does not; $nin: [''] goes through applyNullSafeNegative(... orWhereNotIn ...) and excludes it, while $null: false (whereNotNull) includes it. driver-memory's matcher: $in is target.includes(value) (matches ''), $null: true is value != null (a stored '' is not null). No engine door refuses the shape: assertListComparandShapes is shape-only (no schema, no type), and the only typed door is the temporal comparand door (temporal-comparand-door.ts), which does not cover numeric or boolean columns.
  • Net effect on a stored sys_sharing_rule.criteria_json: 「is empty」 on a numeric or boolean column stored as { $or: [{ F: { $in: [''] } }, { F: { $null: true } }] } matches such a row today; after the admin's next edit the widget writes { F: { $null: true } }, which does not. 「Is not empty」 goes the other way — { F: { $nin: [''], $null: false } } excluded the row, { F: { $null: false } } shares it: an access-boundary widening on the very path the card flags. The PR's NON_STRING_VALUE_TYPES docblock states 「stored value is never a string」 and 「No form writes '' into them either」; the second is true of objectui's form (NumberField / CurrencyField / PercentField send null on clear, RatingField / SliderField send numbers, BooleanField sends !!checked), the first is true only by declaration — the platform's write door does not enforce it, and the PR's own reading (a) already recorded that isMissing 「never rewrites」 ''. The count in (c) was 0 in readable stores and production is unreadable, which is exactly the case the triage's valve names: 「⛔ Do not re-scope stored sharing rules on this card's authority」.

2. Which field types fall in each bucket — partly RIGHT, and WRONG on consistency with the hard line. The widget's typeOf(field) returns the object schema's f.type for every non-hidden field outside NON_FILTERABLE, so the vocabulary is the spec's FieldType at large. At the head: the $null-only shape goes to the installed spec's NUMERIC_VALUE_TYPES ∪ BOOLEAN_VALUE_TYPES = number, currency, percent, rating, slider, progress, summary, boolean, toggle (read from @objectstack/spec 17.4.0, identical to the changeset's list); every other type and an unknown type (t === undefined) keeps the objectui#10790 bytes: the string class (text … qrcode), select / radio, lookup / master_detail / user / tree, date / datetime / time, the multi-option class, formula, autonumber. Reachability through the real dropdown (operatorsForFieldType): number / currency / percent / rating sit in numberOperators (offers both operators); slider / progress / summary / toggle fall to textOperators (offers both); boolean alone sits in booleanOperators (offers neither, so its $null arm is reachable only through the re-save of a stored old-shape row). The classification is sourced from two spec sets rather than a restated list — RIGHT — and the refusal to import NON_TEXT_STORED_VALUE_TYPES is well-founded: in 17.4.0 it equals numeric ∪ boolean, but objectstack origin/main field-value.zod.ts already spreads CALENDAR_DATE_TYPES, INSTANT_TYPES and CLOCK_TIME_TYPES into it 「by RULING, not by storage」, so adopting it would move temporal columns on a spec bump. Keeping date / datetime / time on the '' shape is consistent with item 1's hard line (DateField and TimeField emit e.target.value, '' on clear; isMissing passes it; formatInput hands it to the temporal canonicalisers, which return it unchanged; a SQLite date / timestamp / time column keeps it as TEXT), and so is keeping lookup (varchar) and formula (return type unread). What is NOT consistent is the other half: the same door that lets '' into a date column lets it into an integer or boolean column, so the line the PR draws — 「the columns whose stored value can be ''」 — is drawn by declared class, not by what the write path admits, and item 1 follows.

3. Read-back — RIGHT. kvToCondition is type-agnostic: $in: [null, ''] reads as is_empty, $nin: [null, ''] as is_not_empty (the pre-objectui#10790 shapes); a lone $or entry reads through isEmptyEntryField as is_empty; { $nin: [''], $null: false } reads as is_not_empty; $null: true / false read as is_null / is_not_null. No arm was added or changed by this PR. Opening rewrites nothing: the localGroup re-seed effect calls setLocalGroup(group) with the memoised group reference (no onChange), and once the schema loads localKey diverges from storedKey for an old-shape rule on a number column, which only re-seeds the same reference. Pinned at the head by the four OLD READER rows on amount (onChange never called, asserted after the field label 「Amount」 is on screen, i.e. after the schema loaded) and by the 10790 file's READER rows on name. The new $null shapes reopen under the 「Is null」 / 「Is not null」 labels (same predicate on such a column), pinned; toggle shares the text bucket so those labels are offered there too. Confirmed by my own run: 77/77 across both files.

4. Pending changesets — WRONG (secondary defect). .changeset/10790-empty-operators-accepted-shape.md (pending, verbatim into the CHANGELOG) says, unqualified by field type: 「They now store the spelling that refusal prescribes … 「Is empty」: { $or: [{ FIELD: { $in: [''] } }, { FIELD: { $null: true } }] } / 「Is not empty」: { FIELD: { $nin: [''], $null: false } }」 and 「the builder writes the new shape the next time any row of that criteria is edited」. At this head both sentences are false for the nine numeric/boolean types (the widget writes { FIELD: { $null: true } } / { FIELD: { $null: false } } there, including on re-save). The new .changeset/10813-typed-empty-null-only.md restates the prior behaviour (「wrote … on every field type, number included」) and the narrowing, but it does not name the 10790 declaration or say which of its sentences it supersedes, and the 10790 file is untouched (the PR body: 「The 10790 file is left untouched」; the report: 「It is not my file」). The repo's own doctrine in scripts/check-changeset-claims.mjs names this class — 「WENT FALSE — true when written, falsified by a LATER merge (objectui#7721, objectui#8617)」 — and that gate is report-only and keyed on backticked FILE names, which is why 「FilterConditionField」 (a symbol) did not trip it. Sentence by sentence, the new changeset: front matter '@object-ui/fields': patch — right; 「wrote 「Is empty」 as … on every field type, number included」 — true at the merge-base (case 'is_empty': return isEmptyEntry(field) unconditionally at 33e58d8); 「the SQL driver binds that '' as-is, so it reached the column as IN ('') / NOT IN ('')」 — true (coerceFilterValue passes a non-temporal value through; whereIn / orWhereNotIn); 「a comparand a strict backend has to cast」 — true in direction, unmeasured (the PR says so); the nine-type list — matches the installed 17.4.0 classes; the two new shapes — match condToMongo at the head; 「Every other column keeps the shapes above byte for byte … because there '' is a value a record can hold」 — the first half true (CONTROL pins), the causal clause is the item-1 error in miniature, since the same holds of the nine types; the temporal sentence — true as far as it goes (DateField / TimeField emit '', the validator passes it, the SQLite column keeps it); the last paragraph on reopening and re-save — true (READER / RE-SAVE pins).

5. Pins — RIGHT. On the merge-base the two arms are unconditional (FilterConditionField.tsx:316-317 at 33e58d8), so of the new file's 54 tests these are red on base: WRITER 4 (number, toggle × 2 operators, exact-bytes toBe against the $null shapes), CLASSES 9 (every member of numeric ∪ boolean), RE-SAVE 2 — 15, matching the dev's ablation count; CONTROL 8 (text, select, lookup, date through the real dropdowns), the string/temporal/unknown CLASSES sweeps and the READER rows are green on base by construction and serve as controls. Both operators, the numeric and the boolean writer, the controls and the re-save path are covered; the CLASSES block reads the classes from the installed spec and guards against a vacuous sweep. The 10790 file's edits move only the number rows out of the two 「Is empty」 / 「Is not empty」 WRITER it.each tables (TYPES to STRING_STORED_TYPES); the equals CONTROL still runs on number, and READER, RE-SAVE and GROUPS are byte-identical. The removed assertions are replaced by stricter ones in the new file; nothing was weakened. My run at the head: Test Files 2 passed (2), Tests 77 passed (77) (54 + 23), under the lock, on a shared box.

② Semver level

'@object-ui/fields': patch; no major anywhere in the pending set (the Changeset Bump Policy check is green). Under AGENTS.md §9 版本号策略 (major follows @objectstack, objectui's own breaking changes are spelled minor), patch is admissible for a fix that narrows one widget's emitted document; if the maintainer reads the re-save re-scope in item 1 as a behavioural break, the policy's spelling for it is minor, never major. Clause-②: no holds: no export added (NON_STRING_VALUE_TYPES is module-private; the export list is unchanged), no accept set widened (kvToCondition gains no arm; the writer only narrows). PR shape: line 1 Part of #10813, line 2 Clause-②: no at line start, no closing keyword anywhere in the body (regex scan over close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved followed by an issue reference: 0 hits); the PR is a draft, as the dispatch contract requires.

③ Boundary flags

  • Blocking (item 1): the change assumes 「a numeric or boolean column never holds ''」, which the platform's write door does not enforce (record-validator.ts isMissing short-circuit; no engine or driver-sql numeric/boolean arm; SQLite-family, memory and mongodb store it). This is not a widget defect alone: the honest fix is the producer-side door (a '' on a numeric/boolean/temporal column refused or normalised at validateRecord, which today skips type checks for any blank string, and whose number branch also omits progress and summary although both are in NUMERIC_VALUE_TYPES), after which the widget's narrowing cannot re-scope anything. That is the card's open question 1 option C, and it belongs on the card, not on this PR's authority.
  • Secondary (item 4): the pending objectui#10790 body publishes two sentences now false for nine types with no superseding note that names them; amend the 10790 body or name it in the 10813 body.
  • Label drift disclosed by the PR: a rule an admin authored as 「Is empty」 on a number/toggle column reopens as 「Is null」 after re-save. On a boolean-typed column neither is_empty nor is_null is in booleanOperators, so a $null row there draws with no offered operator — pre-existing for is_null, not introduced here, but the new arm makes it the re-save outcome of a hand-authored old-shape rule on such a column.
  • Reading (b) (Postgres IN ('') on a typed column) stays unmeasured; the PR says so.
  • Multi-option columns (JSON in driver-sql, $in / $nin refused by assertOperatorAppliesToColumn) still receive the '' member — recorded by the PR as out of scope; a [] store makes $null alone wrong there too.
  • CI at the head. Poll 1, 2026-09-27T19:06:06Z: 42 check-runs — 31 success, 3 skipped (Test (coverage shard …), Test (coverage), dependabot), 8 in_progress (Spec Main Shape Gate, Test (shard 1/8), 2/8, 3/8, 4/8, 5/8, 7/8, 8/8), 0 failed. Poll 2, 2026-09-27T19:19:25Z: 43 check-runs — 40 success (all eight shards, Spec Main Shape Gate, Lint, Type Check, Changeset Claim Re-read, Changeset Bump Policy, Changeset Declaration, Line Citation Gate, Governed Surface Queue Guard, Test (dist pins), Build & E2E, the aggregate Test which appeared between polls, and the rest), 3 skipped, 0 failed, 0 running. Only the Console Performance Budget bot commented on the PR (PASS); no changeset-claim-re-read comment was posted.
  • Scratch: worktree …/scratchpad/pr-10834/review/wt created detached at the head, installed offline in 7s, tests run once under the lock (acquired after 228s of queue, held 12s), node_modules deleted and the worktree removed with plain git worktree remove; git worktree list no longer shows it. The only trace left in /home/user/objectui is the review ref refs/review/pr-10834 the instructions prescribed.

Implemented-by: claude/issue-10813-is-empty-one-meaning
Reviewed-by: session_01MCg3z5cGrV51xEddpGcxEY

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Closed unmerged by the domain:ui seat 4 (session_01MCg3z5cGrV51xEddpGcxEY) · 2026-09-27T19:26Z.

The at-tier review above (5859057830) is FAIL on the card's hard line. The triage note called step 2 「safe in every branch」, but the platform's write door does not keep '' out of a numeric or boolean column. record-validator.ts returns at if (isMissing(value)) return null; before its number and boolean checks, and driver-sql's formatInput has no numeric or boolean arm. So '' is stored on SQLite, sqlite-wasm and turso, and on the memory and mongodb drivers. The seat re-read the short-circuit on objectstack origin/main 17bd3187. A stored sharing rule re-saved by this widget would then change the rows it matches, and 「is not empty」 would widen.

The fix order this points to (producer and write door first, then the widget) goes back to the card with the readings. The branch claude/issue-10813-is-empty-one-meaning stays, and this PR can be reopened if triage routes the widget half back unchanged.


Generated by Claude Code

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants