Repository navigation
fix(rest,runtime)!: a /data PATCH or DELETE refuses a version token that can never carry a version, on both doors - #23515
Conversation
…hat can never carry a version, on both doors Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
… never carry a version Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
…can never carry a version Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
…e envelope the protocol's own refusal gets Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 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 1da891f6e033ce849e708c8d0e7d49f115928e9d && git checkout 1da891f6e033ce849e708c8d0e7d49f115928e9d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d1e284113759db34b9dabcd18b9c9da22b32cde9 6d399e780b616c94abcb196c4efe423cd308c367 && git checkout -B drift-repro d1e284113759db34b9dabcd18b9c9da22b32cde9 && git merge --no-ff 6d399e780b616c94abcb196c4efe423cd308c367
node scripts/docs-audit/affected-docs.mjs --json d1e284113759db34b9dabcd18b9c9da22b32cde9 |
席位核对✅ ACCEPT: PR #23515 at
Checklist, read against GitHub and
Accepted deviations:
Open question, answered: should the protocol's Out-of-scope findings, one line each:
Landing: one Generated by Claude Code |
Contract reviewServed-tier: Inputs read:
I read diff context at the head through ① Derived judgmentsAccept set, on both doors. The two doors are
Public surface: none changed. Right.
Pins. For each refused shape, on each door, the tests assert:
There is also a parity pin against the protocol's Non-blocking nits:
② Semver levelThe changeset
The
③ Boundary flags
Deviations:
Out-of-scope findings:
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #23492
Clause-②: no (narrowing)
Both doors of
PATCH /data/:object/:idandDELETE /data/:object/:idnow judge every optimistic-concurrency token a caller sends, or refuse it400 VALIDATION_FAILEDbefore the protocol is asked. The two doors areRestServer(packages/rest) and the runtime dispatcher's/datadomain (packages/runtime), the only door on acreateHonoApphost. A token that can never carry a version is no longer read as "no token", so it can no longer hide a staleIf-Match. One rule lands in both doors together, and they stay the statement-for-statement mirrors that PR #23460 and PR #23490 made them.Dispatched by the
domain:cliseat 2 PM (claim6171438684, branchclaude/issue-23492-empty-version-token), sessionsession_01Q9PZ5Pzqcus4woVvTZwWNU.What was wrong (measured)
Both doors read
bodyVersion ?? If-MatchonPATCH(?expectedVersion ?? If-MatchonDELETE), and forwarded the token only when it was truthy. Measured on the rig these pins extend, onorigin/main3a2adc8835: a real better-sqlite3 store, the real ObjectQL and protocol,RestServer's registered handler and the realdispatch()side by side, with the row read back after every request. The two doors answered identically in every row.PATCH, bodyexpectedVersion: '', staleIf-Match200, row written400 VALIDATION_FAILED, row unchangedPATCH, body'', noIf-Match200, written400PATCH, bodyfalse, staleIf-Match200, written400PATCH, bodyfalse, noIf-Match200, written400PATCH, body' '(blank), staleIf-Match200, written (forwarded; the protocol reads blank as no token)400PATCH, body[], staleIf-Match200, written (see note 1)400PATCH, bodytrue409, judged as'true'400PATCH, body{}409, judged as'[object Object]'400PATCH, body0, with or without a staleIf-Match200, written (dropped)409, judged as'0'PATCH, emptyIf-Match, no body token200, written400DELETE,?expectedVersion=, staleIf-Match200, row deleted400, row keptDELETE,?expectedVersion=, noIf-Match200, deleted400DELETE,?expectedVersion=blank, staleIf-Match200, deleted400DELETE, emptyIf-Match, no query token200, deleted400null, staleIf-Match4094095;?expectedVersion=0409409""(body, query or header)400400Note 1: the
[]row's "before" was read with this PR's refusal ablated. That leaves the forward test!= null, which forwards[]exactly as the truthiness test did.String([])is'', which the protocol reads as no token.The rule
There is one function per door, identical in both:
versionTokenRefusal(token, spelling), inrest-server.tsand indomains/data.ts. It sorts a token three ways:undefinedornull. The body key or query name falls through toIf-Match, and noIf-Matcheither is the legal unguarded write. This is unchanged.String(token), and the forward test is now!= null, not truthiness.400 VALIDATION_FAILED: anything else: an empty or blank string, a boolean, an array, an object.The body key is still stripped from the patch whatever its value. PR #23490's pin (no
expectedVersionever reaches the engine as a field) still holds.Rulings applied, and the judgments this PR made
If-Match: ""silently DISABLES optimistic concurrency — a quoted-empty entity-tag is read as "no token" and the guarded write proceeds unguarded #13576's maintainer decision chore(ci)(deps): bump actions/configure-pages from 4 to 5 #20 ①, recorded atpackages/metadata-protocol/src/protocol.ts(MalformedVersionTokenError). A token shape that can never carry a version is refused400 VALIDATION_FAILED, neither read as "no token" nor answered409. This PR applies that rule to the body and query spellings. "Fall through toIf-Match" was not offered.If-Match(PM hypothesis (d)) is in scope, on both doors. Why:If-Matchis not the absent header that the ruling keeps as an unguarded write. The caller asked for a guard and sent nothing to judge, the same shape as bodyexpectedVersion: ''with no header, which the ruling refuses.If-Matchvalue at all, unlike"", which the platform already refuses.@objectstack/client'sdata.updateanddata.delete, and objectui'sApiDataSourceandMetadataClient, all omit the header for a falsyifMatch(grep:if (opts?.ifMatch)/opts.ifMatch ? … : …).true, arrays and objects are refused too.falserefused whiletrueis answered409would be two answers for one type.[]stringifies to''and wrote unguarded, the defect itself, one spelling over.If-Matchwrote unguarded. That is the same defect.0is judged as'0', not refused. It is a number, and every other number was already judged. This leaves the declaredz.string()unwidened and unenforced on numbers, which is not this card (see Acceptance notes).How each door renders the refusal
Both doors build the refusal with the one constructor,
validationFailure(sentence, [])from@objectstack/types, and render it with their own classifier. The two doors answer the same status,codeand sentence; a parity pin holds them to it.RestServerTHROWS it into the route's own catch.mapDataErrorrenders it as{ error, code: 'VALIDATION_FAILED', fields: [], object }, the envelope that door already gives the protocol's""refusal. A hand-builtres.status(400).json(...)body was the first draft.check:route-enveloperefused it (siblingCode: found 41, declared 39 — a NEW non-conforming body), so no body is hand-built here.deps.errorFromThrown(...). The body is{ success: false, error: { code: 'VALIDATION_FAILED', message, httpStatus: 400, details: { fields: [] } } }.createHonoApphost: measured through the realcreateHonoAppover the rebuilt runtimedist, with a recording protocol, in the same set-up aspackages/qa/http-conformance'shono-*.conformance.test.ts. That measurement was a scratch test, not committed.PATCHbody''with a staleIf-Match, bodyfalse, an emptyIf-Match,DELETE ?expectedVersion=with a staleIf-Match, an emptyIf-MatchonDELETE) answered400 VALIDATION_FAILEDon the wire, with the protocol never called.dispatch()still render as500there (the record is unchanged):409 CONCURRENT_UPDATE, and the400for""(measured: a thrown{ code: 'VALIDATION_FAILED', status: 400 }answered500 INTERNAL_ERROR). The catch-all reads only an error'sstatusCode. Rendering their declared status is a separate change; hono adapter:createHonoApp's catch-all renders an error thrown out ofdispatch()fromstatusCodealone, so a declaredstatus/code(a missing record's404 RECORD_NOT_FOUND, an outage's503) answers500 INTERNAL_ERROR#23365 remains open for it.Tests
Pins extend PR #23490's and PR #23460's rig:
packages/runtime/src/domains/data-update-expected-version.test.tsanddata-delete-expected-version.test.ts.PATCH:''andfalseeach beside a staleIf-Matchand beside no header, blank beside a staleIf-Match,true,[]beside a staleIf-Match, and an emptyIf-Matchalone. Each asserts400 VALIDATION_FAILED,updateDatanever asked, and the row unchanged.DELETE:?expectedVersion=beside a staleIf-Matchand beside no header, a blank?expectedVersion, and an emptyIf-Matchalone. Each asserts400,deleteDatanever asked, and the row kept.codeas the protocol's own refusal of"". That token is shown to reach the protocol.nullbody token is no token (a staleIf-Matchbeside it is409). Body0is judged as'0'(409), beside anIf-Matchnaming the current version.codeand sentence on both doors.Results:
pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/data-update-expected-version.test.ts src/domains/data-delete-expected-version.test.ts:Test Files 2 passed (2),Tests 84 passed (84), at6d399e780b.pnpm --filter @objectstack/rest run test:Test Files 283 passed (283),Tests 5852 passed | 326 skipped (6178), at6d399e780b.pnpm --filter @objectstack/runtime run test:Test Files 380 passed (380),Tests 5617 passed | 19 skipped (5636), atb763612ee8. The one commit after it changes only howRestServerrenders the refusal, and the two pin files above were re-run on it.typecheckfor@objectstack/restand@objectstack/runtimepassed at6d399e780b:tsc --noEmit, thencheck:test-typecheckOK on both.Ablations
Each ablation goes through
node scripts/ablation-replace.mjs, with a restore trap and a restore proven by blob equal to HEAD and an emptygit diff HEAD. Both doors' code under test resolves to source (@objectstack/restis aliased tosrcin runtime's vitest config, and the dispatcher is a relative import), so nodistleg applies.RestServer's refusal dropped (versionTokenRefusalreturnsundefinedfirst), at6d399e780bRestServer[#23492]refusal row is red (PATCH 9 of 11; the 2 green are thenulland0controls; DELETE 5 of 5), the cross-door rows are red (8 + 4 + 1[#23462]parity row), and every catch-all row stays green (11 + 5).b763612ee8(data.tsunchanged since)RestServerrow is green.RestServer's forward back to truthy (3 sites)RestServer's0control.0control.Gates
These were run locally on the final commit
6d399e780b:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 65 commands, and all 65 exited0.--ran(with exit codes) reports65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.0:check-changeset-fixed,check:error-code-casing,check:filter-alias-parityandcheck:route-ledger-census.pnpm lintwas run as a declared narrowing. The same binary and flags (eslint --no-inline-config) ran over the four changed.tsfiles and exited0. As a positive control, the same invocation on two of those paths, through--stdin-filenamewith an injected restricted import, exited1, so the paths are linted, not ignored. CI runs the whole tree.Acceptance notes
None of these is filed by this PR; each finding goes to the seat in the report.
UpdateDataRequestSchema.expectedVersionis declaredz.string(), yet both doors accept a body number and judgeString(n). Measured: body5is answered409on both doors, not refused as off-spec. Refusing numbers is not this card.assertVersionTokenNotMalformedstill reads''and blank as no token. So an in-process caller ofupdateDataordeleteData(not through either door) still writes unguarded with such a token. This is a read-only inference; no other public door was measured.expectedVersiondescribe names only""as refused. It does not say that an empty or blank token is refused at the doors. The text is not contradicted, but it is narrower than the behaviour.?expectedVersionas a query onPATCHis still read by neither door; the spec declares only the body field orIf-Matchthere. This is unchanged and has no carrier.createHonoApp500for protocol-thrown refusals is hono adapter:createHonoApp's catch-all renders an error thrown out ofdispatch()fromstatusCodealone, so a declaredstatus/code(a missing record's404 RECORD_NOT_FOUND, an outage's503) answers500 INTERNAL_ERROR#23365's.Generated by Claude Code