Repository navigation
fix(runtime)!: the dispatcher's DELETE /data/:object/:id honours ?expectedVersion and If-Match, as RestServer's twin does - #23460
Conversation
…ectedVersion and If-Match The /data domain's DELETE branch reads ?expectedVersion, else If-Match, behind the multiplicity rule RestServer's twin applies, and callData's delete arm forwards the token to deleteData, so a stale delete through the dispatcher is refused instead of applied. A host whose protocol has no deleteData refuses a token it cannot judge with 501. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
…he version token (runtime minor, breaking) Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
…sion pins The two `engine.findOne` probes carried an `as any` the query-options-erasure ratchet counts; the options type as written. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 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 4c1b4e05f27c055fb8fd56aa5b9f12e1c088948f && git checkout 4c1b4e05f27c055fb8fd56aa5b9f12e1c088948f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4e0838d997d63a7662c839e6f1f914b22de475b2 85123b4628fe67764280238075ca771dd073cf9c && git checkout -B drift-repro 4e0838d997d63a7662c839e6f1f914b22de475b2 && git merge --no-ff 85123b4628fe67764280238075ca771dd073cf9c
node scripts/docs-audit/affected-docs.mjs --json 4e0838d997d63a7662c839e6f1f914b22de475b2
|
…te refusal today The stale-delete bullet promised the protocol's 409 wherever the dispatcher serves the route; the createHonoApp catch-all reads only an error's statusCode, so that host renders the refusal as 500 with the record kept. The bullet now says so. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
…ctedVersion The repeat bullet's 400 is what the dispatcher answers to a repeat it is handed. An adapter that hands it only the last value of a repeated query name (the createHonoApp catch-all, until it keeps repeats) has that last value judged as the token. The bullet now says so, in a form true in either landing order. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #23265 (body and all seven comments: the triage grade, the release note, the regrade, both claims and both os-dev-reports), PR #23460 (body, its four-file list, the net diff against its merge-base with main, ① Derived judgmentsEvery accept-set and public-surface change the diff implies, named and judged.
Derived gate families, as the head's check-runs answer them (read at the stamp above): 40 check-runs. Every lint, type-check, changeset, documentation, queue-guard, claim-guard, build and dogfood run is ② Semver level
③ Boundary flagsRound 0 (
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Regen-provenance: 6167020590 ·
Generated by Claude Code |
Fixes #23265
Clause-②: no (narrowing)
The runtime dispatcher's
DELETE /data/:object/:idnow reads the optimistic-concurrency token the wayRestServer's twin reads it and forwards it todeleteData. A stale delete through the dispatcher is refused instead of applied.Dispatched by the
domain:cliseat 2 PM (claim6164842979, branchclaude/issue-23265-dispatcher-delete-version), sessionsession_01Q9PZ5Pzqcus4woVvTZwWNU.What was wrong
RestServerreads?expectedVersion, elseIf-Match, behindrefuseRepeatedQueryParams(req, res, ['expectedVersion']), and hands the token todeleteData. The protocol then refuses a delete whose record has moved (409 CONCURRENT_UPDATE). The dispatcher dropped the token twice:domains/data.ts, theDELETEbranch, read neither the query name nor the header.action-execution.ts,callData's delete arm, forwarded only{ object, id }todeleteData. So even a domain that read the token could not have passed it on.Through the
createHonoAppcatch-all, a stale delete deleted the row and answered200, the same answer as a delete with no token.What changes
domains/data.ts, theDELETEbranch.?expectedVersion, elseIf-Match, in REST's order.?expectedVersionfirst, before the service is asked, with400 VALIDATION_ERRORandrepeatedQueryParamMessage('expectedVersion', n). That is the idiomi18n.ts,security.tsandpackages.tsalready use.String(token), which is REST's own test.requestIfMatch, which covers both shapes a host handsdispatch(): the FetchHeadersthat the catch-all passes asc.req.raw, and a Node header record. This mirrorsmetaSaveRequestOptions's module-privaterequestHeader.action-execution.ts,callData's delete arm.expectedVersiontodeleteData, only when a caller sent one.deleteData, cannot judge a version. A delete carrying a token is refused there with501before anything is read or deleted. This is the shape finding(runtime): through the @objectstack/hono catch-all, PUT /meta/:type/:name ignores If-Match, If-None-Match and ?mode=draft — a stale token writes (200, not 409) and a draft save lands ACTIVE #22141 gave the/metasave's fallback writer..changeset/23265-dispatcher-delete-expected-version.md.@objectstack/runtimeminor,fix(runtime)!:, with FROM → TO,Clause-②: no (narrowing), one ADR-0087 marker (not-required (no-migration-prescription)) and the launch-window BREAKING line.No
@objectstack/restexport is added, nopackages/specchange, no other domain or branch.⚠ File surface: one file beyond the claim
The claim names the
DELETEbranch ofdata.ts. The fix also editspackages/runtime/src/action-execution.ts(callData's delete arm, +25/-1). The card's acceptance is "forwards the version todeleteData", andcallDatais the only path from this branch todeleteData. Without this edit the acceptance cannot be met: ablation B below shows the stale pin red withdata.tsfixed and only thecallDataforward removed. It was taken under the role file's bounded in-place-fix rule:501fallback refusal.Every other
callData('delete', …)caller (domains/mcp.ts,endpoint-executor.ts) passes{ object, id }, so itsdeleteDatacall is unchanged.Pins (
packages/runtime/src/domains/data-delete-expected-version.test.ts)The rig. Each door gets a real store: better-sqlite3
:memory:, the realObjectQLand a realObjectStackProtocolImplementation. The same cases run againstRestServer's registeredDELETEhandler and against the realHttpDispatcher.dispatch(). The dispatcher is fed what thecreateHonoAppcatch-all feeds it, including a real FetchRequest. After every request the test reads the store, not the door's answer.Cases on each door:
deleteDatagets no token;?expectedVersiondeletes;?expectedVersionis refused409 CONCURRENT_UPDATEand the row is kept;If-Matchbehaves like?expectedVersion;?expectedVersionis judged beforeIf-Match;?expectedVersionis refused400 VALIDATION_ERRORwith the multiplicity sentence, beforedeleteData;Side by side:
If-Matchthe way the FetchHeadersdoes.Fallback:
?expectedVersionorIf-Matchon a protocol with nodeleteDatagives501 NOT_IMPLEMENTED, and the engine is neither read nor written;How the conflict is read: the stale refusal is not returned by
dispatch()./datahas no local catch, so it throws the protocol'sConcurrentUpdateError. The pin reads it throughresolveThrownHttpError(@objectstack/types), the one rule the dispatcher's exits answer a throw with, and sets it beside whatRestServeranswered throughmapDataError.Before / after.
823da2f21c, with this test file):Tests 11 failed | 9 passed (20). EveryRestServercase was green. Every catch-all case except the control was red: the stale delete answered200and was applied, the repeat was ignored, anddeleteDatareceived no token.6059514619):Tests 20 passed (20).Ablations. Each ran through
node scripts/ablation-replace.mjsin WRAP mode, with a restore trap. Each anchor hit once andmutation landedwas printed. Each restore was proven by the blob hash equalling HEAD's and by an emptygit diff HEAD. The expected direction was red each time, and each went red.data.tsforward deleted:9 failed | 11 passed. These went red: matching, stale,If-Match, precedence and one-element on the catch-all; the side-by-side conflict; the header-record pin; and both501pins, since no token reachescallDatawithout the forward. EveryRestServercase, the control and both repeat refusals stayed green, because the repeat refusal sits ahead of the forward.callDataforward deleted:7 failed | 13 passed. These went red: matching, stale,If-Match, precedence and one-element on the catch-all; the side-by-side conflict; and the header-record pin. The501pins stayed green, because the fallback refusal is its own statement.if (false)):2 failed | 18 passed. Both501pins went red.Mechanism readings (the dispatch's hypotheses)
readSingleQueryValueexists inquery-multiplicity.tsbut is not on the@objectstack/restentry, sorepeatedQueryParamMessageis used and no export is added.d45d0e938ausedrepeatedQueryParamMessageini18n.ts,security.tsandpackages.ts. Indata.tsit usedassertFilterParamSuppliedOnce, the filter-slot rule./metasave reads_context.request?.headersin both shapes. Its reader is module-private inmeta-save-request.ts, sodata.tscarries a ten-line mirror rather than a new export.dispatch(): a stale delete throws the protocol'sConcurrentUpdateError(status 409,code CONCURRENT_UPDATE).resolveThrownHttpErrorreads409 / CONCURRENT_UPDATE, which is REST's status and code.createHonoApp(scratch run: adapter source over the runtimedistbuilt atcaeac16970, not committed): a stale delete through?expectedVersionor throughIf-Matchanswers500 INTERNAL_ERRORwith the conflict sentence, and the row is kept. The current version answers200and the row is gone.statusCodealone. That is outside this branch, and 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. It is not edited here.?expectedVersionhanded todispatch()as an array gives400 VALIDATION_ERRORand REST's sentence, identical toRestServer's answer.Gates (final head
6f793007c5)This head merges
origin/mainat05ab9ef895. The incoming commits touchedpackages/spec(the ui page and action schemas), onemetadata-protocoltest and onelinttest, and nothing on this path. After the merge:the runtime closure was rebuilt (
turbo run build --filter="@objectstack/runtime...", 31 tasks);pnpm --filter @objectstack/spec check:generatedreported all 14 artifacts up to date.pnpm --filter @objectstack/runtime run typecheck:TC_EXIT=0. That coverstsc --noEmitandcheck:test-typecheck: 27 files, 190 errors, 68 pinned signatures held. The new test is compiled bytsconfig.test.json's program (--listFilesOnly: 1 hit), and the maintsconfig.jsonexcludes it (0 hits).vitest run --project local --maxWorkers=2:Test Files 374 passed (374),Tests 5475 passed | 19 skipped (5494).vitest run --project repo --maxWorkers=2:Test Files 3 passed (3),Tests 808 passed (808).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 65 commands, each run with its exit code captured before any pipe. All 65 exited 0.--ran:65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint(this lane's addition):LINT_EXIT=0.Earlier head
caeac16970: the union went red oncheck:query-options-erasure. The test surface grew 236 to 238, from twoas anycasts on the new test's store probes.3813914f60typed them and the ratchet held at 236. At the same head,check:dual-build-cjs-loadsansweredPREREQUISITE NOT MET; at6f793007c5it ran and passed.Since the merge,
origin/mainhas moved again, at least 3 commits (to2d2e3ce431). One of them touchesmetadata-protocol/src/protocol.ts(an object-level sharing key retired), outsidedeleteData. CI's merge ref measures that joint state.Acceptance notes
PATCH /data/:object/:idhas the same drop, and it is not fixed here. The dispatch rules out other branches. Measured on the realcreateHonoAppin the same scratch run:PATCHwith a staleIf-Matchanswered200and wrote the row;expectedVersionanswered500withUnknown field 'expectedVersion' on object 'task';RestServerjudges both as the OCC token, refusing a stale one409, and strips it from the patch.RestServer's 409 body carriescurrentVersion/currentRecord. The dispatcher's thrown-error exits (errorFromThrown,dispatcher-plugin'serrorResponseBase, both viaresolveThrownHttpError) carry neither. OncreateHonoAppthe question is moot until 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 lands. Carrier: 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.Generated by Claude Code