refactor(plugin-grid): import the batch-explain cap from @objectstack/spec/security - #6333
Merged
os-support-ai merged 2 commits intoAug 25, 2026
Merged
Conversation
`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
Contributor
✅ Console Performance Budget
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
Size Limits
|
os-support-ai
marked this pull request as ready for review
August 25, 2026 13:29
os-support-ai
deleted the
claude/issue-6286-explain-batch-cap-from-spec
branch
August 25, 2026 13:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6286
useRecordCrudVerdictsdeclaredconst 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.0resolves tonode_modules/.pnpm/@objectstack+spec@17.2.0_ai@7.0.65_zod@4.4.3_/node_modules/@objectstack/spec, and./securityexports the constant:dist/security/index.d.mts's re-export list, declareddeclare const EXPLAIN_BATCH_MAX_RECORD_IDS = 200in the shared chunk;typeof number, value200,hasOwnPropertytrue (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:./securityexports the constant17.0.0-rc.617.0.0= 20017.1.0= 20017.2.0= 200This matters beyond trivia.
packages/plugin-grid/package.jsondeclares"@objectstack/spec": "^17.0.0", and a runtime import into published source is judged byscripts/check-spec-range-floors.mjsagainst the minimum that range admits, not against the installed version. Had17.0.0lacked the symbol, this PR would have owed a floor bump, and a consumer resolving17.0.0would have gotundefined— 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.0carries it, so no manifest change is needed and no range moves. That gate ispush+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/securitydoes exportExplainRequest(withrecordIds?: string[]) andExplainDecision(withrecords?: Array<{recordId: string; visible: boolean; decidedBy?}>). It does not export a standalone type for a batchrecords[]entry —ExplainRecordAttributionis the layer-level row attribution offExplainLayer, a different thing — soWireRecordVerdicthas 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:
WireRecordVerdictis{ recordId?: unknown; visible?: unknown }because the payload is unvalidated, and asserting the spec's validated shape ontores.json()would turn the hook's own fail-open guards into type-level dead code. The genuinely contract-first move isExplainDecisionSchema.safeParse, which is a behaviour change (the schema requiresallowed/object/operation/principal) and needs its own card. The request side is adoptable and nearly free, but carries a real review question —RecordCrudOperationis deliberately two verbs whereExplainRequest['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
200would have proved nothingThe 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.tsxis 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 of3and asserts the request chunking follows it — a boundary no hand copy of200can 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 becausescripts/check-vi-mock-specifiers.mjsjudges 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.tsxalso now derives its cap fixture and bound from the same export instead of re-typing200/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 TERMwith absolute paths. Both directions are single-line markers, counted before and after, plus a blob-hash comparison against theHEADblob: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 --statempty and the blob hash back to5065876b….No rebuild is involved: vitest aliases
@object-ui/plugin-gridtosrc/, and the test imports the hook by relative path, so nothing readsplugin-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 + 1side in the same breath (commitdb31e5fe9), 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$?:vitest run packages/plugin-gridTest Files 88 passed (88)·Tests 827 passed (827)tsc --noEmit(src project)SRC_EXIT=0tsc -p tsconfig.test.jsonTEST_EXIT=0eslint .(plugin-grid)0 errors, 683 warnings— all pre-existing; the new file linted with 0/0check:spec-symbols✅ 1305 files scanned against 4959 spec export namescheck:phantom-deps✅ Every in-scope import is declared by the package that publishes itcheck: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-graphcheck-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 errorscheck-type-check-coverage.mjs✅ 41/41 packages compile their testsBoth
tscprojects were red onTS2307before 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 --listFilesshowsspec/dist/security/index.d.mtsas a program input of plugin-grid's src project — but it does so on the reverted tree too, becausepackages/types/dist/index.d.tscarriesexport 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 lintisturbo run lint(per-packageeslint .), andeslint.config.jssets noparserOptions.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 owneslint .in full — 124 files, the package's entire lint job — rather than the whole farm; CI runs the rest regardless.Generated by Claude Code