Skip to content

refactor(plugin-grid): import the batch-explain cap from @objectstack/spec/security - #6333

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6286-explain-batch-cap-from-spec
Aug 25, 2026
Merged

refactor(plugin-grid): import the batch-explain cap from @objectstack/spec/security#6333
os-support-ai merged 2 commits into
mainfrom
claude/issue-6286-explain-batch-cap-from-spec

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #6286

useRecordCrudVerdicts declared const EXPLAIN_BATCH_MAX_RECORD_IDS = 200 — a hand copy of a server contract constant — under a doc comment naming its own expiry condition. The spec exports the constant, so the copy becomes an import and only the half of the comment explaining why it was declared locally is dropped. The half explaining why the cap exists and what the server does with an over-cap request (400 VALIDATION_FAILED, never truncation) is kept and now sits on the import.

All measurements below were run at db31e5fe9, the head of this branch.

Premise, re-derived from the resolved package (not the changelog)

@objectstack/spec@17.2.0 resolves to node_modules/.pnpm/@objectstack+spec@17.2.0_ai@7.0.65_zod@4.4.3_/node_modules/@objectstack/spec, and ./security exports the constant:

  • statically — the name is in dist/security/index.d.mts's re-export list, declared declare const EXPLAIN_BATCH_MAX_RECORD_IDS = 200 in the shared chunk;
  • at runtime, through the same specifier the source now uses — typeof number, value 200, hasOwnProperty true (control: 43 exported keys, so the module resolved rather than answering an empty namespace).

No value disagreement, so the premise fork in the dispatch does not fire.

Correction: the declaration expired one release earlier than the card assumed

The card attributes the expiry to the pin reaching 17.2.0. Measured against the published tarballs, that is later than the truth:

version ./security exports the constant
17.0.0-rc.6 no
17.0.0 yes, = 200
17.1.0 yes, = 200
17.2.0 yes, = 200

This matters beyond trivia. packages/plugin-grid/package.json declares "@objectstack/spec": "^17.0.0", and a runtime import into published source is judged by scripts/check-spec-range-floors.mjs against the minimum that range admits, not against the installed version. Had 17.0.0 lacked the symbol, this PR would have owed a floor bump, and a consumer resolving 17.0.0 would have got undefined — which in the chunking loop (i += undefined) degrades to one empty request and a silent fail-open for every row, not a crash. 17.0.0 carries it, so no manifest change is needed and no range moves. That gate is push+paths-triggered (release path, not per-PR), so this reasoning is the evidence for it rather than a green job.

The riders the card names — both declined, with evidence

Measured: @objectstack/spec/security does export ExplainRequest (with recordIds?: string[]) and ExplainDecision (with records?: Array<{recordId: string; visible: boolean; decidedBy?}>). It does not export a standalone type for a batch records[] entry — ExplainRecordAttribution is the layer-level row attribution off ExplainLayer, a different thing — so WireRecordVerdict has no counterpart to swap in beyond an indexed access.

Neither is adopted here, deliberately and not by drift. The response side is a posture conflict rather than a mechanical swap: WireRecordVerdict is { recordId?: unknown; visible?: unknown } because the payload is unvalidated, and asserting the spec's validated shape onto res.json() would turn the hook's own fail-open guards into type-level dead code. The genuinely contract-first move is ExplainDecisionSchema.safeParse, which is a behaviour change (the schema requires allowed / object / operation / principal) and needs its own card. The request side is adoptable and nearly free, but carries a real review question — RecordCrudOperation is deliberately two verbs where ExplainRequest['operation'] is eight.

Filed as #6332 with the full reasoning and a suggested three-step shape, so the thread survives this card closing.

Verification — and why an assertion on 200 would have proved nothing

The values agree on both sides of this change, so every assertion on the value passes before and after. The pre-existing cap test in rowRecordCrudVerdict.test.tsx is exactly that ghost. What changes is reference identity, so that is what is pinned.

packages/plugin-grid/src/hooks/useRecordCrudVerdicts.batchCap.test.tsx (new) stands the spec module in at a cap of 3 and asserts the request chunking follows it — a boundary no hand copy of 200 can produce. It carries a control case proving the stand-in actually installed (the factory ran, i.e. the hook's own import graph pulled the module in) and that the stub differs from the shipped value. This matters because scripts/check-vi-mock-specifiers.mjs judges relative specifiers only — its own verdict line counts this mock among the "651 bare (out of scope)" — so an inert bare-specifier mock has no gate, and the control is the substitute.

rowRecordCrudVerdict.test.tsx also now derives its cap fixture and bound from the same export instead of re-typing 200/250, removing the last hand copy on this surface. That is a cleanup, not a two-world assertion, and the ablation proves it: it stays fully green against the reverted hook.

Ablation

Revert the import alone (restore the local const, delete the spec import), under trap … EXIT INT TERM with absolute paths. Both directions are single-line markers, counted before and after, plus a blob-hash comparison against the HEAD blob:

restored local decl (was 0): 1
spec import           (was 1): 0
blob now: ff9c2bcc…  (HEAD blob 5065876b… — differs, so the mutation landed)

Result on the mutated tree: 4 of 4 failed in the new file; 15 of 15 still passed in rowRecordCrudVerdict.test.tsx. Restored via the trap and verified byte-exact — git diff HEAD --stat empty and the blob hash back to 5065876b….

No rebuild is involved: vitest aliases @object-ui/plugin-grid to src/, and the test imports the hook by relative path, so nothing reads plugin-grid/dist. The mutated file is the one the runner executes.

The first ablation ran red 3 of 4, not 4 of 4, and that caught a ghost of my own. The case "exactly cap ids is one request" survived the revert — three ids fit under a cap of 200 as readily as under a cap of 3. It now asserts the cap + 1 side in the same breath (commit db31e5fe9), and the second ablation is 4 of 4.

Gates run (derived from each CI job's own step list, not memory)

At db31e5fe9, quoting each gate's own verdict line rather than a shell $?:

gate result
vitest run packages/plugin-grid Test Files 88 passed (88) · Tests 827 passed (827)
tsc --noEmit (src project) SRC_EXIT=0
tsc -p tsconfig.test.json TEST_EXIT=0
eslint . (plugin-grid) 0 errors, 683 warnings — all pre-existing; the new file linted with 0/0
check:spec-symbols ✅ 1305 files scanned against 4959 spec export names
check:phantom-deps ✅ Every in-scope import is declared by the package that publishes it
check:vi-mock-specifiers ✅ OK (…433 carry a mock; …651 bare (out of scope))
check:control-bytes ✅ OK (scanned 5197 tracked text file(s))
check:self-import, check:esm-specifiers, check:pre-install-import-graph
check-changeset-presence.mjs ✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-lint-coverage.mjs ✅ 46/46 packages linted, 0 with outstanding errors
check-type-check-coverage.mjs ✅ 41/41 packages compile their tests

Both tsc projects were red on TS2307 before the dependency closure was built (pnpm --filter '@object-ui/plugin-grid^...' build) — pre-existing, in files this PR does not touch, and reported here because a reader running them cold will hit the same thing.

Not measured, said plainly: tsc --listFiles shows spec/dist/security/index.d.mts as a program input of plugin-grid's src project — but it does so on the reverted tree too, because packages/types/dist/index.d.ts carries export type * as Security from '@objectstack/spec/security'. It is a degenerate control, not provenance evidence, and is not offered as any.

Lint narrowing declared: pnpm lint is turbo run lint (per-package eslint .), and eslint.config.js sets no parserOptions.project, so linting is not type-aware and a change in this package cannot move a verdict in another. This PR ran plugin-grid's own eslint . in full — 124 files, the package's entire lint job — rather than the whole farm; CI runs the rest regardless.


Generated by Claude Code

claude added 2 commits August 25, 2026 13:05
`useRecordCrudVerdicts` declared `EXPLAIN_BATCH_MAX_RECORD_IDS = 200` locally,
a hand copy of a server contract constant, under a doc comment that named its
own expiry condition. `@objectstack/spec/security` exports the constant, so the
copy is replaced by an import and only the "why it was declared locally" half of
the comment is dropped.

No value and no behaviour change: the spec exports 200, verified statically and
at runtime against the resolved package. What changes is reference identity —
the client can no longer drift from the server's cap.

Covered by a reference-identity test that stands the spec module in at a cap no
hand copy could produce; an assertion on 200 passes on both sides and proves
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
The ablation leg found "exactly cap ids is one request" surviving the revert:
three ids fit under a cap of 200 as readily as under a cap of 3, so that half is
another ghost. The case now asserts the cap + 1 side in the same breath, which
only passes when the boundary being applied is the spec's.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3223.2 KB 3266.6 KB
Main entry chunk (gzip) 154.2 KB 350 KB
Entry file index-DYxiBihT.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) 10.96KB 4.16KB
app-shell (runtime-config.js) 18.10KB 6.51KB
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) 505.75KB 114.70KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 171.74KB 47.48KB
fields (index.js) 238.79KB 59.99KB
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.95KB 10.97KB
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) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
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) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.21KB 44.67KB
plugin-dashboard (index.js) 133.35KB 34.45KB
plugin-designer (index.js) 211.95KB 42.75KB
plugin-detail (index.js) 245.10KB 62.31KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 126.92KB 30.85KB
plugin-gantt (index.js) 164.14KB 39.87KB
plugin-grid (index.js) 201.21KB 54.43KB
plugin-kanban (index.js) 52.83KB 14.55KB
plugin-list (index.js) 111.94KB 27.24KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.49KB 7.59KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 84.55KB 20.74KB
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) 54.84KB 18.43KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
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) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
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) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 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.49KB 2.14KB
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-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 13:29
@os-support-ai
os-support-ai added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit c38162d Aug 25, 2026
28 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6286-explain-batch-cap-from-spec branch August 25, 2026 13:41
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.

plugin-grid's local EXPLAIN_BATCH_MAX_RECORD_IDS has reached its own stated expiry — the spec pin now exports the constant

2 participants