Skip to content

fix(related-list): compile the badge's parent scope by relationship arity - #9184

Merged
os-tesla merged 2 commits into
mainfrom
claude/issue-8882-related-badge-row-parity
Sep 11, 2026
Merged

os-tesla merged 2 commits into
mainfrom
claude/issue-8882-related-badge-row-parity

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #8882

The defect, measured

A related list on a multiple: true relationship rendered its rows above a tab with no count at all. Measured on a real detail page, the two reads went out as:

read $filter sent
ROW query (settled) { task_versions: { $contains: 'tv-1' } }
BADGE probe { task_versions: 'tv-1' }

Rows rendered: 2. Badge: null. The driver refuses equality on an array-valued column (400 INVALID_FILTER), and the count store's catch swallows that refusal without a setCount, so the store holds no entry and the tab draws nothing.

Two independent compilers of one condition: objectui#7299 taught the ROW side to compile by the relationship field's arity, and the badge's copy was not part of that change.

Shape — one implementation, not a matching patch

Patching the badge's copy to agree would have left the second compiler in place to drift again. So the condition now has one implementation:

  • New packages/core/src/utils/parent-scope.ts, exported from @object-ui/core:
    • composeParentScopeFilter(relationshipField, parentId, fields?) — returns the plain MongoDB-style parent scope, $contains or = by arity.
    • isMultiValueRelationship(fields, fieldName) — the verdict on its own, for the raw-URL related-list arm that must know the arity in order to refuse.
    • parentRelationshipFieldDef(fields, fieldName) — the two-shape container reader, moved here from RelatedList.
  • RelatedList (rows) and RelatedCountStore (badge) both call it. RelatedList's local copy is deleted.

The arity rule itself is unchanged and still not local: it is @objectstack/spec/data's isMultiValueField, the same predicate the driver executing the query decides on. Measured, since the rule is broader than multiple === true in both directions: {type:'multiselect'} and {type:'tags'} answer true with no flag, {type:'master_detail', multiple:true} answers false.

Why core, and not "one layer up"

RelatedList carried a standing warning against putting a local arity rule one layer up. This is not that: the rule is still the spec's, and there is now exactly one caller of it. The warning is preserved at the old site and restated in the new module, now naming the badge as the second reader.

Dependency direction was measured, not assumed — no new edge:

  • @object-ui/core already depends on @objectstack/spec and already exports mergeFilterNodes, which both sides already import for the other half of this same $filter.
  • @object-ui/components depends on @object-ui/core; @object-ui/plugin-detail peer-depends on it.
  • The reverse (composer in components, imported by plugin-detail) also holds, but core is where the sibling half already lives.

How the badge gets field metadata

The store's own comment claimed the probe "has no field metadata in hand — four scalars and a filter is the whole input". True of the function, not of its caller: the page:tabs probe effect holds ctx.dataSource, the same object the row side calls getObjectSchema on. That is what made the shared composer feasible.

So RelatedCountStore.fetch takes an optional trailing fields, and the probe effect resolves each child object's schema from that DataSource before probing.

The two sides make a different trade with the same seam, deliberately:

  • Rows do not gate on a loaded schema (objectui#7299) — an adapter without getObjectSchema would otherwise render every related list empty. They attempt equality, are refused, and refetch.
  • The badge cannot copy that: the store caches the first answer it gets, so a lenient backend that answered the wrong question with a number would have that number cached and never re-probed. It resolves arity first and probes once.

That asymmetry is pinned rather than left to be rediscovered as a bug (MEASURED DIFFERENCE below).

The parity claim is no longer asserted-and-unenforced

related-count-store.ts advertised badge/row parity as "a property of the code rather than of two implementations agreeing by luck", with the arity gap logged beneath it as a known exception. Both halves of that $filter are now shared, and the claim is held up by a pin that goes red, not by repetition.

Tests

New: packages/app-shell/src/views/RecordDetailView.relatedBadgeArity-8882.test.tsx (5 cases), on the page-level harness its neighbour RecordDetailView.relatedListFilter-4664.test.tsx established. The fake backend refuses equality on an array-valued column the way driver-sql does and throws on any filter shape it does not recognise — a permissive evaluator is the one bug that would make the whole file lie.

  • FIXTURE — the backend really refuses equality and really answers membership selectively (2 of 3 rows), in both directions.
  • SUBJECT — badge probe and settled row query carry the same parent condition.
  • SUBJECT — badge digits equal the rendered row count, at a positive count. Two zeros are equal, so parity is only evidence above zero; drawn > 0 and badge !== '0' are asserted separately.
  • MEASURED DIFFERENCE — rows attempt first and refetch; the badge probes exactly once, already correct.
  • LIVE CONTROL — a single-value relationship, correct today and after: both reads stay the byte-identical { task_version: 'tv-1' } equality object.

Before the fix this file read 2 failed / 2 passed: both subjects red (expected null to be '2'), both controls green.

Acceptance notes

Out-of-scope observations, noted and not filed — neither is a reproducible defect or a contract breach, and no queued PR touches these files:

  • RelatedList's pre-metadata row attempt is a deliberate, documented trade, not a defect; it now has a pin describing it.
  • The count store's catch returns 0 without a setCount, which is why a refused probe yields no badge rather than a 0 badge. Unchanged here and correct for this card — a cached 0 would be worse.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ


Generated by Claude Code

…rity

The related-list tab badge compiled the parent-relationship condition with a
second, independent compiler that always sent bare equality. The ROW query has
compiled that condition to match the relationship field's ARITY since
objectui#7299, so on a `multiple: true` relationship the two sides asked the
driver two different questions: the rows rendered and the badge did not, because
the driver refuses equality on an array-valued column and the count store
swallows the refusal without a setCount.

Patching the badge's copy to match would have left two compilers in place to
drift again. Instead the condition has ONE implementation — `@object-ui/core`'s
`composeParentScopeFilter`, with the `isMultiValueRelationship` verdict behind
it — and both the row query and the badge probe call it. The arity rule itself
is still `@objectstack/spec/data`'s `isMultiValueField`, the same predicate the
driver executing the query decides on.

`RelatedCountStore.fetch` gains an optional trailing `fields` argument, and the
`page:tabs` probe effect resolves the child object's schema from the same
DataSource the row side reads it from before probing. A caller that cannot see
metadata keeps the historical equality wire byte for byte.

The store's badge/row parity claim is no longer asserted-and-unenforced: it is
held up by a page-level pin that renders a real detail page over a backend which
refuses equality the way driver-sql does, and reads the badge digits against the
rendered row count at a positive count.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 51 chunks) 3091.1 KB 3134.8 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index--j_EaCh7.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.69KB 6.21KB
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) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
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.41KB 1.23KB
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.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
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) 26.08KB 7.56KB
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) 500.68KB 114.78KB
core (index.js) 8.52KB 3.41KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 209.38KB 57.92KB
fields (index.js) 247.14KB 62.34KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.21KB 2.26KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.95KB
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) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
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.39KB 3.10KB
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) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.52KB 19.98KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 131.18KB 34.62KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 251.95KB 65.34KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.94KB 41.04KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.10KB 14.33KB
plugin-list (index.js) 112.58KB 27.65KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.54KB 3.31KB
plugin-view (index.js) 84.42KB 20.80KB
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.66KB 3.50KB
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) 94.03KB 31.02KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
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.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
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) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
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) 14.27KB 5.47KB
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

`tsc` refused the implicit `any` on the map callback (TS7006), which the
package's `type-check` reaches because its test config compiles the test files.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 51 chunks) 3091.2 KB 3134.8 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-xozpc4yt.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.69KB 6.21KB
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) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
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.41KB 1.23KB
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.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
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) 26.08KB 7.56KB
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) 500.68KB 114.78KB
core (index.js) 8.52KB 3.41KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 209.38KB 57.92KB
fields (index.js) 247.41KB 62.43KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.21KB 2.26KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.95KB
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) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
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.39KB 3.10KB
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) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.52KB 19.98KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 131.18KB 34.62KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 251.95KB 65.34KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.94KB 41.04KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.10KB 14.33KB
plugin-list (index.js) 112.58KB 27.65KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.54KB 3.31KB
plugin-view (index.js) 84.42KB 20.80KB
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.66KB 3.50KB
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) 94.03KB 31.02KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
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.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
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) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
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) 14.27KB 5.47KB
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

Copy link
Copy Markdown
Collaborator Author

PM review — verified at source, against the merge base

Diff read against git merge-base origin/main <head> (e82dad1b3), not against HEAD^: 7 files, +737/−82. Every item below is something I measured here, not something read off the report.

R2 — one implementation, not a matching patch ✅

  • packages/core/src/utils/parent-scope.ts is new and exports composeParentScopeFilter / isMultiValueRelationship / parentRelationshipFieldDef; packages/core/src/index.ts re-exports it.
  • RelatedList.tsx's local parentRelationshipFieldDef is deleted (the diff removes the function body, not just its comment), and both remaining call sites — the referenceFieldIsMultiValue memo and the parentScope composition — now go through the seam.
  • related-count-store.ts's parentScope[relField] = parentId is gone; the store calls the same composeParentScopeFilter.
  • Residual-copy sweep: git grep -n "isMultiValueField" -- packages/plugin-detail packages/components packages/app-shell/src returns prose and tests only — no second executable arity rule survives in any of the three packages.

Dependency direction measured, not assumed: packages/core/package.json already declares @objectstack/spec: ^17.3.0, and @objectstack/spec/data is already imported by core/src/adapters/ValueDataSource.ts among others. No new edge. FieldContainerLike (predicate-record.ts:61) already includes null | undefined, so the optional fields parameter type-checks through isMultiValueRelationship without a widening.

R3 — the parity claim is enforced, not asserted ✅

This was the ruling-zone item that could have sunk the delivery: related-count-store.ts's badge/row parity claim had to end up either true or removed, ⛔ not left asserted and unenforced. It is now true and pinned, and I did not take the pin's falsifiability on trust — I derived it:

  • RecordDetailView.relatedBadgeArity-8882.test.tsx's scalarEquals throws INVALID_FILTER when the stored value is an array, and the FIXTURE case asserts that throw in both directions plus a selective membership answer (2 of 3 rows), which is the one thing that would make a permissive evaluator visible.
  • Therefore, with the badge sending bare equality, the probe rejects → fetchCount's catch returns without a setCount → the tab draws no digits → both SUBJECT cases fail. The reported 2 failed / 2 passed pre-fix reading follows from the fixture's own code, independently of the report.
  • The positive-count guard is present and load-bearing: expect(drawn).toBeGreaterThan(0) before the parity comparison, plus not.toBe('0'). Two zeros are equal, and that trap is closed.
  • LIVE CONTROL keeps the single-value path byte-identical ({ task_version: 'tv-1' } on both reads), so the change is shown not to have moved the arm that was already correct.

The MEASURED DIFFERENCE case earns its place: the rows' lenient first attempt and the badge's resolve-arity-first probe are a deliberate asymmetry, and pinning it is what stops the next reader from "fixing" it.

R4 — Clause-② ✅

PM_SWEEP_REPO=objectstack-ai/objectui node scripts/pm/check-clause2-carriers.mjs --pair 9184
✓ PR #9184 / card #8882 — the clause-② declaration is readable in the fixed spelling and both carriers agree.

Source-text pin sweep (with a lit control)

Queried __tests__/, *.test.ts(x) and scripts/ for pins naming the five changed source files. Hits on RelatedList.tsx, containers.tsx and related-count-store.ts are prose references or pins on untouched properties — column-identity.ratchet.test.ts pins accessorKey || columnIdentity( (untouched), residue-namespaces-3546.test.tsx pins t('detail.add', …) (untouched), layout-default-jsdoc-7361.test.ts reads containers.tsx for layout defaults (untouched). Control: the same query form hits packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx:161KanbanImpl.tsx, so the sweep was looking.

CI

36 check runs, 0 pending, 0 failing. A complete read, not a partial one — that discipline was bought with #9169.

Changeset

@object-ui/core / @object-ui/components / @object-ui/plugin-detail all minor, which matches what actually changed: three new core exports and one new optional trailing parameter on RelatedCountStore.fetch. Purely additive on every published face; the equality wire is unchanged byte for byte for any caller that passes nothing.

Verdict: land. Clearing needs:contract-review from both carriers, flipping ready, arming SQUASH.


Generated by Claude Code

@os-tesla
os-tesla marked this pull request as ready for review September 11, 2026 16:20
@os-tesla
os-tesla added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 759606e Sep 11, 2026
38 checks passed
@os-tesla
os-tesla deleted the claude/issue-8882-related-badge-row-parity branch September 11, 2026 16:39
os-tesla pushed a commit that referenced this pull request Sep 11, 2026
…arity

Brings in objectui#8882 (PR #9184): `composeParentScopeFilter` /
`isMultiValueRelationship` in `@object-ui/core`, the seam this branch is the
first caller of.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants