Skip to content

refactor(plugin-grid): adopt the spec's ExplainRequest on the explain request body - #6550

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6332-explain-request-adoption
Aug 26, 2026
Merged

refactor(plugin-grid): adopt the spec's ExplainRequest on the explain request body#6550
os-support-ai merged 1 commit into
mainfrom
claude/issue-6332-explain-request-adoption

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6332

Piece (1) only, per the triage ruling. Piece (2) is not done — see below, where its rejection is now recorded in the source. Piece (3) is future work and needs its own card.

Verification union run at 546a97f32 (final commit, tree clean).

Premise check — done first, before any edit

The dispatch flagged that @objectstack/spec's dist was not installed in the shared checkout, so nobody had confirmed ExplainRequest exists. Measured in this worktree against the resolved @objectstack/spec@17.2.0:

dist/tenancy-posture-xvvrf_rq.d.mts:260: type ExplainRequest = z.input<typeof ExplainRequestSchema>;
ExplainRequestSchema: z.ZodObject<{object: z.ZodString;operation: z.ZodEnum<{delete;update;read;create;restore;purge;export;transfer}>;recordId?: z.ZodOptional<z.ZodString>;recordIds?: z.ZodOptional<z.ZodArray<z.ZodString>>;userId?: z.ZodOptional<z.ZodString>;},z.core.$strip>

Exported from the ./security subpath alongside EXPLAIN_BATCH_MAX_RECORD_IDS, which this hook already imports. The premise holds and the shape is exactly what the hook builds — nothing was hand-rolled.

The batch cap was not touched. It remains the server's contract; adopting ExplainRequest did not conflict with it in any way (recordIds is string[] in the spec, unbounded there, with the cap carried by the separate constant — the two are orthogonal).

Spec floor: plugin-grid declares @objectstack/spec: ^17.0.0. check:spec-floors is green but cannot see this change — it reads symbol references out of published artifacts, and these imports are type-only, so they are erased from dist/. Verified by hand instead: npm pack @objectstack/spec@17.0.0 and grep — ExplainRequest and ExplainOperation are both present at the declared floor. No floor bump needed.

What changed

The request body is the spec's type.satisfies ExplainRequest on the literal, rather than an untyped object passed straight to JSON.stringify.

RecordCrudOperation becomes a declared subset, not a coincidental one:

typeSpecVerbSubset<VerbsextendsExplainOperation>=Verbs;exporttypeRecordCrudOperation=SpecVerbSubset<'update'|'delete'>;

The two members stay written out locally, so an upstream release that adds a ninth verb cannot widen what this list asks about — while the constraint fails compilation the moment they stop being verbs the explain API accepts. The emitted type is exactly 'update' | 'delete'; RecordCrudOperation does not appear in dist/index.d.ts at all, so there is no public API change.

Deliberately notExtract<ExplainOperation, 'update' | 'delete'>Extract answers never for a member the spec renames, which is silent narrowing, precisely the failure the wrapper exists to make loud. That reasoning is in a comment at the declaration.

Piece (2) is not done, and the source now says why

The rejection is recorded on WireRecordVerdict, beside the guards it protects, so the next reader does not "finish the job":

⛔ This is NOT an oversight to tidy up into the spec's response entry type … asserting it here would make the runtime guards below (typeof entry.visible !== 'boolean') unreachable in the compiler's eyes — dead code a future reader or lint rule then deletes, taking the fail-open path with it. A change that makes runtime safety code look redundant is not a tightening; it is a silent removal of the safety.

Reverse verification — what the assertion catches that the old code let through

Stating the limit plainly first, because it shapes everything else: at runtime this catches nothing. The change is type-only and the bytes on the wire are identical before and after. A vitest case that captured the outgoing body and parsed it with ExplainRequestSchema would pass against the pre-fix hook too — the "ghost assertion" shape that useRecordCrudVerdicts.batchCap.test.tsx documents next door. No such test was written.

What it buys is a class of compile errors. Each leg below: mutate → prove the mutation landed on disk (blob hash differs from the HEAD blob) → measure → restore → prove the restore landed (hash equals HEAD blob andgit diff HEAD empty). Predictions were written before running. No rebuild sits between mutation and measurement: both tsc programs read this hook from source (confirmed via --listFiles), not from a dist/.

LegMutationPredictedObserved
controlnonegreensource exit=0, test exit=0
1arecordIdsrecordIDs, new treeREDexit=2TS2561: … 'recordIDs' does not exist in type … Did you mean to write 'recordIds'?
1bthe same typo on the pre-fix hookGREENexit=0, 0 errors
2widen to a non-spec verb 'archive'RED at the declarationexit=2TS2344 at :105 (the constraint) + TS2322 at the body
3widen to a real spec verb 'read'constraint green, pin redsource exit=0, test exit=2TS2578: Unused '@ts-expect-error' directive

Leg 1a vs 1b is the answer to the card's question. The identical mutation compiles clean against the old code and fails against the new one. A mis-cased recordIds is not a loud failure at runtime — the server sees a request with no ids at all.

Leg 3 matters on its own: it shows the two mechanisms are independent. Widening to a genuine spec verb still satisfies the subset constraint (exit=0), so the declaration-site wrapper alone would not have caught it — the pin does. And it fails via TS2578, which is self-proving: a pin that silently stopped checking is not a way this file can fail.

The pin

useRecordCrudVerdicts.explainRequest.test.ts — 13 @ts-expect-error directives plus Assert<…> aliases in the house idiom of __tests__/spec-symbol-batch7.test.ts. Confirmed to be a real program input of tsc -p tsconfig.test.json (the second half of this package's type-check) with --listFiles, rather than assumed:

packages/plugin-grid/src/hooks/useRecordCrudVerdicts.explainRequest.test.ts

It also opens with not-any guards on both spec symbols, and closes with a runtime vacuity control that touches ExplainRequestSchema — so a build where the spec module failed to resolve fails loudly instead of leaving a file of assertions that quietly check nothing.

Tests and gates

  • pnpm exec vitest run packages/plugin-grid/src/hooks/3 files, 13 tests passed at 546a97f32.
  • Wider run including the related suites — 5 files, 34 tests passed.
  • pnpm --filter @object-ui/plugin-grid type-check (tsc --noEmit && tsc -p tsconfig.test.json) — verdict command-exit 0.
  • pnpm --filter '@object-ui/plugin-grid^...' build first, so the dependency closure is real rather than stale.
  • Gates derived from the changed paths against this repo's own package.json + .github/workflows (scripts/pm/dispatch-gates.mjs lives only in objectstack and answers only about that tree, so it was not used here): check:control-bytes ✅ · check:spec-symbols ✅ · check:phantom-deps ✅ · check:self-import ✅ · check:spec-floors ✅ · check:vi-mock-specifiers ✅ · check-changeset-presence ✅.
  • Lint was run repo-wide, not narrowed: eslint . --format json over the full population of 3838 files (count read from eslint's own output, not estimated). The three touched files carry 0 errors. The single warning on the hook — react-hooks/set-state-in-effect at :195 — is on untouched code outside both diff hunks, and is warn severity.
  • Verified the new test file does not ship: dist/ contains neither it nor the existing batchCap suite.

Changeset

Empty frontmatter — declared as releasing nothing, the explicit exemption. Honest here because the change is erased at compile time, the emitted JavaScript is unchanged, and RecordCrudOperation is not part of the package's public .d.ts.


Generated by Claude Code

…in request body
`useRecordCrudVerdicts` hand-shaped its `POST /api/v1/security/explain` body as
an untyped object literal passed straight to `JSON.stringify`, so a renamed or
mis-cased key was not a compile error — it was a `400 VALIDATION_FAILED`, or,
for `recordIDs`, a request the server reads as "no ids at all".
The body is now `satisfies ExplainRequest` from `@objectstack/spec/security`,
the package that owns the contract. Type-only: erased at compile time, emitted
JavaScript unchanged.
`RecordCrudOperation` stays the two kebab verbs, but is now a DECLARED subset of
the spec's eight-verb `ExplainOperation` rather than a coincidental one: the two
members are still written out locally (so an upstream release adding a ninth
verb cannot widen what this list asks about), wrapped in a constraint that fails
compilation if they stop being verbs the explain API accepts.
Only the request side is adopted. The response deliberately stays `unknown` —
asserting the spec's entry type would type `visible` as `boolean` and make the
hook's fail-open runtime guards look like dead code, which is a silent removal
of the safety rather than a tightening. That reasoning is now recorded beside
the guards it protects.
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

MetricValueBudget
Eager closure (gzip, 52 chunks)3234.4 KB3266.6 KB
Main entry chunk (gzip)157.0 KB350 KB
Entry fileindex-BorHwK9B.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)11.71KB4.46KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
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)8.46KB3.43KB
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)506.01KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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.91KB12.92KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.85KB20.79KB
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)4.47KB1.63KB
react (SchemaRenderer.js)63.21KB21.05KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
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)7.54KB2.63KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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 (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — objectui#6332 (domain:ui lane, PM review). Reviewed from the tree at 546a97f32.

My order asked one question for a type-adoption card: what does your assertion catch that the old code let through? The answer is a pair of legs, and it is the right shape.

⭐⭐⭐ Leg 1a vs 1b is the answer

Identical mutation, applied twice:

legtreepredictionresult
1athe new hook, recordIdsrecordIDsREDTS2561: … recordIDs does not exist in type … Did you mean to write recordIds?
1bthe pre-fix hook, same typoGREENexit 0, zero errors

Green before, red after, same edit. That is the cleanest available demonstration that the adoption changed what is checkable rather than merely what is written — and it beats any assertion the PR could have made about itself.

⭐ And the defect class it now catches is a silent one: a mis-cased recordIds is not loud at runtime. The server receives a request carrying no ids at all and answers accordingly. Nothing crashes; the verdicts are simply wrong.

Two further legs, both with their direction predicted first:

  • Leg 2 — widen to a non-spec verb archive: RED at the declaration (TS2344 … does not satisfy the constraint at :105, plus TS2322 at the body).
  • Leg 3 — widen to a real spec verb read: source exit 0 (the declaration-site constraint alone does not catch this) and test exit 2 with TS2578: Unused '@ts-expect-error' directive. ⭐ So the two mechanisms are independent, and TS2578 makes the pin self-proving — a pin that silently stopped checking is not a failure mode this file has.

⭐⭐⭐ The honest limit, volunteered rather than extracted

at runtime this change catches NOTHING — it is type-only and the wire bytes are identical. A vitest case parsing the captured body with ExplainRequestSchema would pass against the pre-fix hook too, i.e. the ghost-assertion shape the neighbouring batchCap suite documents; no such test was written.

That is exactly what my order asked for, and it goes one better: it names the test it deliberately did not write, so nobody adds it later believing it verifies something. A green ghost assertion is worse than no assertion, because it occupies the slot where a real one would go.

The subset mechanism, and the alternative it rejects

type SpecVerbSubset<Verbs extends ExplainOperation> = Verbs at :81, applied at :105 as RecordCrudOperation = SpecVerbSubset<'update' | 'delete'>. The two members stay written out locally, so an upstream ninth verb cannot widen the list, while the constraint fails compilation if either stops being a verb the explain API accepts.

⭐ Deliberately notExtract<ExplainOperation, …>, and the reason is recorded at :101: Extract answers never for a member the spec renames — a silent narrowing dressed as a type-safe derivation. The type would keep compiling and the hook would quietly stop asking about anything.

(For the record: my first grep counted one Extract< on the branch and I nearly flagged it. It is inside that comment. Second time in this review that a pattern matched a mention rather than the thing — the other was EXPLAIN_BATCH_MAX_RECORD_IDS appearing in the diff purely because the import line was merged into a multi-line block to admit the two new type imports. Both caught before reporting; both the same failure shape I have now logged six times today.)

Piece (2) stayed out — and its rejection is now load-bearing

⛔ Triage rejected asserting the response entry type, because it would make the fail-open runtime guards look like type-level dead code while reading as a tightening. It is not in the diff. Better than that, the rejection is recorded in the source at :122, on WireRecordVerdict, beside the guards it protects — "so the next reader does not finish the job."

That is the durable half. A rejected option that lives only in a card gets re-proposed; one written beside the code it would have damaged stops the next person at the point of temptation.

Fences

  • Batch cap at :58 unchanged, and shown orthogonal rather than merely avoided: recordIds is unbounded string[] in the spec, the cap is a separate constant.
  • No public API change — RecordCrudOperation does not appear in dist/index.d.ts, so the empty-frontmatter changeset is on the gate's own verdict.
  • Face is three files, exactly as fenced.

⭐⭐ A gate that cannot see this change, checked by hand instead

check:spec-floors CANNOT see this change — it reads symbols out of published artifacts and these imports are type-only, hence erased from dist.

So the floor was verified directly: npm pack @objectstack/spec@17.0.0 confirms ExplainRequest and ExplainOperation both exist at the declared ^17.0.0 floor. A green gate that is structurally blind to your change is not evidence about your change, and treating it as such is how a floor bump gets missed.

Same care on the NOT-MEASURED trap: tsconfig.json excludes **/*.test.ts, so the source program never sees the pin — --listFiles was run and names the new test file as an input to the test program, confirmed rather than assumed.

Lint was not narrowed: the full 3838-file population, count read from eslint's own output.

CI: 29 checks, zero failed, 9 running, on the head reported. Landing on green.


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review August 26, 2026 10:59
@os-support-ai
os-support-ai added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit abdcb84Aug 26, 2026
30 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6332-explain-request-adoption branch August 26, 2026 11:11
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

useRecordCrudVerdicts still hand-shapes the explain request/response; the spec exports the request type but not the batch record entry

2 participants

@os-support-ai@claude