Repository navigation
fix(rest,runtime)!: a JSON null /data create or query is refused 400 on both doors; the dispatcher query validates FindDataRequestSchema and asks exposure first - #23617
Conversation
…th doors; the dispatcher query validates FindDataRequestSchema; exposure is asked before the request gate
Location 1: both POST /data/:object doors drop req.body ?? {} / body ?? {},
so a JSON null create answers 400 VALIDATION_FAILED on data instead of an
empty row.
Location 2: the dispatcher's POST /data/:object/query validates the
assembled FindDataRequestSchema request RestServer's twin assembles, and
forwards exactly that request. RestServer's query drops req.body ?? {} too,
so a null query body is refused on both doors.
Location 3: the dispatcher asks the object's API exposure before each
request gate (PATCH, POST create, POST query) through
refuseUnexposedDataAction, the gate extracted verbatim from callData.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
… both doors Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
…te narrowing; the pending write-door entry no longer says a null create is unchanged Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
…ta-request-contract-remainder Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
…, the path object at the request level; a body query key is no second spelling The forward carried the path object inside the query, which the engine fallback (no protocol registered) refuses as an unservable key. It now hands callData the query the gate parsed, minus the path object, so every body that passes the gate reaches the engine exactly as before, except a body query key: it used to be unwrapped as the query, a spelling the gate never sees, and is now the field it names, as through RestServer. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
…e it now lives, refuseUnexposedDataAction Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
📓 Docs Drift CheckThis PR changes 2 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
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 40162759140c4d9b74fc97ddb3cac83c57854431 && git checkout 40162759140c4d9b74fc97ddb3cac83c57854431
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9f8126048793e9b5760f87130cb83645f4c185ce 7d30b11474108982e49cd48ddb76de589b953a12 && git checkout -B drift-repro 9f8126048793e9b5760f87130cb83645f4c185ce && git merge --no-ff 7d30b11474108982e49cd48ddb76de589b953a12
node scripts/docs-audit/affected-docs.mjs --json 9f8126048793e9b5760f87130cb83645f4c185ce
|
✅ ACCEPT: PR #23617 at
|
Contract reviewServed-tier: Inputs: card #23574 (body and all five comments, the location-3 addition ① Derived judgmentsGate verdicts on the head (41 check-runs, all completed, read at 2026-10-15T05:45Z): 35 success, 5 skipped as expected (Dogfood Verify CLI, Temporal Conformance, Console Pin Gate, the Dogfood Regression Gate shard matrix placeholder, Packed-tarball smoke), 1 failure: Accept-set and surface changes the diff implies, each judged:
② Semver level
Sentence by sentence against the diff and the PR's own measured table: the opening paragraph is true (both The modified pending changeset,
③ Boundary flagsReport
Implemented-by: VERDICT: FAIL One sentence stands between this head and PASS: the query-route clause of bullet 6 in Generated by Claude Code |
|
Seat note: the contract-review record
Generated by Claude Code |
…answer moved; the query route's gate order is stated as a fact The dispatcher's query route had no request gate on the release base, so callData's exposure gate already answered a query on an exposure-denied object 404/405; that FROM never existed. A null create on such an object already answered the exposure refusal too. The opening sentence and the ADR-0087 note say the same, precisely. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
Contract reviewServed-tier: Inputs, read fresh on this head: card #23574 (body and every comment, the patch-round os-dev-report ① Derived judgmentsGate verdicts on the head (48 check-runs, all completed, read at 2026-10-15T06:41Z): 39 success; 7 skipped as expected (Console Pin Gate, Dogfood Verify CLI, Temporal Conformance, the Dogfood Regression Gate shard matrix placeholder, Packed-tarball smoke, and a second The code on this head is the code the superseded record judged. The patch round is one commit (
② Semver levelLevel and declaration, unchanged and right. The corrected text, sentence by sentence against
The modified pending changeset,
③ Boundary flagsPatch-round report
Flags carried from the superseded record, unchanged on this head: the exposure-refusal code split across doors is escalated to #23528 (statuses pinned, codes unchanged); the Implemented-by: VERDICT: PASS Generated by Claude Code |
✅ ACCEPT, round 2: PR #23617 at
|
Fixes #23574
Clause-②: no (narrowing)
The closing card of the
/datarequest-contract family that PR #23571 started, on the same two files. Each of the card's three locations lands with its own pin, beside the twin door. The two doors areRestServer's registered handlers and the runtime dispatcher's/datadomain, which is the only answer on a host that mounts just the@objectstack/honocatch-all.What changed
Location 1: a JSON
nullcreate is refused on both doors.packages/rest/src/rest-server.ts, thePOST /data/:objecthandler:req.body ?? {}is gone from both the gate input and the forward.packages/runtime/src/domains/data.ts, the create branch:body ?? {}is gone. PR fix(rest,runtime)!: both /data write doors validate the request as the caller sent it; a number expectedVersion is refused 400 #23571 had mirrored it on purpose, so the two doors would not split.nullnow answers400 VALIDATION_FAILEDnamingdataon both doors, and no row is written. This matches what PR fix(rest,runtime)!: both /data write doors validate the request as the caller sent it; a number expectedVersion is refused 400 #23571 did forPATCH.null. Measured in source:plugin-hono-server, the@objectstack/honocatch-all and the conformance adapter all hand an empty body over as{}. That value is a record, so it still creates a row (pinned as a control).Location 2: the dispatcher's
POST /data/:object/queryvalidatesFindDataRequestSchema.RestServer's twin assembles, statement for statement:{ object, query }, where a record body is the query with the path object written last, and any other body is the query as sent.400 VALIDATION_FAILEDwitherror.details.fields. It goes throughrequestSchemaRefusal, which is PR fix(rest,runtime)!: both /data write doors validate the request as the caller sent it; a number expectedVersion is refused 400 #23571'swriteRequestRefusalwidened to'query'and renamed, since it is no longer only for writes.object, not inside the query. That keeps the engine fallback working when no protocol is registered (it serves a query's clauses only, andaction-execution-calldata-batch-retired.test.tspins that path). Every body that passes the gate reaches the engine exactly as before, with one exception, described next.querykey used to be unwrapped as the query itself (callDatareadsparams.queryfirst). The gate cannot see that spelling, so{ "query": { "limit": "ten" } }would have passed it and been served as the full set. The catalog row for this route says the body is the QueryAST, which is howRestServerand@objectstack/client'sdata.querytreat it. So the key is now the field it names, and the engine refuses it400 INVALID_FIELD, as it already does throughRestServer. Thedata-path-object.test.tspin "still honours an explicitqueryenvelope" is flipped (see Pins).H4, folded in (same family, same fix): a JSON
nullquery is refused on both doors.RestServer's query handler readreq.body ?? {}too, so anullbody was treated as no query and answered the full record set, on both doors.FindDataRequestSchema.querydeclares a QueryAST or nothing, nevernull. So?? {}is dropped fromRestServer's query handler, which is the second region ofrest-server.tstouched here (the dispatch allows it onceRestServerowes a change).nullnow answers400 VALIDATION_FAILEDnamingqueryon both doors.Location 3: exposure before the request gate, measured as read.
PATCHbody that is not a record, or a create body that is an array, a string or a number, with its request gate's400(anullcreate, which the old gate read as{}, and every query, which had no gate, already reached the exposure gate).RestServer, whoseenforceApiAccessruns first, answered405or404. The table below has the measurements (404/405, not403).PATCH,POSTcreate andPOSTquery), at the position whereRestServerasksenforceApiAccess. OnPATCH, the version-token refusal still runs first on both doors, as before.callData. It is now moved, not copied, intorefuseUnexposedDataActioninpackages/runtime/src/action-execution.ts.callDataasks it first for every caller, unchanged, and the/datadomain asks it before its gates. The extracted body is the old block with the same throw and the same fail-open.callDataasks again.Before / after, both doors
Measured on PR #23571's rig (a real better-sqlite3 store, real
ObjectQL, the real protocol, andRestServer's registered handler besideHttpDispatcher.dispatch()with the catch-all's body parse), at744b73f53f(before) and on this branch (after). Each object's exposure is read from the engine registry's one definition on both doors.lockedsetsapiMethods: ['get'], andhiddensetsapiEnabled: false. The "after" column is what the pins below assert at this head.RestServerbeforePOST /data/taskbodynull[data], no row,createDatanever askedPOST /data/task/query{ "limit": "ten" }[query.limit][query.limit],findDatanever asked{ "joins": [] }(retired)[query.joins][query.joins]42[query][query]"garbage"/[{}][query]findDataasked)[query]null[query]{ "query": { "where": … } }{},{ "where": … },{ "limit": 1 },{ "object": "other" }PATCH/POSTonlocked, body array, string or number (andnullonPATCH)PATCH/POSTonhidden, the same bodiesPOSTnullonlocked/hiddennullread as{}passed the gate)locked/hiddenEnvelopes keep each door's own convention.
RestServeranswers{ error, code, fields, object }, and the dispatcher answers{ success: false, error: { code, message, details: { fields } } }. The pins compare status, code, field names, record counts and store state across doors, never envelope bytes. The exposure refusals carry different codes on the two doors (see Acceptance notes), so the location-3 pins compare status only.Pins
packages/runtime/src/domains/data-write-request-gate.test.ts(PR #23571's rig, extended; 26 tests to 73). It has one block per location, and each block has a per-door section and a cross-door parity section:nulljoins the create's refused bodies on both doors. Each row asserts 400 +VALIDATION_FAILED+fields: [data], thatcreateDatawas never asked, and that the store is unchanged. TheCREATE_REFUSEDfilter that heldnullout is gone. There is a new control:{}still creates a row (201).VALIDATION_FAILED+ the named member, and thatfindDatawas never asked. Two controls per door check the full set and a filtered set. A parity block covers the six bodies plus the bodyquerykey and a bodyobject. A further row asserts400 INVALID_FIELDfor the bodyquerykey on both doors.locked(405) andhidden(404), and for each ofPATCH,POSTcreate andPOSTquery, every body (four non-records and a record, or three queries) is answered the exposure status. The engine is never asked and the row is unchanged. A parity block checks that both doors give the same status for every body, which is the seat's pin.getMetaItemsforRestServerand the kernel'smetadataservice for the dispatcher. Without them each exposure gate fails open (measured:RestServeranswered201onhidden). The rig docblock says so.packages/rest/src/request-schema-gate.conformance.test.ts:nulljoins the violating bodies of both the create row and the query row.Flipped pins (repo-wide grep for a
nullcreate answering201and for the dispatcher query answering200to these bodies). Two hits, both flipped:data-write-request-gate.test.ts: theCREATE_REFUSEDexclusion ofnull(pinned as parity only) now refusesnullon both doors.data-path-object.test.ts: "still honours an explicitqueryenvelope" becomes "a bodyquerykey is the field it names, never a second spelling of the query". It asserts thatfindDatareceives{ query: { where: … } }as the query, and its comment names the gate-bypass reason and the real-store400 INVALID_FIELDpin.Ablations
Each ablation ran through
scripts/ablation-replace.mjsagainst the committed implementation, in WRAP mode. Each one verified the anchor count and the blob change on disk, and verified the restore againstHEAD(blob equal andgit diff HEADempty). These suites resolvedata.tsand@objectstack/restfrom source, sodist/is not involved. Runtime files:data-write-request-gate,http-dispatcher,data-path-objectandaction-execution-calldata-batch-retired.RestServercreate gate input back toreq.body ?? {}RestServernullcreate row and thenullcreate parity row) and 1 rest conformance (the create row)body ?? {}nullcreate row and the parity row)RestServerquery back toreq.body ?? {}RestServernullquery row and the parity row) and 1 rest conformance (the query row)PATCHexposure call deletedpatchonlocked/hiddenrows and their 2 parity rows)create)query){ ...body, object }querykey parity row, itsINVALID_FIELDrow, and the flippeddata-path-objectpin)action-execution-calldata-batch-retired, thehttp-dispatcherforward-shape pin, and the flippeddata-path-objectpin)Tests and gates
The runtime suites, typecheck and eslint ran at
7def2b301.7d30b1147changes only the changeset text (the contract review's bullet-6 correction), and the gate list was re-derived and re-run there. Exit codes were captured before any pipe.@objectstack/runtime,vitest run --project local: 383 files, 5,819 passed, 19 skipped, exit 0.--project repo: 3 files, 808 passed, exit 0.@objectstack/rest,vitest run --project local: 285 files, 5,858 passed, 326 skipped, exit 0.--project repo: 5 files, 213 passed, 2 skipped, exit 0. These ran atd10096da0;git diff d10096da0 7def2b301 -- packages/restis empty.data-write-request-gate.test.ts73,data-path-object.test.ts11,http-dispatcher.test.ts248 andaction-execution-calldata-batch-retired.test.ts8, all green. The first run of the full runtime suite caught the two pins this change moved, the engine-fallback query and thequeryenvelope, and the third commit handles them.pnpm --filter @objectstack/runtime typecheckexit 0 (the test layer compiles; the debt ledger is unchanged).pnpm --filter @objectstack/rest typecheckexit 0.--no-inline-config) overpackages/runtime/srcandpackages/rest/src: exit 0. Repo-widepnpm lintis left to CI.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 96 commands at7def2b301: 95 exit 0 and 1 exit 1 (check-empty-changeset --base origin/main, by design, see below);--ran: 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN. Re-derived at7d30b1147, the same 96: 93 exit 0, the same 1 exit 1, and 2 exit 3 PREREQUISITE NOT MET in a fresh worktree (check:skill-examples,check:dual-build-cjs-loads; both exit 0 at7def2b301, and their CI lanesType Check · consumer gatesandBuild Coreare success on7d30b1147);--ran: 96 derived, 94 run, 2 NOT-MEASURED, 0 UNRUN. Beside the derivation:pnpm check:durability-log-levelexit 0, andcheck:route-envelope,check:error-status-conformanceandcheck:dispatcher-error-vocabularyare in the derived list, each exit 0.A pending release note this change makes false: confirmation needed
.changeset/23491-patch-data-request-gate.mdis PR #23571's entry. It is still pending, so it ships in the same release. Its "Unchanged" list says a JSONnullbody on aPOSTcreate "both doors still read as an empty record and answer201". This PR makes that false, so the sentence is corrected in the same stroke: the clause is dropped, and one sentence points to this entry.check-empty-changesetrefuses any change to a changeset the PR did not add, and stays red for the deliberate-correction class by design: "say so on the PR … and get it confirmed". This is that statement. Restoring the base sentence would ship a false line in the same CHANGELOG version as this entry's FROM/TO.Changeset
.changeset/23574-data-request-contract-remainder.md:@objectstack/restand@objectstack/runtimeminor,fix(rest,runtime)!:, the BREAKING banner,Clause-②: no (narrowing), and the sameadr-0087: not-required (no-migration-prescription)disposition PR #23571's changeset carries. It has one FROM/TO line per narrowed answer. No new error code:VALIDATION_FAILEDalready answers these refusals on both doors.Landing vs the claimed file surface
The claim named
data.ts, therest-server.tscreate handler, their tests and one changeset. Beyond that:rest-server.ts, thePOST /data/:object/queryhandler: the H4?? {}only, which the dispatch allows onceRestServerowes a change. No other region ofrest-server.tsis touched.packages/runtime/src/action-execution.ts: the exposure gate is extracted fromcallData(moved, not copied), which is what location 3 needs.content/docs/permissions/system-context.mdx, row 48: the isSystem census anchors the extractedrefuseUnexposedDataAction, ascheck-system-context-censusrequires for the bypass read that moved..changeset/23491-patch-data-request-gate.md: the one-sentence correction above.packages/runtime/src/domains/data-path-object.test.ts: the flipped pin.Acceptance notes
RestServeranswersOBJECT_API_METHOD_NOT_ALLOWED/OBJECT_API_DISABLED. The dispatcher's addressed-object gate throws{ statusCode, message }with no code, which the host renders asMETHOD_NOT_ALLOWED/RESOURCE_NOT_FOUND, while the same file's relation-target refusal already carries theOBJECT_API_*codes. The status agrees on both doors and is pinned. The code split is reported to the seat as a finding and is not changed here.checkApiExposure's docblock (api-exposure.ts) says "The only caller iscallData". That was already untrue before this PR: the relation-target gate also calls it. It is left as it is because the file is outside this surface.objectkey as unservable, althoughobjectis a declared QueryAST member. It is unreachable from the/datadoor after this PR, which forwards the path object at the request level. Observed only, not measured on another caller.Generated by Claude Code