Skip to content

feat(core,react): declare record.* the row-predicate canon and warn on the two deprecated spellings - #5737

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-5330-record-predicate-canon
Aug 23, 2026
Merged

feat(core,react): declare record.* the row-predicate canon and warn on the two deprecated spellings#5737
os-sam merged 2 commits into
mainfrom
claude/issue-5330-record-predicate-canon

Conversation

@os-sam

@os-samos-sam commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Fixes#5330

Implements the maintainer ruling of 2026-08-20 (option B): the canon is record.*; the bare shorthand and data.* enter a deprecation window. No spelling is removed, and none may be before a stored-metadata survey sizes the window.

The first measurement: the server's accept set

The ruling made this the dev's first measurement, because the canon statement must state the server's accept set, not the client's. Measured against @objectstack/formula@17.1.0 — the engine the server evaluates with, and the one packages/core's fieldRules.ts already delegates to:

spellingserver runtime (buildScope + celEngine)server authoring oracle (firstUndeclaredReference)
record.status{ ok: true, value: true }accepted (null)
bare statusUnknown variable: statusrefused ('status')
data.statusUnknown variable: datasilently accepted (null)

buildScope({ record }) mounts exactly ['record']data is never bound and the row's fields are never flattened to top level. The server accepts record.* and nothing else. The renderer's three-way binding has no server counterpart, which is precisely why warning about it is the client's job.

data.* is the dangerous one, and the reason this warning is worth shipping.datais in @objectstack/formula's SCOPE_ROOTS, so the server's bare-identifier oracle waves data.status through — that list is a deliberately generous "never faults" lint baseline, not the runtime accept set. A data.* row predicate therefore passes every authoring gate the platform has and then binds nothing at runtime: not an error, a constant false. A visible that is constantly false is a button that silently never appears — the #4075 fail-closed signature, which is exactly the family this card descends from.

The deprecation is layer-scoped, not platform-wide

The card's framing calls data.* "legacy". The measurement says otherwise: data is the canonical root one layer over, in a metadata-editing form. objectstack's CANONICAL_ROOT_BY_LAYER reads { runtime: 'record', metadata: 'data' } (ADR-0089 D3), and objectui's own app-shell metadata-admin SchemaForm binds { data: row } on purpose. Declaring data.* deprecated unqualified would have contradicted ADR-0089 D3 and broken the metadata-editing layer's contract, so the canon here is scoped to the runtime record layer and the detector stands down elsewhere.

Where the warning lives, and who sees it

usePredicateRecordContext sees the row but never the predicate text, so it structurally cannot detect a spelling. The detection has to sit where row and text meet — and there are two such places, not one:

  • evalRowPredicate (@object-ui/core) — list/row surfaces, row kebabs, the bulk bar, kanban formatting, page:header.
  • useCondition (@object-ui/react) — the four generic action renderers and record:alert, via usePredicateRecordContext.

A warning wired into only the first would have missed exactly the surfaces this card is about while reading as covered, so both tiers call one shared detector in packages/core/src/evaluator/rowPredicateCanon.ts. It surfaces in the dev console at first evaluation — the same channel as the existing legacy-dialect and failed-predicate warnings, and the same channel the sibling #4356 deprecation uses. Production is gated off, matching dashboard-filters.ts: a deprecation warning that floods a production console is a warning that gets muted.

Detection reuses the server's own oracles (collectCelRootIdentifiers, firstUndeclaredReference) rather than a regex, so no second dialect judgement is invented client-side; an unparseable source is not this module's verdict to give.

Three conservative stand-downs, each of which can only remove a report — a false deprecation warning sends an author to rewrite a predicate that was correct:

  • bare shorthand is reported only when the undeclared identifier is an own key of this row (a deployment global not in SCOPE_ROOTS would otherwise read as a bare field);
  • data.* is reported only when data actually names the row (a rowless dialog or metadata-editing form is left alone);
  • legacy ${…} predicates are never reported — in that dialect data.* is the correct spelling, and useCondition's own documented example is '${data.status === "active"}'. This is the case that would have made the warning unshippable if it were wrong.

Prose homes

The card's premise named two; there are three, and one was already correct. packages/plugin-detail/.../record-alert.tsx:40-42 already states the canon and cites the 2026-08-20 ruling — written by PR #5688 — so it is untouched. The two that presented the three spellings as peers now declare the canon: packages/core/src/evaluator/listConditional.ts (evalRowPredicate) and packages/react/src/hooks/useExpression.ts (usePredicateRecordContext, plus useRowPredicate's summary, which taught the shorthand without qualification).

What #5688 already covers (not duplicated)

PR #5688 (8a44390, merged 2026-08-22) touched plugin-detail/src/renderers/record-alert.tsx, its record-alert.rowBinding.test.tsx, and a changeset. Its three-spelling pins for record:alert are not re-pinned here; this PR pins the shared evaluator tiers those renderers sit on, plus the detector itself.

Verification

Union re-run on the final commit a0ba08e:

gatereading
vitest run packages/core/ packages/react/Test Files 148 passed (148) · Tests 2669 passed (2669)
type-check (core, react)packages/core type-check: Done · packages/react type-check: Done
pnpm lint (whole repo, 47 packages)Tasks: 47 successful, 47 total · 0 errors
check-control-bytes✅ OK (scanned 4788 tracked text file(s); skipped 85 binary)
check-changeset-presence✅ 5 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)

Consumer sweep — plugin-detail + components/renderers/action: 104 passed (104) files, 1117 passed (1117) tests, confirming the new warning breaks no consumer that asserts on console.warn.

Lint is the full repo run, not a narrowed one, so no narrowing argument is needed. The 9 no-explicit-any warnings on useExpression.ts are pre-existing (9 at base 8549453, 9 at head; zero any in added lines).

Ablation, both tiers. Each mutation was confirmed on disk before the run (call-site count 1→0 plus an injected marker present exactly once), each script carried a trap … EXIT INT TERM restore, and each restore leg was verified (marker absent, call site back, tree clean, suite green again). vitest aliases @object-ui/core to packages/core/src, so no build is in the resolution path for either tier and no rebuild leg applies.

  • removing the core call: 3 failed / 12 passed (from 15/15)
  • removing the react call: 2 failed / 5 passed (from 7/7)

In both cases the reds are exactly the positive warning assertions, and the "stays silent" pins correctly stayed green — a warning that is removed cannot make a silence assertion fail.

Scope

Clause ② does not trip: the accept/reject set does not move, the three-way binding is unchanged, and the warn is a warning rather than a refusal. No file outside the declared surface was touched — content/docs/api/schema-reference.md (fenced to #5667) and packages/components/src/renderers/basic/** (fenced to #5717) are untouched, as are all usePredicateRecordContext consumers, which were read but not edited.

The survey this warning enables

Removal is Phase 2 and is deliberately not attempted here. The warning is what makes the survey possible at all (ADR-0078 — a tolerance nothing ever reports can never be retired, because nothing would ever show that the last document carrying it is gone). A sizing survey needs to sample, per stored visible/disabled/enabled predicate on a runtime record surface: the CEL root each predicate uses (record / data / bare), bucketed by tenant and by whether the metadata is customer-authored or ships in an example app. detectNonCanonicalRowSpelling is exported precisely so that sweep can run offline over stored metadata rather than waiting for a render.

⛔ Phase 0 is not done and is out of scope for this card: objectui's own docs and example apps have not been checked for places that still teach the shorthand. Warning authors while the docs still teach the form is the contradiction users report as a bug — the #4356 precedent shipped Phase 0 in the same PR. Filed separately rather than absorbed here.


Generated by Claude Code


Generated by Claude Code

… on the two deprecated spellings (#5330)
Implements the maintainer ruling of 2026-08-20 (option B): the canon is
`record.*`; the bare shorthand and `data.*` enter a deprecation window,
warned about now and removable only after a stored-metadata survey.
The canon states the SERVER's accept set — the ruling's stated first
measurement. Measured on `@objectstack/formula@17.1.0`:
`buildScope({ record })` mounts exactly `['record']`, so a bare field
faults `Unknown variable: status` and `data.*` faults `Unknown variable:
data`. The renderer's three-way binding has no server counterpart.
`data.*` is lint-silent and runtime-fatal: `data` IS in SCOPE_ROOTS (a
generous "never faults" lint baseline, not the runtime accept set), so it
passes every authoring gate and then binds nothing — a constant false,
which for `visible` is a button that silently never appears.
No spelling is removed; no predicate changes verdict.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7snar5mwF7qoXJazqKhys
#5330)
The two tiers are separate evaluation entries and a warning wired into
only one misses the surfaces this card is about: the generic action
renderers and `record:alert` go through `usePredicateRecordContext` +
`useCondition`, not through `evalRowPredicate`.
Each tier's pins assert both directions — the binding still resolves all
three spellings (no removal before the survey) AND the two deprecated
ones now report. Ablation-checked on both tiers: removing the core call
turns 3 of 15 red, removing the react call turns 2 of 7 red, and in both
cases the "stays silent" pins correctly stay green.
`record:alert`'s own three-spelling pins landed with PR #5688 and are not
duplicated here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7snar5mwF7qoXJazqKhys
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3916.7 KB3990.2 KB
Main entry chunk (gzip)152.5 KB350 KB
Entry fileindex-Coj-kniO.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)12.80KB4.47KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)32.45KB8.06KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)11.67KB4.55KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)510.39KB114.67KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)164.55KB45.67KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.41KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.34KB60.98KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.80KB27.20KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.61KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.59KB1.79KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-sam
os-sam marked this pull request as ready for review August 23, 2026 03:23
@os-sam
os-sam added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit d1ab06fAug 23, 2026
23 checks passed
@os-sam
os-sam deleted the claude/issue-5330-record-predicate-canon branch August 23, 2026 03:24
os-sam pushed a commit that referenced this pull request Aug 23, 2026
…e canon (#5738)
Phase 0 of the objectui#5330 row-predicate deprecation: stop TEACHING a
spelling the Phase-1 warning (PR #5737) now flags.
`packages/plugin-form/README.md` illustrated a field-level conditional rule
as `requiredWhen: 'status == "scheduled"'` — the bare shorthand. The same
README's own table two hundred lines up says these are "CEL predicates over
the live record, evaluated by `@objectstack/formula` — the same engine and
dialect the server enforces", and on that engine `buildScope({ record })`
mounts exactly `['record']`: the bare root faults there with
`Unknown variable: status`. `requiredWhen` is one of the two rules enforced
client AND server, so this was not merely non-canonical — it was the one
arm the server refuses outright, handed to authors as the example.
Confirmed with the shipped oracle rather than a regex: the exported
`detectNonCanonicalRowSpelling` reports `bare-shorthand → record.status` on
the old text and reports nothing on the new, so this cannot disagree with
the warning authors are seeing in the console.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7snar5mwF7qoXJazqKhys
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 23, 2026
…e canon (objectstack-ai#5738) (objectstack-ai#5758)
Phase 0 of the objectui#5330 row-predicate deprecation: stop TEACHING a
spelling the Phase-1 warning (PR objectstack-ai#5737) now flags.
`packages/plugin-form/README.md` illustrated a field-level conditional rule
as `requiredWhen: 'status == "scheduled"'` — the bare shorthand. The same
README's own table two hundred lines up says these are "CEL predicates over
the live record, evaluated by `@objectstack/formula` — the same engine and
dialect the server enforces", and on that engine `buildScope({ record })`
mounts exactly `['record']`: the bare root faults there with
`Unknown variable: status`. `requiredWhen` is one of the two rules enforced
client AND server, so this was not merely non-canonical — it was the one
arm the server refuses outright, handed to authors as the example.
Confirmed with the shipped oracle rather than a regex: the exported
`detectNonCanonicalRowSpelling` reports `bare-shorthand → record.status` on
the old text and reports nothing on the new, so this cannot disagree with
the warning authors are seeing in the console.
Claude-Session: https://claude.ai/code/session_01E7snar5mwF7qoXJazqKhys
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sam@claude