Repository navigation
fix(runtime)!: the dispatcher's by-name /meta doors ask RestServer's query decision, so ?member= is honoured or refused, never dropped (#23072, dispatcher half) - #23211
Conversation
metaItemQueryRefusal (meta-item-read-gate.ts) judges the item read, /layers, /published and the dispatcher's PUT: recognition over the door's closed set, the arity of its single-valued names, and the item read's refusal of a member on the layered view. RestServer's item GET is re-pointed at it with its answers unchanged; the runtime dispatcher asks it on all four doors and its item read honours ?member= through every protocol read, with the name-keyed fallbacks never answering a named member. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
…sports The decision's sets, arity, rule order and in-place unwrap; RestServer's item read writing exactly the decision; the dispatcher's item read honouring ?member= through every protocol read with the protocol's refusal handed on and the name-keyed fallbacks never answering a named member; /layers, /published and PUT refusing what they do not read. meta-list-projection-parity's ITEM_PROBES gains the member selector on both transports. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
…'s query decision 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>
Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 35 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 1408904256333014658b921c67ea6c87fe6bcdd1 && git checkout 1408904256333014658b921c67ea6c87fe6bcdd1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 150da61aa54c6a287bdc834db3b13399d18e50d9 6fe8e7462997a86173eedf17e0625062bdebfd76 && git checkout -B drift-repro 150da61aa54c6a287bdc834db3b13399d18e50d9 && git merge --no-ff 6fe8e7462997a86173eedf17e0625062bdebfd76
node scripts/docs-audit/affected-docs.mjs --json 150da61aa54c6a287bdc834db3b13399d18e50d9
|
Contract reviewServed-tier: Rendered 2026-10-13T09:21Z by the adopting seat's session, read-only, adversarially: the card thread (triage ① Derived judgmentsEvery accept-set and public-surface change the diff implies, named right or wrong against the handlers' ACTUAL read points on both transports.
② Semver levelRight. Judged against AGENTS.md Post-Task Checklist step 3,
③ Boundary flagsThe report
Everything in ① and ③ holds, and ② holds. Nothing in this record is a finding the seat must act on before landing; the two residuals and the class-b filing are follow-ups. Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #23072
Clause-②: yes (narrowing)
The dispatcher half of #23072, under the maintainer's ruling B (
6138588561): one portable query decision inpackages/rest/src/meta-item-read-gate.ts, called by both transports. Slice 1 (theRestServerhalf, PR #23133) landed atc4b84ad702. This slice closes the card.The decision
metaItemQueryRefusal(door, query), new on@objectstack/rest's entry.dooris'item','layers','published'or'save'. It answers{ status: 400, code: 'VALIDATION_ERROR', message, rule }, orundefinedto go on. The rules, in order:rule: 'unknown-parameter'): a name outside the door's closed set, with the sentencerefuseUnknownQueryParamsanswers;'repeated-parameter'): a single-valued name supplied twice, with the multiplicity rule's sentence. A name supplied once as a one-element array is unwrapped in place, asrefuseRepeatedQueryParamsunwraps it;itemonly, a member on the layered view ('layered-member'):RestServer's sentence.metaMemberSelector(query), new on the entry: the selector's one reading,{ member }or{}. It moved unchanged fromrest-server.ts.MetaItemQueryDoorandMetaItemQueryRefusalare module exports the emitted.d.tsdeclares locally beside the two functions (read indist/index.d.tsanddist/index.d.cts).meta-item-read-gate.ts, values unchanged, still exported fromrest-server.tsby re-export (sorest-server-meta-member-selector.test.tsandrest-server-meta-door-closed-sets.test.tsimport them unchanged):META_ITEM_MEMBER_PARAM,META_ITEM_READ_PARAMS,META_ITEM_SAVE_PARAMS,META_ITEM_LAYERS_PARAMS,META_ITEM_PUBLISHED_PARAMSandmetaMemberSelector. They have to sit with the decision:rest-server.tsimports the gate module, so the gate module cannot import it back.META_ITEM_SAVE_PARAMSlessforcerather than kept by hand; and the layered-member sentence.query-allowlist.ts:unknownQueryParamRefusalMessage(query, allowed)is the recognition sentence as a pure function.refuseUnknownQueryParamsnow writes it, so every route keeps its answer. The decision takes the arity halves (readSingleQueryValue,repeatedQueryParamMessage) fromquery-multiplicity.ts, whose index note names them as the portable halves.RestServer's item GET, re-pointed in spelling onlymetaItemQueryRefusal('item', req.query)once, at its top. Rules 1 and 2 are written there, in the nested{ error: { code, message } }their gates wrote. Rule 3 is thrown where it always was, after the protocol and the mask posture, tohandleRouteError, which writes its flat body.RestServer's two item GET routes,/api/v1/meta/:type/:nameand the environment-scoped one. It sent 30 queries under 8 configurations (cache on and off; a protocol with and without a layered read, with and without a member refusal; a system caller and a plain caller). It recorded every status, body, header, thrown error, the query object after the call (the in-place unwrap) and every protocol call, on the base treede24373ecband onaf356c3a13. Result: 480 records, 256 of them400and 224200. Both dumps are 348398 bytes,cmpreports them identical, and both hash to sha256e27c1b55dc34648f12861a8489e8d75516b384bc6c8b4b6e54de0b92f8177ca9.rest-server-meta-member-selector.test.tsis unchanged and green.The runtime dispatcher, per branch
Each branch now asks the decision through
deps.error(message, status, { code }), its own nested envelope.handleMetadataRequest)GET/HEADstate,preview(inline),package(metaItemPackageBinding),layers(wantsMetaItemLayers),locale(metaRequestLocalebehindcreateMetaItemAnswer),member(metaMemberSelector, new)membernever readMETA_ITEM_READ_PARAMSand the five-name arity list, bothRestServer's. Rules 1 and 2 are answered at the read's entry, after the405verb check. Rule 3 is answered after the mask posture, as onRestServer.PUTpackage(metaItemPackageBinding),mode(metaSaveRequestOptions)forceandmemberdroppedpackage,mode, both single-valued:META_ITEM_SAVE_PARAMSlessforce, which this door was ruled never to take (its409remedy already says so). Answered after the capability and walled-catalog gates, before any query read./layerspackage(metaItemPackageBinding)META_ITEM_LAYERS_PARAMS,packagesingle-valued, first statement/publishedMETA_ITEM_PUBLISHED_PARAMS(empty), first statementThe member on the item read (ruling B's measured costs):
getMetaItemcalls: the draft read (readPendingDraft), theobjectbranch'sreadFromProtocol, and the generic branch.catches let the protocol's refusal out when a member is named. The outercatchrenders it througherrorFromThrown, so a selector on a type with no members, or a blank one, is the protocol's located400.objectbranch goes protocol-first and skips the registry. The generic branch skipsMetadataService.getItem. A member the bundle does not hold is404, as onRestServer.?layers=beside?member=is refused, whatever the protocol can do.Caller census
@objectstack/client:meta.getItemsendspackage, andmeta.getPublishedsends nothing. Both are accepted.meta.saveItemsendspackageandmode(accepted), andforceonly when its caller passesforce: true(refused on the dispatcher, see below)./layerscaller. No in-repo production code passesforcetosaveItem: the only callers that do areclient.test.tsURL-builder tests over a mocked fetch.1fb1ced36243(a blobless read-only fetch):MetadataClient.readItemResponsesendsstate,previewandpackage, andlayeredsendspackage. Both are accepted.savesendsforceonly fromResourceEditPageandPermissionMatrixEditor's force-save, after a409 DESTRUCTIVE_CHANGEand a confirm. On a dispatcher-only host that retry already failed with the same409(the dispatcher's remedy: "accepts noforce"). It now answers the located400namingforce. That is a changed failure, not a newly red flow./_draftsand list reads, not these four doors.@objectstack/runtimelocal and repo projects: green.@objectstack/http-conformance:hono-meta-item-read-gate,hono-meta-list-read-gateandhono-meta-save-preconditions, which bootcreateHonoAppwith the real dispatcher (PUT ?mode=draft,GET ?state=draft). 3 files, 17 tests green.@objectstack/hono: 6 files, 139 tests green.RestServer, so dogfood exercises the re-pointed item GET. Seven files ran:email-template-two-locales-survive-boot(?member=,?memeber=),flow-shipped-name-layered-read,flow-unshipped-name-layered-code,showcase-object-extension-meta-readandshowcase-object-extension-scalar-divergence(?layers=true),multi-package-artifact(?package=), andmeta-published-and-state-routes. 7 files, 47 tests green.Tests
Everything below ran at head
6fe8e74629, throughscripts/pm/os-verify-lock.sh, and each reportedVERDICT command-exit 0.Typecheck:
@objectstack/rest:tsc --noEmitpluscheck:test-typecheckOK (0 files / 0 errors).@objectstack/runtime:check:test-typecheckOK (27 files / 190 errors / 68 pinned held, the ledger unchanged).--listFilesshows each package'stsconfig.test.jsoncompiles the new and edited test files: rest 1 of 1, runtime 2 of 2.@objectstack/rest:vitest --project local:Test Files 277 passed (277),Tests 5802 passed | 326 skipped (6128).--project repo:Test Files 5 passed (5),Tests 213 passed | 2 skipped (215).@objectstack/runtime:vitest --project local:Test Files 365 passed (365),Tests 5201 passed | 19 skipped (5220).--project repo(withmeta-list-projection-parity):Test Files 3 passed (3),Tests 808 passed (808).The census suites:
@objectstack/http-conformance(the threehono-meta-*files): 3 files, 17 tests passed.@objectstack/hono: 6 files, 139 tests passed.@objectstack/dogfood(the seven files above): 7 files, 47 tests passed.New pins:
packages/rest/src/meta-item-query-decision.test.ts, 19 tests: each door's set and sentence, arity, the in-place unwrap, the order of the three rules,metaMemberSelector, andRestServer's item read writing exactly the decision, nested for rules 1 and 2 and flat for rule 3.packages/runtime/src/domains/meta-item-query-decision.test.ts, 23 tests: the dispatcher's four doors, refusal and preservation for each. Each refusal is compared withRestServer's answer for the same request (code and message), on the item read,/layersand/published. The value pins run against a realObjectStackProtocolImplementationover an engine never reached.meta-list-projection-parity:ITEM_PROBESgains?member=zh-CNand?layers=true&member=zh-CN. The protocol double refuses a member on a non-bundle type, as the protocol's first step does. Both transports agree on all 26 item cells times 4 callers, plus the 2 undeterminedobjectcells. The reference-moves block pins that?member=movesRestServer's answer. That is 57 more parity tests (682 to 739).Controls:
rest-server-meta-member-selector.test.tsandrest-server-meta-door-closed-sets.test.tsare unchanged and green.Ablations
Both legs ran on the committed head
6fe8e74629(and before the merge onaf356c3a13;meta.tsis the same blob2fbc1e6b65f1on both). They ran insidescripts/pm/os-verify-lock.shthroughscripts/ablation-replace.mjs(wrap mode, anchors that must hit), under a bashEXIT/INT/TERMtrap that restoresgit checkout HEAD --on the absolute path and proves the restore by blob hash and an emptygit diff HEAD. The tests importmeta.tsfromsrc/(../http-dispatcher.js), so nodist/preflight applies. Expected direction: red. Observed: red.metaItemQueryRefusal(, 4 hits, replaced by a function that answersundefined(marker__ABLATION_23072_DECISION_DROPPED__). The blob went2fbc1e6b65f1toa21ea5f22452.Tests 38 failed | 724 passed (762).?layers=),/layers(member, repeatedpackage),/publishedandPUT(member,force, repeatedmode), plus the one-element-array unwrap.?layers=true&member=zh-CNitem cell.2fbc1e6b65f1== HEAD,git diff HEADempty, marker count 0.getMetaItem. Marker__ABLATION_23072_MEMBER_FORWARD_DROPPED__; the blob went2fbc1e6b65f1todf1a6d6c06e8.Tests 31 failed | 731 passed (762).viewrefusal.?member=zh-CNitem cell. Theobjectcells stay green, their forward intact.Gates
At head
6fe8e74629:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths. It took the change set from the merge based4aaeaf0e2: 9 paths, +1073 / −110. It derived 65 commands, the PM's count. All 65 ran with the exit code captured before any pipe, and all exit 0.pnpm check:dual-build-cjs-loadsfirst exited 3,PREREQUISITE NOT MET(8 packages withoutdist/: studio, client-react, embedder-openai, knowledge-memory, knowledge-ragflow, organizations, service-cluster-redis, service-knowledge). After building them (58 of 58 cached) it exits 0: "108 published require entry point(s) across 67 package(s) load".node scripts/pm/dispatch-gates.mjs --ran ran.listexits 0: "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED".pnpm check:route-ledger-censusexits 0 ("all 1 census sentence(s) match their arrays").pnpm check:route-envelope(in the 65) exits 0, andrest-server.tsgained no dialect.check:dts-emittedran in the rest build, anddist/index.d.ts/index.d.ctsdeclaremetaItemQueryRefusalandmetaMemberSelectoron the entry's export list.--eventpayload:check-changeset-no-majorexits 0: "LEVEL AXIS: this PR declares clause-②yes (narrowing)… no package … is gradedpatch".check-adr-0087-registrationexits 0: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition …[BREAKING+bang+clause-②-narrowing]not-required (no-migration-prescription)".check-empty-changesetexits 0.pnpm lintis CI's):eslint.config.mjs:files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']lessNEVER_LINTED. ESLint'sisPathIgnoredis false for all 8 touched.tsfiles.eslint --no-inline-config --format jsonover them: 8 files, errorCount 0, warningCount 0, fatal 0, exit 0.calculateConfigForFileanswersparserOptions.projectnull andprojectServicenull for every one, so no type-aware rule runs, and this diff cannot move an untouched file's verdict.Acceptance notes
@objectstack/restminor(two new exports) and@objectstack/runtimeminor(the dispatcher's four accept sets narrow, a BREAKING change shipped asminorunder the launch-window convention).fix(runtime)!:,Clause-②: yes (narrowing), one ADR-0087 dispositionnot-required (no-migration-prescription)stating its facts, and the launch-window BREAKING line, as PR 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's changeset was graded and marked.check-changeset-no-major --eventaccepts every well-formed spelling for this diff, because both packages areminor:yes,yes (widening),yes (narrowing),noandno (narrowing).no (widening)is malformed: its level axis is NOT MEASURED.check-adr-0087-registrationreads the changeset asBREAKING+bang+clause-②-narrowingwith that disposition.yes (narrowing)is the truthful spelling: the entry widens, and four runtime doors narrow./layersthat is before the501for a protocol with no layered read, and onPUTbefore the protocol is resolved.RestServer's own/layersandPUTkeep their gate lines, which this slice does not touch:/layersrecognition is its first statement, and its arity check runs insideserveMetaItemLayered, after the501.400on the dispatcher and501onRestServer. Through thecreateHonoAppcatch-all no repeated name reaches the dispatcher at all (it flattens the URL query, last value wins).refuseRepeatedQueryParamsalways has. It is documented on the function, and every read after it is meant to see the string.meta.ts+117 / −11;rest-server.ts+51 / −90;query-allowlist.ts+27 / −8; the entry +9) and the changeset +23.origin/main(d4aaeaf0e2, 5 commits:metadata-protocol's save door,runtime'spackages.tsandflow-clone.ts, spec retirements; none touches a file in this diff). The build state was refreshed after it (install, closure build,packages/runtime/.objectstackremoved)..claude/**,docs/adr/**,skills/**,AGENTS.mdorCLAUDE.mdpath.Generated by Claude Code