Repository navigation
fix(rest)!: PUT and the five by-name /meta sub-doors declare closed query sets, so ?member= is refused, never dropped (#23072, RestServer half) - #23133
Conversation
…are closed query sets PUT /meta/:type/:name and GET .../references, /layers, /audit, /diff and /published each open with refuseUnknownQueryParams over a set measured from their read points, so ?member= (and any other name they do not read) is a located 400 instead of being dropped. The unknown-parameter message says "This endpoint takes no query parameters." for an empty set. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
…cated 400 The census caller: the pin sent an inert ?organizationId= that PUT never read; the door's closed set refuses it, so the pin splits into the body half (still 200, nothing reaches the save) and the query half (400, nothing written). Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 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 — 17 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 35bc6f53722f0993ebb78781a87b3916841ed951 && git checkout 35bc6f53722f0993ebb78781a87b3916841ed951
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dc93781c1947b53cff50a550ad551e462a464594 d64fc8e605e08f7748dd5e858ca80770ef9ec50b && git checkout -B drift-repro dc93781c1947b53cff50a550ad551e462a464594 && git merge --no-ff d64fc8e605e08f7748dd5e858ca80770ef9ec50b
node scripts/docs-audit/affected-docs.mjs --json dc93781c1947b53cff50a550ad551e462a464594
|
Contract reviewServed-tier: Rendered 2026-10-13T04:40Z by the adopting seat's session, read-only: the three-dot diff against ① Derived judgmentsEvery accept-set and public-surface change the diff implies, judged against each handler's ACTUAL read points at this head (
② Semver levelWrong — this is the FAIL: the changeset under-declares a breaking accept-set narrowing.
Remedy for the next head — ③ Boundary flags
Everything in ① and ③ holds; ② alone fails this head. A new head carrying the three changeset edits and no other change can be re-recorded against ① and ③ as written here. Implemented-by: VERDICT: FAIL Generated by Claude Code |
…ept-set narrowing Grade `@objectstack/rest` `minor`, carry the `fix(rest)!:` summary line, declare `Clause-②: no (narrowing)`, state the ADR-0087 disposition `not-required (no-migration-prescription)` with its facts, and add the launch-window BREAKING line. The per-door remedies are unchanged. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
Part of #23072
Clause-②: no (narrowing)
This is the
RestServerhalf of #23072.PUT /meta/:type/:nameand the five by-name sub-doors outside ruling 6116165299's list (/references,/layers,/audit,/diff,/published) now each declare a closed query-parameter set (AGENTS.md route rule 5). So the bundle member selector?member=, and any other name a door does not read, gets a located400 VALIDATION_ERRORinstead of being dropped.#23072 remains open for the other half: the runtime dispatcher's
/metaitem GET. That half needsMETA_ITEM_READ_PARAMSand the refusal sentence from@objectstack/rest. Neither is on the package's published entry, so reaching them needs a NEW public export. The dispatch reserved that decision, so the half goes back to the seat asneeds_decision, with the measurement below. No dispatcher code is in this PR.Patch round 1: the changeset declares the narrowing (new head
d64fc8e605)The contract review of
d813364338(6138381862) failed on ② only. The changeset graded six doors' accept-set narrowingpatch, under a bareClause-②: noand with no ADR-0087 disposition, so every gate read it as non-breaking. This round edits.changeset/23072-meta-doors-closed-sets.mdonly (commit49994b3335), then mergesorigin/main(merge commitd64fc8e605, the new head). No source file moves, and the merge brought nothing underpackages/rest.'@objectstack/rest': minor(waspatch).fix(rest)!: …, and this PR's title carries the same!.Clause-②: no (narrowing)(wasClause-②: no), in the changeset and on line 2 above.adr-0087: not-required (no-migration-prescription). Its facts, each checked against the diff:packages/spec, no metadata schema and no stored shape is in the diff.packages/rest/src/index.tsdoes not re-export. The build entry issrc/index.tsalone, andexportsholds only..unknownQueryParamMessage, which the entry does not export either.@objectstack/restpublishes.**BREAKING** (an accept-set narrowing), shipped asminor.Gates at
d64fc8e605, each exit captured before any pipe:node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0,check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.The signals read areBREAKING+bang+clause-②-narrowing, and the disposition isnot-required (no-migration-prescription).node scripts/check-changeset-no-major.mjs --base origin/main: exit 0,This diff introduces no major bump.Locally the level axis reads NOT APPLICABLE, because there is nopull_requestpayload. Driven offline with--eventcarrying this title and body: exit 0,LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose published source … it moves is graded patch.Control, the same body against the old headd813364338, whose changeset still sayspatch: exit 1,clause-② declares no (narrowing) while no moved package is graded minor or above. So line 2 and the changeset have to move together, and atd64fc8e605they do.node scripts/check-empty-changeset.mjs --base origin/main: exit 0.pnpm check:changeset-gate-self-tests: exit 0 (170, 548 and 401 assertions).dispatch-gates --commandswith no paths, atd64fc8e605: 65 commands over 5 paths against merge basedc93781c1, and all 65 exit 0. On the first passcheck:dts-closure,check:dual-build-cjs-loadsandcheck:lean-entry-closureexited 3 (PREREQUISITE NOT MET: a fresh worktree has nodist/). Afterturbo run build --filter='!@objectstack/docs' --concurrency=2through the lock (74 successful,VERDICT command-exit 0) all three exit 0.check:sourcemap-no-sources-contenthad run while that build was still writing, so it was re-run on the finished build: exit 0.dispatch-gates --ran:65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED.origin/mainmoved toe540e7ffb9during the run, 5 commits, none underpackages/restand none in the changeset gates. The same derivation over this PR's 5 paths, on a throwaway checkout ofe540e7ffb9, gives the identical 65 commands.@objectstack/resttests were not re-run: the merge brought no change underpackages/rest, and this round moves no source file.The defect, per door
None of the six declared a set. Each read the names it knew and ignored the rest.
PUT /meta/email_template/x?member=zh-CNwith anen-USbody wroteen-USand answered200. The five GETs answered for the canonical member a request that named another. Both are plausible answers about the wrong row, and no status, header or field told them apart.The sets, measured from each handler's read points
All six live in
registerMetadataEndpointsInner, on basebcebd8409d. Each door's helpers were read too, not just the handler body. The whole-request helpersresolveExecCtx,resolveProtocol(route, hostname,X-Environment-Id),resolveObjectMasker,resolveMetadataService,walledCatalogRefusal,resolveMetaWriteActorand the read-gate sources read no query key. The cloud host'skernel-resolverstrategy is not in this tree, so it was not read.META_ITEM_*_PARAMS)PUT /meta/:type/:nameforce(handler),package(metaItemPackageBinding),mode(a frame down,metaSaveRequestOptions,meta-save-request.ts:118)META_ITEM_SAVE_PARAMS=force,package,modeGET …/referencesMETA_ITEM_REFERENCES_PARAMS= (empty)GET …/layerspackage(a frame down,serveMetaItemLayered)META_ITEM_LAYERS_PARAMS=packageGET …/auditlimit(readDeclaredQueryNumber)META_ITEM_AUDIT_PARAMS=limitGET …/difffrom,to,fromVersion,toVersion(readDeclaredQueryNumber)META_ITEM_DIFF_PARAMS= those fourGET …/publishedMETA_ITEM_PUBLISHED_PARAMS= (empty)PUT,/auditand/diffalready had a multiplicity guard over exactly these names. Recognition runs just before it, after the capability and authoring gates, which is the placement the five ruled doors use./references,/layersand/publishedopen with recognition, as the item GET does. The refusal is the file's ownrefuseUnknownQueryParams, which writes the nested envelope{ error: { code: 'VALIDATION_ERROR', message } }. That is the envelope the five ruled doors answer for a name they do not know.PUTstays body-addressed (ruling 6116165299): it refusesmemberand does not read it. None of the five sub-doors starts honouringmember, because their protocol requests do not declare it. They refuse it.One edit outside the claimed file surface:
query-allowlist.ts/referencesand/publishedread no query parameter, so their closed sets are empty. They are the first empty sets in the repository. With an empty set,unknownQueryParamMessageendedSupported parameters: ., which reads as a truncated answer and would have shipped with this PR. The function now endsThis endpoint takes no query parameters.when the set is empty. Every non-empty set's sentence is byte-identical to before: the three existing pins of that message (rest-server-approvals-unknown-filter.test.ts,rest-server-closed-query-params.test.ts,rest-server-meta-member-selector.test.ts) pass unchanged. This file is not in the claim's file surface. It is declared here, and in the report to the seat, rather than worked around with a second refusal dialect.Caller census: every in-repo caller of these doors, and what it sends
@objectstack/clientmeta.saveItem(both clients, onemetaSaveQuerybuilder)force,package,mode@objectstack/clientgetReferences/getPublished@objectstack/clientgetAudit/diffItemlimit/from,to1fb1ced36243,@object-ui/data-objectstackMetadataClient(save,layered,references,diff,audit), shallow-fetched read-onlyforce,mode,package/package/ nothing /from,to/limit; no/publishedcallerUnpublishedAppBar.tsx,metadata-admin/external/api.ts(PUTbyfetch)PUTwith a query stringpackage×21,mode×16,force×1/layersand/publishedcallerspackages/rest/src/meta-item-save-capability-gate.test.ts"query/body-smuggled organization ids"?organizationId=onPUT, a name the door never readThe one inert caller is a test pin written while
PUTdropped unknown names: it asserted that a query-smuggledorganizationId"does not reach the write" by watching it get dropped. It is split in two. The body half is unchanged:200, andorganizationIdis not in the save request. The query half is now the located400, withsaveMetaItemnever called. That is the same invariant, made loud. No SDK or console caller sends a name outside a new set, so the census found noneeds_decision.Not here: the runtime dispatcher's
/metaitem GETMeasured on
bcebd8409d,packages/runtime/src/domains/meta.tshandleMetadataRequest:state,preview,package,layers(wantsMetaItemLayers) andlocale(metaRequestLocale, insidecreateMetaItemAnswer).memberis never read and never forwarded.@objectstack/runtimeimports from@objectstack/rest(a single.export). Its index exportsmetaItemPackageBinding,metaSaveRequestOptions,repeatedQueryParamMessageand the read-gate family. It does not exportMETA_ITEM_READ_PARAMS,META_ITEM_MEMBER_PARAM,metaMemberSelector,refuseUnknownQueryParamsorunknownQueryParamMessage. Reaching any of them needs a new public export, so this PR stops there.RestServerwrites{ error: { code: 'VALIDATION_ERROR', message } }. The dispatcher'sdeps.error(message, 400)writes{ success: false, error: { code: 'VALIDATION_ERROR', message, … } }throughbuildApiError. Therestindex already records that split for the multiplicity rule ("a dispatcher domain takes the message and builds its own body"). So "the same envelope" means the sameerror.codeand the same message, each in its own transport's body.membercosts there, beyond the export: threegetMetaItemcalls need it (theobjectbranch, the generic branch andreadPendingDraft). Two lookups wrapgetMetaItemin a barecatch {}, which would swallow the protocol's located400for a bad selector and fall through to the registry orMetadataService. TheMetadataServicefallback (getItem(type, name, packageId)) keys by name alone, so for a member the protocol answers absent it would serve the canonical member. The?layers=withmemberrefusal is a sentence inrest-server.tstoo.The options and their analysis are in the report on #23072.
For the spec lane: could PR #23070's reader serve
memberon the five sub-doors?This is read from source, not built.
resolveBundleMemberSelectorresolves the selector on every door. What differs is whether the door's store can address a member:/references: no, permanently.findReferencesToMetasweeps the graph for items that name(type, name). A reference names an item, not one member of a bundle, so there is nothing to select. The refusal is the right answer, not a gap./layers: yes, with threading. The overlay readfindServedOverlayRowalready takesmember, and so do the code readslookupArtifactItemandregistry.getItem.getMetaItemLayeredpasses none. TheMetadataServicecode read (readItemFromMetadataService) would need the addressed-member checkgetMetaItemapplies.overlayLockLayerAtreads every member's rows by name.GetMetaItemLayeredRequestSchemawould need the field./audit: no, not with the reader alone.sys_metadata_audithas no member column (type,name,organization_id,operation, …), so every member's attempts share one trail. Servingmemberneeds a column, the writer stamping it, andAuditMetaItemRequestSchema.member./diff: yes, with threading.historyRowsOfMember(type, rows, member)already takes a member, anddiffMetaItempasses none. The defaultto(the active version) would read a member-scoped active row, andRestServer's per-caller gate reads the canonical current document (fetchCurrentMetaDocument). No spec request is declared for the door./published: partly. The overlay half ridesgetMetaItemLayered, as/layersdoes. The code and package snapshot fallbackgetPublishedreadsMetadataService, which keys by name and holds one member per name.Evidence
The measurements below name the head they ran on. Code commit
f0f77809f4is the source everyrestandruntimerun below built from.d813364338adds only the census pin's split.pnpm turbo run build --filter='@objectstack/dogfood^...' --filter='@objectstack/runtime' --concurrency=2gaveTasks: 64 successful, 64 total, lockVERDICT command-exit 0. Then the 8 packagescheck:dual-build-cjs-loadsnamed as unbuilt gave58 cached, 58 total,BUILD8_EXIT=0.@objectstack/resttypecheck (tsc --noEmit && check:test-typecheck):TYPECHECK_EXIT=0,check:test-typecheck: OK … 0 file(s) / 0 error(s).@objectstack/resttest, wholelocalproject, at52b332b6d5:Test Files 1 failed | 275 passed (276),Tests 1 failed | 5781 passed | 326 skipped (6108). The one red is the census caller above. After its split, atd813364338: the new pins, that file and the ruled-doors file (rest-server-meta-door-closed-sets,meta-item-save-capability-gate,rest-server-meta-member-selector) gaveTest Files 3 passed (3),Tests 72 passed (72). Therepoproject gaveTest Files 5 passed (5),Tests 213 passed | 2 skipped (215). Between52b332b6d5andd813364338only that test file changed (packages/rest/srcis otherwise byte-identical). So the whole-package line at the final head is a declared narrowing: the changed file was re-run green, and CI runs the full set.@objectstack/runtimetypecheck:TYPECHECK_EXIT=0. Testlocal:Test Files 363 passed (363),Tests 5174 passed | 19 skipped (5193).repo, which includesmeta-list-projection-parity.test.tswith its/layersread census overrest-server.ts:Test Files 3 passed (3),Tests 751 passed (751). LockVERDICT command-exit 0.rest-server-meta-member-selector.test.tsis unchanged and green in the 72 above.pnpm exec vitest run --maxWorkers=2over the 9PUT-with-query files, the 7/layersand/publishedcallers,email-template-two-locales-survive-bootandflow-shipped-name-by-name-read, atd813364338:Test Files 18 passed (18),Tests 156 passed (156), lockVERDICT command-exit 0. The first lock attempt wasexit 99queue-timeout, which is NOT MEASURED; the re-run used the same slot.d813364338, insideos-verify-lock.shwith a restore trap, throughscripts/ablation-replace.mjs. The mutation dropsPUT'srefuseUnknownQueryParams(req, res, META_ITEM_SAVE_PARAMS), with marker__ABLATION_23072_SAVE_RECOGNITION_DROPPED__. Counts on disk: before anchor 1 / marker 0, during anchor 0 / marker 1. Blob9a64fac24d3c → f1b89f68cd07. Result:Tests 3 failed | 18 passed (21). ExactlyPUT's three refusal pins are red, and every other door's and every preservation pin stays green. Restored: blob9a64fac24d3c== HEAD,git diff HEADis empty, and after the restore the counts are anchor 1 / marker 0. The rest test file imports./rest-server.jsfromsrc/, so nodist/is in this loop. The expected direction was red, and red was observed.dispatch-gates --commandsunion, 65 commands, run atd813364338with each exit code captured before any pipe: all 65exit 0.dispatch-gates --ran:65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED. An earlier pass hadcheck:dual-build-cjs-loadsatexit 3(PREREQUISITE NOT MET, 8 packages unbuilt). After building them it exits 0 (108 published require entry point(s) across 67 package(s) load). Added, as the dispatch asked:pnpm check:route-ledger-censusexit 0(all 1 census sentence(s) match their arrays), andpnpm check:route-envelopeexit 0, which is also in the 65. The per-gate verdict lines are in the report on meta doors: the bundle-member selectormemberis dropped silently by the runtime dispatcher GET, by PUT, and by the by-name doors outside the ruling (no closed query sets); honour it or refuse it loudly #23072.pnpm lintis CI's. ① Population, read fromeslint.config.mjs:files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minusNEVER_LINTED, andisPathIgnoredis false for all four touched.tsfiles. ② Count, fromeslint --no-inline-config --format json: 4 files, 0 errors, 0 warnings, 0 fatal, exit 0, atd813364338. ③ Invariance: the resolved config hasparserOptions.projectnull andprojectServicenull, so type-aware linting is off. The enabled rules (no-restricted-imports,slot-lookup/no-any-assignment,no-restricted-syntax,query-options/no-any-erasure,verify-stand-in/no-asserted-driver-argument,comment-swallow/no-code-inside-block-comment) are per-file AST rules, so this diff cannot move any untouched file's verdict.Acceptance notes
origin/mainin patch round 1 (d64fc8e605, a merge commit overdc93781c19). The 15 incoming commits touch nothing underpackages/rest. The branch's delta againstorigin/mainis still these 5 files, +357 / −3 (the changeset is now 23 lines), andpackages/specis byte-identical toorigin/main's./layers,/publishedandPUTbranches declare no closed set either. ItsPUTdrops?member=(and, by its own settled ruling,?force=) exactly asRestServer's did. These are the same family as meta doors: the bundle-member selectormemberis dropped silently by the runtime dispatcher GET, by PUT, and by the by-name doors outside the ruling (no closed query sets); honour it or refuse it loudly #23072 but outside its population: the card names the dispatcher's item GET only. They are named in the report for the seat.@objectstack/restminorunderClause-②: no (narrowing), with one ADR-0087 disposition (patch round 1, above). The claim'spatchand bareClause-②: nounder-declared the narrowing.@objectstack/runtimeis not in it, because no runtime file changed.Generated by Claude Code