Skip to content

fix(data-objectstack): parse a dropped-fields entry's fields elements and object instead of asserting them - #7159

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-6889-wire-dropped-fields-guard
Sep 1, 2026
Merged

fix(data-objectstack): parse a dropped-fields entry's fields elements and object instead of asserting them#7159
os-warren merged 2 commits into
mainfrom
claude/issue-6889-wire-dropped-fields-guard

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #6889

Measured on origin/main at fd11e1644 (fd11e1644601d261ec00e371f8bf0b4ce8a7455a). The card is from 2026-08-30 and its snippets were not trusted; the filter, the type and the reader were all re-derived at that head.

1. The gating measurement — driven, not reasoned

The card left the severity question open and pointed at the one reader. That reader was executed, not read: a scratch probe drove the real production chain end to end — ObjectStackAdapter.create() to onWriteWarning to app-shell's emitWriteWarning, with the real t from useObjectTranslation() and the real fieldLabel from useSafeFieldLabel() (exactly what AdapterProvider passes) and a recording sink.

wire fields reader outcome rendered description
['type','source_method'] (control) resolved ... : Andon type, Source method
[42] resolved ... : 42
[{ name: 'salary' }] resolved ... : [object Object]
[null, 'type'] resolved ... : , Andon type
[['type','source_method']] resolved ... : type,source_method
[true] resolved ... : true
['type', 42, {}] resolved ... : Andon type, 42, [object Object]
object key absent resolved unaffected — the reader names fields off WriteWarningEvent.resource

No throw on any JSON-representable non-string element. The reader degrades to a wrong label, or to an empty entry for null. Nothing a user would act on as a real field name.

The one throw observed came from a deliberately non-JSON probe (an element whose toString throws), and it did not reach the reader at all — it threw inside the adapter, in withoutNoOpDrops, on Object.prototype.hasOwnProperty.call(sent, f). droppedFields arrives as parsed JSON, so that value is not reachable from the wire. Reported as a curiosity, not as evidence of a defect.

Verdict: severity is cosmetic. Per the card's own ruling that licenses the cheaper honest repair — never no repair.

2. Which branch that selected, and why it is the opposite of the cheap one

The card offered two shapes: narrow the filter to check element types, or keep the filter permissive and let the declared type say unknown.

The second one — the "cheaper" branch — cannot be taken without moving a published type. WireDroppedFieldsEntry feeds asDroppedFieldsNotice, which returns DroppedFieldsNotice, whose fields is string[] on both arms and which IS re-exported. Declaring the wire fields as unknown[] and stopping there only relocates the split one call down, into the cast; carried to its honest conclusion it makes a subscriber's declared string[] become unknown[]. That is the contract tier, and it is not what this PR does.

So this PR takes the filter-narrowing branch, and additionally makes the internal wire type honest — which is free, because WireDroppedFieldsEntry is not published (evidence below). The two together give the shape the card asked for: what the code establishes and what the declaration claims are now the same thing at every step.

3. Both over-claims held, independently

  • fields elements — the gate read Array.isArray(fields) && fields.length > 0 and nothing about the elements, while the asserted type declares string[]. Real.
  • object — the installed spec pin (@objectstack/spec@17.2.0) declares object: z.ZodString, i.e. required. Omit carried that required string through, and the gate never read object. Real, and independent of the first.

This falsifies the dispatch's A2.3 hypothesis, which guessed that a required object might mean "no gap". Required is precisely what makes it a gap — an optional object would have had nothing to over-claim. The adapter's own batch path already knew this: it wrote e.object ?? op?.object ?? '', a runtime defence against an absence the type denied.

4. What changed

  • WireDroppedFieldsEntry now declares only what the gate establishes: { object?: unknown; fields: unknown[]; reason?: unknown }.
  • The gate is named (isWireDroppedFieldsEntry) and shared by the single-record and batch paths, which previously spelled it twice. It requires at least one string element: an entry naming no field has nothing truthful to tell the user, which is the same disposition the boundary already gave fields: [].
  • asDroppedFieldsNotice narrows fields to its string elements, and takes object from the wire when it is a string, else from the object the write targeted. It now builds the notice, so the last cast in this seam is gone.
  • The batch path computes the object name once, for both the notice and the event's resource; those were two separate expressions that could disagree.

The repair is deliberately asymmetric with reason's, and the code says why: a reason from the future is the expected direction of version skew, so it earns an explicit skew arm carrying the wire value verbatim. fields is z.array(z.string()) in the spec and cannot grow a non-string element without a breaking change, so a non-string element is off-spec input — refused here, fixed at the producer (AGENTS.md #0.1), never given an invented rendering. object is neither: the adapter knows which object it wrote to, so a missing one is healed rather than dropped, and no entry is ever silenced for it (objectui#3484).

Bounded, per the card's own scope

No widening into PR #6884's territory (reason untouched) or objectui#4934's. objectui#3160's canonical arm is not widened to string. app-shell's writeWarningToast.ts was read only — it is unchanged, and the probe that drove it was scratch and is not in this diff.

5. Evidence

Published-surface probe — the claim that no published type moved. Built the package at fd11e1644 and at this branch's head and compared the emitted dist/index.d.ts:

  • declaration lines (comments and blanks stripped): 570 vs 570, identical, diff exit 0
  • control on the same instrument: the full files DO differ (diff exit 1) — the delta is comment text only, carried in from a private method's JSDoc. So the zero is a reading, not a broken comparison.
  • git diff fd11e1644 over the source adds and removes zero lines beginning with export.
  • WireDroppedFieldsEntry appears 0 times in the built dist/index.d.ts; positive control DroppedFieldsNotice appears 4 times on the same grep. The two private methods emit signature-less (private notifyDroppedFields;).

Reader census. Repo-wide grep for onWriteWarning, WriteWarningEvent and DroppedFieldsNotice: exactly one non-test subscriber, packages/app-shell/src/providers/AdapterProvider.tsx:78, which calls emitWriteWarning in writeWarningToast.ts. Nothing in apps/. Control: the same instrument returned dozens of hits across the test suites, so the single production hit is a reading.

No test pinned the permissive behaviour. Every fields: array in the write-warning suites is all-strings or empty; none pins a non-string element or a missing object. So the fix's shape was not constrained by an existing pin.

Ablation. Direction predicted before running: reverting only index.ts to fd11e1644, keeping the new test file, turns 8 of the 9 new tests red and leaves exactly one green — the object pass-through control — while the sibling suites stay green.

Mutation proven on disk by blob hash, not by an editor's exit code: worktree c48540398871a4a9bb4e4e4a049598f267080aff equals the base blob and differs from the HEAD blob; marker counts isWireDroppedFieldsEntry 4 to 0 and fallbackObject 3 to 0, with fields.length > 0 back at 2. Observed: 8 failed, 34 passed across 4 files — exactly the predicted split, including the single green control. Restore proven by state: git diff HEAD, git diff --cached and git status --short all empty, and the worktree blob hash back to 1b93160c80b2d6ebd3a01366a2d05ba5a48c5d14.

No rebuild was needed for the ablation to be valid: vitest.config.mts aliases every workspace package to its src, and the suites import ./index relatively, so the mutation reached the run from source rather than from a stale dist.

Gate union, re-run after the final commit, at e268018b4 (exit code captured by redirect-then-capture, never across a pipe; verdicts quoted from each gate's own line):

gate exit verdict
pnpm --filter @object-ui/data-objectstack type-check 0 tsc --noEmit clean
pnpm exec vitest run (5 files, from the repo root) 0 Test Files 5 passed (5) / Tests 56 passed (56)
pnpm --filter @object-ui/data-objectstack lint 0 419 problems (0 errors, 419 warnings)
check-changeset-presence.mjs 0 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
pnpm changeset:check 0 No changeset declares a major bump.
pnpm check:control-bytes 0 OK (scanned 5931 tracked text file(s); skipped 85 binary)
pnpm check:self-import 0 No package names itself inside its own src/.
pnpm check:phantom-deps 0 Every in-scope import is declared by the package that publishes it.
pnpm check:vi-mock-specifiers 0 OK
pnpm check:vi-mock-inherit 0 OK

The package type-check really does cover the new test file: tsc --listFiles names droppedFieldsShape.boundary.test.ts once, with src/index.ts as the control.

Declared narrowing. The repo-wide pnpm lint is CI's run; this branch ran the per-package form instead, and the narrowing is measured rather than skipped: the population came from eslint's own config resolution (eslint . in the package dir, not a hand-picked list); --format json reports 58 files linted, 0 errors, and both touched files are in that list; and eslint.config.js declares no projectService and no parserOptions (control: rules matches 10 times on the same grep), so type-aware linting is off and this diff cannot move the verdict on any file it did not touch.

Not measured. Whether any real server emits a non-string fields element or omits object. That is unanswerable from these two repos — a host application outside them is invisible to any measurement runnable here — and no inference about it was made.


Generated by Claude Code

…ts and `object` instead of asserting them

The write-warning boundary's structural gate read `Array.isArray(fields) &&
fields.length > 0` and asserted the entry into `Omit<DroppedFieldsEvent,
'reason'>`, which claims a `string[]` of field names and a REQUIRED
`object: string`. It read neither. `fields: [42]` reached every subscriber
typed as a field name, and an entry with no `object` arrived claiming a
string that was not there.

Same discipline the sibling `reason` fix applied: parse rather than assert.

- `WireDroppedFieldsEntry` now declares exactly what the gate establishes:
  `{ object?: unknown; fields: unknown[]; reason?: unknown }`.
- The gate is named (`isWireDroppedFieldsEntry`), shared by the single-record
  and batch paths, and requires at least one string element — an entry that
  names no field has nothing truthful to say.
- `asDroppedFieldsNotice` narrows `fields` to its string elements and takes
  `object` from the wire when it is a string, else from the object the write
  targeted. It now BUILDS the notice, so the last cast in this seam is gone.
- The batch path computes the object name once, for both the notice and the
  event's `resource`; those were two separate expressions that could disagree.

No published type changes: `DroppedFieldsNotice`, `WriteWarningEvent` and
`UnrecognizedDropReasonEvent` keep their declared shapes, and a subscriber's
`fields: string[]` is now true rather than asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 48 chunks) 3152.6 KB 3191.4 KB
Main entry chunk (gzip) 142.6 KB 350 KB
Entry file index-iIR6WK6O.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) 15.33KB 5.59KB
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) 5.13KB 2.35KB
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) 512.32KB 116.52KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 178.14KB 49.57KB
fields (index.js) 244.25KB 61.73KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
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.55KB 0.62KB
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 (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
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) 5.12KB 1.74KB
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) 15.75KB 3.80KB
plugin-calendar (index.js) 46.92KB 12.93KB
plugin-charts (index.js) 65.94KB 18.64KB
plugin-chatbot (index.js) 190.53KB 45.18KB
plugin-dashboard (index.js) 132.61KB 34.56KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 248.95KB 63.55KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 133.11KB 32.61KB
plugin-gantt (index.js) 165.21KB 40.37KB
plugin-grid (index.js) 202.31KB 54.66KB
plugin-kanban (index.js) 53.21KB 14.66KB
plugin-list (index.js) 113.21KB 27.60KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 29.34KB 8.47KB
plugin-tree (index.js) 8.98KB 3.08KB
plugin-view (index.js) 85.90KB 21.12KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 3.11KB 1.48KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
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 (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.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
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

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 08:23
@os-warren
os-warren added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit f75810e Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-6889-wire-dropped-fields-guard branch September 1, 2026 08:37
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.

finding(data-objectstack): WireDroppedFieldsEntry's guard still asserts more than the shape filter checks — fields element types and object presence

2 participants