Repository navigation
fix(client)!: every limit query-parameter emitter sends what the caller wrote - #20060
Conversation
The published SDK set the `limit` query parameter behind three guard
spellings: a truthy test on three methods, `!= null` on four, and
`!== undefined` on seven. One input therefore got different answers by
method: `{ limit: 0 }` was silently dropped on some (the server answered
200 with its default window) and sent on the others (400), and `null`
was dropped on some and sent as the text `null` on the rest.
All fourteen emitters now use `!== undefined`: only an absent value is
left off the wire, and the declared door judges everything else. The
SDK still does not validate `limit` itself. The `automation.runs.list`
docblock that described the truthy exception is rewritten to match.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
Drives every `limit` query-parameter emitter through the real method against a stubbed fetch and reads the requested URL: an absent value is not sent, and `20`, `0`, `NaN` and an untyped `null` are all sent as written. A census row counts the emitters in `index.ts`, so a new one cannot arrive with its own guard spelling and no row here. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2a3e2f580f61690eb5147a6a0c072373a1434cdc && git checkout 2a3e2f580f61690eb5147a6a0c072373a1434cdc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 57c2b7371210dff6dc72d5d42d94a67f08fd3530 ba1c3eae92db9487da62501d7361511846c42124 && git checkout -B drift-repro 57c2b7371210dff6dc72d5d42d94a67f08fd3530 && git merge --no-ff ba1c3eae92db9487da62501d7361511846c42124
node scripts/docs-audit/affected-docs.mjs --json 57c2b7371210dff6dc72d5d42d94a67f08fd3530
|
Fixes #19567
Clause-②: no (narrowing)
What this does
@objectstack/clientset thelimitquery parameter behind three different guards, so one input got a different answer depending on the method. All 14 emitters inpackages/client/src/index.tsnow use!== undefined. Only an absent value stays off the wire.0,NaNand an untypednullleave the client exactly as written, and the door that declares the bound decides. The SDK itself still does not validatelimit, per the ruling: an SDK that refuses0on its own is the wider change triage said must be raised separately.Every change is a narrowing:
0,NaNandnullinstead of dropping them.!= nullemitters now sendnullinstead of dropping it.!== undefinedemitters are byte-unchanged.{ limit: null }goes out as the textnullon all 14 emitters, where before it went out on 7. Everylimithere is typednumber | undefined, sonullonly arrives from an untyped caller. The doors that parselimitrefuse it with400 invalid_number. Droppingnullinstead would turn those 7 emitters from400to200with a default page, which is a widening, and the stop valve says not to ship that. That route was not taken. No in-repo caller passesnullor0to any of these methods, and neither does objectui. I checked both:apps/console/src/pages/developer/FlowRunsPage.tsx:451passeslimit: 20,plugin-grid/src/ImportWizard.tsx:1484passeslimit: 50, anduseClientNotifications.ts:157passes nothing. That was objectuimainat961ceaa.The
automation.runs.listdocblock said "0andNaNare the exception, and they are dropped rather than refused: the guard below is truthy". That is no longer true, so it is rewritten. Every other docblock and README line on these methods is still true. I searchedpackages/client/**andcontent/docs/**, and the only hand-written statement of the SDK'slimithandling was that one docblock.Census, re-run on
origin/main5581d300The PM counted 13 emitters; there are 14. The 14th spells the key inside a template literal rather than through
.set('limit', …):meta.getAuditat:2143, with the guardopts?.limit !== undefined ?followed by`?limit=${opts.limit}`. It was already!== undefined, so the route leaves it unchanged. It is in the census and in the test table. The PM's other correction holds: the twolistImportJobsrows (:7086,:7932) are client emitters, not server-side reads.5581d300meta.getHistory!== undefined!== undefinedmeta.getAudit(template literal)!== undefined!== undefinedenvironments.listRevisions!== undefinedautomation.runs.list!== undefinedautomation.listRuns!= null!== undefinedsearch!== undefined!== undefinednotifications.list!== undefinedai.conversations.list!== undefined!== undefinedai.pendingActions.list!== undefined!== undefineddata.listImportJobs!= null!== undefineddata.export!== undefined!== undefinedenvironment(id).meta.getHistory!== undefined!== undefinedenvironment(id).data.listImportJobs!= null!== undefinedenvironment(id).automation.listRuns!= null!== undefinedInstrument.
git grep -n limit -- packages/client/src/index.tsreturned 48 lines, each read by hand. I then ran two narrower patterns over the same file:.set('limit',gives 13 hits, and a template?limit=followed by an interpolation gives 1 hit. The file has noURLSearchParamsbuilt from an object and noObject.entriesloop that could setlimitthrough a variable. The twoObject.entriesloops at:6978and:7854expand afindfilter object into field filters.Lit control. The same instrument also finds three look-alikes, and I excluded each one by reading it:
data.findmapslimitto thetopkey (:6917,:7825), so it is a different wire key.:4024hard-codes&limit=1, so it is not caller-driven.query-builder.ts:285writes a QueryAST member for a POST body, not a query parameter.No other file in
packages/client/srcemitslimit, so the family stays inside this one file and no helper was added.What each door does with the value
In-repo doors were measured by reading the handler and then running the door's own parse against each spelling. The probe imported the real
parseIntegerParamfrompackages/runtime/src/query-param.tsand evaluated the other doors' inline expressions. Excerpts are in the report.limitis declared0NaN/nullGET /api/v1/meta/:type/:name/history(and its/environments/:idmount)HistoryMetaItemRequestSchema.limit=z.number().optional(), deliberately unbounded (spec api/protocol.zod.ts:1532)200, zero events (sys-metadata-repository.ts:1208)Number.isFinite(rest-server.ts:7708),200with the whole logGET /api/v1/meta/:type/:name/auditAuditMetaItemRequestSchema.limit, clamp[1, 500], "never refused" (:1421)protocol.ts:8906)rest-server.ts:7877), default 100GET /api/v1/cloud/environments/:id/revisionsobjectstack-ai/cloudGET /api/v1/automation/:name/runsListRunsRequestSchema.limit=int().min(1).max(100).default(20)(api/automation-api.zod.ts:569)400 VALIDATION_FAILED,min_value400 VALIDATION_FAILED,invalid_number200with the default 20 →400/environments/:idmount)400(already sent)NaN400(already sent);nullwas droppednull:200with the default →400GET /api/v1/searchGLOBAL_SEARCH_PARAMS(rest-server.ts:10236)NaN, so there is no overall cap (protocol.ts:11756,:11805); see the notesGET /api/v1/notificationsListNotificationsRequestSchema.limit=z.number().optional(), declared clamp into 1..200 (:2781)200, one row (messaging-service.ts:541)400 VALIDATION_FAILED,invalid_number(notifications.ts:75)0: 50 rows → 1 row;NaN/null:200→400GET /api/v1/ai/conversationsListAiConversationsRequestSchema.limit=int().positive().optional()(:2942)service-ai)GET /api/v1/ai/pending-actionsListAiPendingActionsRequestSchema.limit=int().positive().optional()(:3078)service-ai)GET /api/v1/data/import/jobs(and its/environments/:idmount)ListImportJobsRequestSchema.limit=int().min(1).max(200).default(50)(api/export.zod.ts:503)rest-server.ts:9714)null: dropped → sent, but both answer 50, so inert on the wireGET /api/v1/data/:object/exportrest-server.ts:9758)rest-server.ts:9841)NOT MEASURED: cloud doors (rows 3, 8, 9), reason: served by objectstack-ai/cloud, which this container cannot read (git ls-remote asks for credentials).Only row 3 changes on the wire, and for row 3 the client-side change follows the ruling whatever the door answers. Its0/NaN/nullanswer should be read in that repo.The door-side gaps above (rows 6, 10, 11, and row 1 on
NaN) were not touched here. This card is the SDK half. They are in the acceptance notes.Tests
packages/client/src/limit-guard-family.test.tsdrives all 14 methods through the realObjectStackClientagainst a stubbedfetch, and reads the URL each one requested. For every emitter it checks five inputs:undefinedis not sent;20,0andNaNare sent as written; and an untypednullis sent asnull. A census row counts the emitters inindex.tsagainst the table, so a new emitter has to join it. Result:Tests 71 passed (71).441ecced), then restored the truthy guard onautomation.runs.listthroughscripts/ablation-replace.mjs. The anchor hit exactly once; the anchor count went 1 → 0, the replacement count 0 → 1, and the blob236492ae9044→d2feaa80a8f4. Result:Tests 3 failed | 68 passed (71), and exactly the threeautomation.runs.listrows went red (0,NaN,null). The restore is proven: the blob after restore equals the HEAD blob236492ae9044, andgit diff HEADis empty. The script also carried its owntraprestore.pnpm --filter @objectstack/client exec vitest run:Test Files 50 passed (50),Tests 640 passed (640).pnpm --filter @objectstack/client run typecheck(tsc --noEmitplus the test-layer program): exit 0, "0 file(s) / 0 error(s)".441ecced.git diff 441ecced ba1c3eae -- packages/is empty: the two later commits touch only the changeset.Gates, at
ba1c3eaenode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 60 commands from the real diff, the same list the PM derived. All 60 exit 0, and--ranreconciliation reports "60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)".check:skill-examplesandcheck:dual-build-cjs-loadsfirst exited 3, "PREREQUISITE NOT MET: no dist", in the pre-build pass. Afterturbo run build --filter=!@objectstack/docs --concurrency=2, both exit 0.dual-build-cjs-loadsreports "104 published require entry point(s) across 67 package(s) load".check:adr-0087-registration --base origin/main: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", with the dispositionnot-required (no-migration-prescription).pnpm lint, the full unioneslint . --no-inline-config: exit 0 atba1c3eae.Changeset
.changeset/19567-client-limit-guards.md:@objectstack/clientminor, with a BREAKING banner (a call that used to answer200can now answer400), the ADR-0087 dispositionnot-required (no-migration-prescription), andClause-②: no (narrowing)copied from the claim. It is not a patch, because a narrowing ships at leastminor.Acceptance notes
getAudit. Triage's comment citesapi/automation-api.zod.ts:66as the bound behindautomation.runs.list, but:66isListFlowsRequestSchema(GET /automation). The runs door's bound is:569:min(1).max(100).default(20). The direction of the ruling is unaffected.offsetondata.listImportJobs(:7088) and on its scoped twin (:7934) still uses!= null, sonullis dropped there while the siblinglimiton the same call now sends it. The door reads both as its default either way (Number(q.offset) || 0). Class: none of a/b/c, and no carrier.top/skipondata.find(:6947,:6948,:7837,:7838) and the canonical normalizer'slimit/offset(:6917,:6918,:7825,:7826) use!= nullby an earlier deliberate choice ("PRESENCE, not truthiness"). An untypedlimit: nullonfindtherefore sends notop, which on that open GET route means the entire match set.nullis outside the declared type. Class: none of a/b/c, and no carrier.sinceSeqandperObjectalready use!== undefined.?limit=is silently widened or substituted on the doors that do not go throughparseIntegerParam.GET /api/v1/searchloses its overall cap (Math.max(1, Math.min(100, NaN))isNaN, and a hit count compared againstNaNnever reaches it).GET /data/:object/exportanswers a one-row export.GET /meta/:type/:name/historyanswers the whole change log. All three return200.GET /data/import/jobsdeclareslimitint().min(1).max(200)and refuses nothing:?limit=0answers the default 50 rows and?limit=500answers 200. Seam:spec:ListImportJobsRequestSchema.limit→runtime:packages/rest/src/rest-server.ts:9714.limit/cursorand never read them, one reportinghasMore: falseas a literal — REBUILD of #19365, which stopped resolving on 2026-09-21 #19543's doors (cursor,hasMore, pagination semantics) are untouched; only thelimitguards moved.Generated by Claude Code