Repository navigation
fix(rest,runtime)!: both /data write doors validate the request as the caller sent it; a number expectedVersion is refused 400 - #23571
Conversation
…e caller sent it
The runtime dispatcher's PATCH /data/:object/:id and POST /data/:object had
no request-schema gate, so a body that is not a record reached the engine:
a PATCH answered 500 with the engine's own error, and a POST created one
row per array element (or an empty row for a number). RestServer's twin
routes refuse those 400 VALIDATION_FAILED; the dispatcher now asks the same
assembled request (UpdateDataRequestSchema / CreateDataRequestSchema) and
returns that refusal before the engine is asked.
RestServer's PATCH read a JSON null body as an empty patch (data ?? {}) and
answered 200, an update that changed no field and still moved the record's
version. The gate now parses the patch as sent, so null is refused 400 on
both PATCH doors. A null create stays as both doors answer it today.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
…sed 400 on both doors UpdateDataRequestSchema.expectedVersion declares a string. Both PATCH doors judged a body number as its String() and answered 409 CONCURRENT_UPDATE; RestServer's request gate only ever parsed the coerced copy. A number now joins the token shapes that can never carry a version: versionTokenRefusal (both copies, statement for statement) refuses it 400 VALIDATION_FAILED before the protocol is asked, and both doors gate and forward the token as the caller sent it. If-Match and the DELETE's ?expectedVersion are strings by transport and are unaffected. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
📓 Docs Drift Check3 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 ec6641277c4261d0aeb1be4a26fa1942a4a87f98 && git checkout ec6641277c4261d0aeb1be4a26fa1942a4a87f98
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 252775d1d307df0d039f297c6c1c1822b7af5b57 3dd76b2a817eb8496a04e25b0c92e3ad2bd3a3d9 && git checkout -B drift-repro 252775d1d307df0d039f297c6c1c1822b7af5b57 && git merge --no-ff 3dd76b2a817eb8496a04e25b0c92e3ad2bd3a3d9
node scripts/docs-audit/affected-docs.mjs --json 252775d1d307df0d039f297c6c1c1822b7af5b57 |
✅ ACCEPT: PR #23571 at
|
Contract reviewServed-tier: Inputs read: card #23491 (body, 4 comments), card #23517 (body, 4 comments), PR #23571 (body, 6-file list, net diff against ① Derived judgmentsEach accept-set or public-surface change the diff implies, with a verdict.
② Semver level
③ Boundary flagsEvery deviation, open question and out-of-scope finding in
Implemented-by: VERDICT: PASS Generated by Claude Code |
Contract review adopted:
|
Fixes #23491
Fixes #23517
Clause-②: no
One PR for a two-card family fold: both
PATCH /data/:object/:iddoors,RestServer's registered handler and the runtime dispatcher's/datadomain (the only answer on acreateHonoApphost), now checkUpdateDataRequestSchemaagainst the request as the caller sent it. The first commit is #23491 and the second is #23517.What changed
Commit 1, #23491: a request-schema gate on the dispatcher, and no coerced copy on
RestServerpackages/runtime/src/domains/data.ts,PATCHbranch: the assembled request{ object, id, data, expectedVersion? }is parsed withUpdateDataRequestSchemaafter the version-token check and beforecallData. The token is stripped fromdata, and nothing is defaulted. A failure returns400 VALIDATION_FAILEDwitherror.details.fieldsthroughdeps.errorFromThrown(validationFailure(…)). It is returned rather than thrown, as the sibling version refusal is.packages/runtime/src/domains/data.ts,POST /data/:objectcreate branch: the same gate withCreateDataRequestSchema. H4 measured the same gap there, and a worse one, so it is folded in (see the table).packages/rest/src/rest-server.ts,PATCHhandler:data ?? {}is gone from both the gate input and the forward. The gate now parses the patch as sent.Commit 2, #23517: a number token is refused, and both doors gate and forward the raw token
versionTokenRefusal, both copies, still statement for statement (diffof the two function bodies is empty): thetypeof token === 'number' && Number.isFinite(token)arm is deleted, so a number joins the refused shapes. The prescription now says "send the version you read as a string". Both docblocks name the change.PATCHdoors put the raw token into the gate input and the forward ({ expectedVersion }, no longerString(expectedVersion)). With the arm gone, anything that reaches the gate is already a non-blank string, so the gate and the forward see the same value the caller sent.DELETEis untouched. Its?expectedVersionand everyIf-Matchare strings by transport.data-delete-expected-version.test.ts(34 tests) runs green on both doors, unchanged.Landing is exactly the claimed file surface: the two branches of
data.ts, thePATCHhandler and theversionTokenRefusalofrest-server.ts, their tests, and one changeset. No other region ofrest-server.tswas touched.The
nullanswer:400on bothPATCHdoorsMeasured first, on the rig PR #23515 built (real better-sqlite3 store, real ObjectQL, real protocol;
RestServer's registered handler andHttpDispatcher.dispatch()side by side), at086fa4640f:RestServeranswered a JSONnullbody200, calledupdateDatawith{}, and moved the record'supdated_at(…26.716Zto…26.723Z) with no field changed. Every other holder of the old version would then lose its next guarded save to a request that wrote nothing.500 INTERNAL_ERROR("Cannot read properties of null (reading 'id')").I picked
400 VALIDATION_FAILEDfor both. The declared type is a record, anddata ?? {}was the consumer-side leniency Prime Directive 12 forbids. This narrowsRestServer's accept set (200to400). It staysClause-②: no: no key is added and no code is new. The changeset carries the narrowing in its FROM/TO list.Before / after, both doors (measured,
086fa4640fvs this head)RestServerbeforePATCHbody[{…}]fields: [data]PATCHbody"garbage"fields: [data]PATCHbody42fields: [data]PATCHbodynullfields: [data], version unchangedPATCHexpectedVersion: 5/0, stale or currentIf-Match"5"/"0")updateDatanever askedPOSTbody[{…}]POSTbody"garbage"POSTbody42POSTbodynullEnvelopes keep each door's own convention (H5).
RestServer:{ error: 'Invalid update request', code, fields, object }, and for a token refusal themapDataErrorenvelope it already gave"". Dispatcher:{ success: false, error: { code, message, details: { fields } } }. The pins compare status, code, field names and store state across doors. They never compare envelope bytes.POSTcreate withnull: left as both doors answer it todayRestServer's create assembles{ object, data: req.body ?? {} }. That region is outside this PR's surface, and the seat's same-file serial rule holds it. So the dispatcher's new create gate asks the same assembled request, statement for statement,?? {}included. The two doors answer anullcreate alike (201, an empty row), and the parity row pins that agreement without pinning the201itself. This is the one??default the diff adds, on purpose. The alternative was a dispatcher-only400, which would split one route into two answers. That leniency is retired on both doors at once or not at all. It is raised as an open question in the report.Pins
packages/runtime/src/domains/data-write-request-gate.test.ts.PATCHandPOSTthroughdispatch()besideRestServer's registered handler. It covers an array, a string, a number andnull. Each row asserts400andVALIDATION_FAILED,fieldsnamingdata, the protocol method never called, and the store unchanged. ForPATCH, the store check includesupdated_at, so the flippednullrow asserts that the version did not move. A cross-door parity block and a record-body control for each verb are included. 26 tests.data-update-expected-version.test.ts: a[#23517]block.expectedVersion: 5and0, each beside a stale and a currentIf-Match, on both doors:400 VALIDATION_FAILED,updateDatanever asked, the row unchanged. A parity block checks status, code and sentence. Controls: the same token as the string'5'is still judged (409), and the currentIf-Matchalone still updates.packages/rest/src/request-schema-gate.conformance.test.ts:nulljoins thePATCHrow's violating bodies.Flipped pins (repo-wide grep for a numeric
expectedVersionanswering409and for anullPATCHanswering200: two hits, both indata-update-expected-version.test.ts, and nonull-200 pin anywhere):control: a body token of 0 … judged as "0" (refused 409)now asserts400 VALIDATION_FAILED,updateDatanever asked, and the row unchanged beside the currentIf-Match. That keeps its original point:0is never dropped as "no token".EDGE_SHAPESa number token(20000101) is removed from the judged-shape parity list. Numbers are pinned in the new[#23517]block.Tests and gates (on
3dd76b2a8)All local runs below are on
3dd76b2a8(the final commit). Exit codes were captured before any pipe.@objectstack/restsuite,vitest run --project local: 284 files, 5,856 passed, 326 skipped, exit 0.@objectstack/runtimesuite,vitest run --project local: 382 files, 5,673 passed, 19 skipped, exit 0.data-write-request-gate.test.ts26/26,data-update-expected-version.test.ts65/65,data-delete-expected-version.test.ts34/34;request-schema-gate.conformance.test.ts16/16.pnpm --filter @objectstack/rest typecheckexit 0, andpnpm --filter @objectstack/runtime typecheckexit 0 (the test layer compiles; the runtime debt ledger is unchanged).--no-inline-config) overpackages/rest/srcandpackages/runtime/src: exit 0, no findings. Repo-widepnpm lintis left to CI.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 65 gate lines at this head. 64 exit 0.pnpm check:dual-build-cjs-loadsexit 3PREREQUISITE NOT MET(it needs every package'sdist/): NOT MEASURED locally, CI's check-run is the reading.--ranreconciliation: 65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN.Ablations. One per behaviour. Each ran through
scripts/ablation-replace.mjsagainst the committed fix, with the anchor proven to hit and a restore proven againstHEAD. These suites resolvedata.tsand@objectstack/restfrom source, so nodist/step was involved.PATCHgate line deletedPATCHrows and the 4PATCHparity rowsRestServergate input back todata ?? {}RestServernullrow and thenullparity row (2/26), and the conformancePATCHrow (1/16)versionTokenRefusalnumber arm restored, alone[#23517]parity rows (sentence differs). The raw-token request gate still refused the number400on the dispatcher, so the per-door rows stayed green. That is the second guard holding, and it is reported as observed.String(expectedVersion)[#23517]rows, the 4 parity rows, and the flipped0controlPOSTrows and the 3POSTparity rowsNot re-run: this branch is three commits behind
origin/main(252775d1d). Those commits touchpackages/cli,packages/metadata-protocolandpackages/spectests, nothing underpackages/restorpackages/runtime, andgit merge-treeis clean. CI runs the merge ref.Acceptance notes
POST /data/:object/queryhas noFindDataRequestSchemagate either. Measured on the same rig at this head:{ limit: 'ten' }and the retired{ joins: [] }answer200with the full set on the dispatcher, whileRestServerrefuses both400 VALIDATION_FAILED(query.limit, and thejoinstombstone). It is reported to the seat as a finding and is not fixed here.updated_at: the protocol'scanonicalVersionInstantreads anumbercolumn as an instant, so I checked whether a number token could ever have matched. On better-sqlite3 the column reads back as ISO text, andexpectedVersion: 1700000000000answered409before this change (now400). The string spelling'1700000000000'still answers409, as before. Other drivers were not measured.os-dev-reporton runtime: the dispatcher'sPATCH /data/:object/:idhas no request-schema gate, so a non-record body answers500with the engine's own error, where REST answers400 VALIDATION_FAILED#23491 marked wrong. It was written by thedomain:cliseat 1 PM, sessionsession_01FLwgbTSmGMrqsXkBDsiwfG, as a read-modify-write of the live body. The dev does not PATCH a PR body.)Generated by Claude Code