Skip to content

fix(runtime)!: the dispatcher's DELETE /data/:object/:id honours ?expectedVersion and If-Match, as RestServer's twin does - #23460

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23265-dispatcher-delete-version
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23265-dispatcher-delete-version

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23265
Clause-②: no (narrowing)

The runtime dispatcher's DELETE /data/:object/:id now reads the optimistic-concurrency token the way RestServer's twin reads it and forwards it to deleteData. A stale delete through the dispatcher is refused instead of applied.

Dispatched by the domain:cli seat 2 PM (claim 6164842979, branch claude/issue-23265-dispatcher-delete-version), session session_01Q9PZ5Pzqcus4woVvTZwWNU.

What was wrong

RestServer reads ?expectedVersion, else If-Match, behind refuseRepeatedQueryParams(req, res, ['expectedVersion']), and hands the token to deleteData. The protocol then refuses a delete whose record has moved (409 CONCURRENT_UPDATE). The dispatcher dropped the token twice:

  1. domains/data.ts, the DELETE branch, read neither the query name nor the header.
  2. action-execution.ts, callData's delete arm, forwarded only { object, id } to deleteData. So even a domain that read the token could not have passed it on.

Through the createHonoApp catch-all, a stale delete deleted the row and answered 200, the same answer as a delete with no token.

What changes

  • domains/data.ts, the DELETE branch.
    • Reads ?expectedVersion, else If-Match, in REST's order.
    • Refuses a repeated ?expectedVersion first, before the service is asked, with 400 VALIDATION_ERROR and repeatedQueryParamMessage('expectedVersion', n). That is the idiom i18n.ts, security.ts and packages.ts already use.
    • Unwraps a one-element array. An empty array counts as no token.
    • Forwards a non-empty token as String(token), which is REST's own test.
    • Reads the header through a local requestIfMatch, which covers both shapes a host hands dispatch(): the Fetch Headers that the catch-all passes as c.req.raw, and a Node header record. This mirrors metaSaveRequestOptions's module-private requestHeader.
  • action-execution.ts, callData's delete arm.
  • .changeset/23265-dispatcher-delete-expected-version.md. @objectstack/runtime minor, 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/rest export is added, no packages/spec change, no other domain or branch.

⚠ File surface: one file beyond the claim

The claim names the DELETE branch of data.ts. The fix also edits packages/runtime/src/action-execution.ts (callData's delete arm, +25/-1). The card's acceptance is "forwards the version to deleteData", and callData is the only path from this branch to deleteData. Without this edit the acceptance cannot be met: ablation B below shows the stale pin red with data.ts fixed and only the callData forward removed. It was taken under the role file's bounded in-place-fix rule:

  1. Same defect. It is the second drop of this card's token.
  2. Mechanical. A conditional spread in REST's own shape, plus the 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 501 fallback refusal.
  3. No other holder. None of the 15 open PRs' file lists names the file (read 2026-10-14 at about 09:20Z).
  4. Same package and gate family.

Every other callData('delete', …) caller (domains/mcp.ts, endpoint-executor.ts) passes { object, id }, so its deleteData call 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 real ObjectQL and a real ObjectStackProtocolImplementation. The same cases run against RestServer's registered DELETE handler and against the real HttpDispatcher.dispatch(). The dispatcher is fed what the createHonoApp catch-all feeds it, including a real Fetch Request. After every request the test reads the store, not the door's answer.

Cases on each door:

  • no token deletes, and deleteData gets no token;
  • the current ?expectedVersion deletes;
  • a stale ?expectedVersion is refused 409 CONCURRENT_UPDATE and the row is kept;
  • If-Match behaves like ?expectedVersion;
  • ?expectedVersion is judged before If-Match;
  • a repeated ?expectedVersion is refused 400 VALIDATION_ERROR with the multiplicity sentence, before deleteData;
  • a one-element array is the one token it encodes.

Side by side:

  • the stale refusal: same status and code at both doors;
  • the repeat refusal: same status, code and sentence;
  • a Node-style plain header record carries If-Match the way the Fetch Headers does.

Fallback:

  • ?expectedVersion or If-Match on a protocol with no deleteData gives 501 NOT_IMPLEMENTED, and the engine is neither read nor written;
  • the control: an unguarded delete still reaches the fallback.

How the conflict is read: the stale refusal is not returned by dispatch(). /data has no local catch, so it throws the protocol's ConcurrentUpdateError. The pin reads it through resolveThrownHttpError (@objectstack/types), the one rule the dispatcher's exits answer a throw with, and sets it beside what RestServer answered through mapDataError.

Before / after.

  • Before (unfixed source at 823da2f21c, with this test file): Tests 11 failed | 9 passed (20). Every RestServer case was green. Every catch-all case except the control was red: the stale delete answered 200 and was applied, the repeat was ignored, and deleteData received no token.
  • After (6059514619): Tests 20 passed (20).

Ablations. Each ran through node scripts/ablation-replace.mjs in WRAP mode, with a restore trap. Each anchor hit once and mutation landed was printed. Each restore was proven by the blob hash equalling HEAD's and by an empty git diff HEAD. The expected direction was red each time, and each went red.

  • A, data.ts forward 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 both 501 pins, since no token reaches callData without the forward. Every RestServer case, the control and both repeat refusals stayed green, because the repeat refusal sits ahead of the forward.
  • B, callData forward 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. The 501 pins stayed green, because the fallback refusal is its own statement.
  • C, fallback refusal disabled (if (false)): 2 failed | 18 passed. Both 501 pins went red.

Mechanism readings (the dispatch's hypotheses)

  • (a) Confirmed: no exported reading. REST spells it inline. readSingleQueryValue exists in query-multiplicity.ts but is not on the @objectstack/rest entry, so repeatedQueryParamMessage is used and no export is added.
    • One correction: d45d0e938a used repeatedQueryParamMessage in i18n.ts, security.ts and packages.ts. In data.ts it used assertFilterParamSuppliedOnce, the filter-slot rule.
  • (b) Confirmed. The /meta save reads _context.request?.headers in both shapes. Its reader is module-private in meta-save-request.ts, so data.ts carries a ten-line mirror rather than a new export.
  • (c) Measured.
  • (d) Pinned: a repeated ?expectedVersion handed to dispatch() as an array gives 400 VALIDATION_ERROR and REST's sentence, identical to RestServer's answer.

Gates (final head 6f793007c5)

This head merges origin/main at 05ab9ef895. The incoming commits touched packages/spec (the ui page and action schemas), one metadata-protocol test and one lint test, 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:generated reported all 14 artifacts up to date.

  • pnpm --filter @objectstack/runtime run typecheck: TC_EXIT=0. That covers tsc --noEmit and check:test-typecheck: 27 files, 190 errors, 68 pinned signatures held. The new test is compiled by tsconfig.test.json's program (--listFilesOnly: 1 hit), and the main tsconfig.json excludes 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 on check:query-options-erasure. The test surface grew 236 to 238, from two as any casts on the new test's store probes. 3813914f60 typed them and the ratchet held at 236. At the same head, check:dual-build-cjs-loads answered PREREQUISITE NOT MET; at 6f793007c5 it ran and passed.

Since the merge, origin/main has moved again, at least 3 commits (to 2d2e3ce431). One of them touches metadata-protocol/src/protocol.ts (an object-level sharing key retired), outside deleteData. CI's merge ref measures that joint state.

Acceptance notes


Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 5 documentable anchor(s).

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))
  • content/docs/api/declarative-endpoints.mdx (via callData (symbol, a top-level function))
  • content/docs/api/error-catalog.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest), expectedVersion (literal, a string literal in handleDataRequest))
  • content/docs/api/error-handling-client.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))
  • content/docs/api/error-handling-server.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))
  • content/docs/api/wire-format.mdx (via expectedVersion (literal, a string literal in handleDataRequest))
  • content/docs/automation/jobs.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))
  • content/docs/automation/webhooks.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))
  • content/docs/data-modeling/drivers.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))
  • content/docs/permissions/authorization.mdx (via callData (symbol, a top-level function))
  • content/docs/permissions/system-context.mdx (via callData (symbol, a top-level function))
  • content/docs/protocol/kernel/error-handling.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))
  • content/docs/protocol/kernel/http-protocol.mdx (via callData (symbol, a top-level function))
  • content/docs/protocol/objectql/types.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))
  • content/docs/ui/forms.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))
  • content/docs/releases/v17/17-5.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))
  • content/docs/releases/v17/17-6.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))
  • content/docs/releases/v17/17-7.mdx (via VALIDATION_ERROR (literal, a string literal in handleDataRequest))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4e0838d997d63a7662c839e6f1f914b22de475b2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c1b4e05f27c055fb8fd56aa5b9f12e1c088948f — the merge of head 85123b4628fe67764280238075ca771dd073cf9c into base 4e0838d997d63a7662c839e6f1f914b22de475b2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4e0838d997d63a7662c839e6f1f914b22de475b2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cf3a77af611bfd714028d9890a5622242e655542
Local-runs: none

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, merge-base 05ab9ef895), and the check-runs on the head. Source context at the head was read with git show only: nothing was checked out, built, run or re-run. Stamp: 2026-10-14T11:11Z.

① Derived judgments

Every accept-set and public-surface change the diff implies, named and judged.

  1. A stale ?expectedVersion on the dispatcher's DELETE /data/:object/:id: was applied with 200, now refused. domains/data.ts forwards the token through callData to protocol.deleteData, whose assertVersionMatch throws the protocol's ConcurrentUpdateError; dispatch() rethrows everything but a permission denial, and resolveThrownHttpError reads it as 409 / CONCURRENT_UPDATE, which is what RestServer's twin answers through mapDataError. The record is kept. Right: the card's acceptance, and the narrowing the grade names.

  2. A stale If-Match: the same. REST reads req.headers['if-match'] ?? req.headers['If-Match']; the dispatcher's module-private requestIfMatch reads the same two record spellings and Headers.get('if-match') on the Fetch Request the createHonoApp catch-all passes as context.request. The protocol's normaliseVersionToken strips RFC-7232 quotes for both doors, so both hand deleteData the same token. Right.

  3. Precedence and emptiness. queryVersion ?? ifMatch, then forward only when truthy as String(token): the twin's own two lines in rest-server.ts's DELETE handler. An empty ?expectedVersion= is therefore "present, then dropped" on both doors alike, a parity and not a divergence. Right.

  4. A repeated ?expectedVersion: was flattened or ignored, now 400 VALIDATION_ERROR with repeatedQueryParamMessage('expectedVersion', n), before callData. REST's refuseRepeatedQueryParams answers the same status, nested code and sentence; a one-element array is unwrapped and an empty one reads as not supplied, exactly as readSingleQueryValue reads them. repeatedQueryParamMessage was already on the @objectstack/rest entry (the export i18n.ts, security.ts and packages.ts import), so the claim's "no new @objectstack/rest export" holds. Right.

  5. callData's delete arm forwards expectedVersion to protocol.deleteData when a caller sent one. DeleteDataRequest (packages/spec/src/api/protocol.zod.ts) already declares the field and deleteData reads it, so nothing in spec moves. callData is not on @objectstack/runtime's entry (src/index.ts re-exports HttpDispatcher, not action-execution), so no published declaration changes. Reach: every other callData('delete', …) caller spells { object, id } literally (domains/mcp.ts; endpoint-executor.ts's executeObjectOperation); the generic bridges in dispatcher-plugin.ts and domains/actions.ts feed only that executor and executeDeclarativeUpdateAction, which issues update and get; the sandbox body-runner.ts and the action context's delete call ql.delete directly. So the forward is reachable from the /data DELETE branch alone, the PR body's "every other caller passes { object, id }" is true, and the file-surface extension is the only way to meet "forwards the version to deleteData". Right.

  6. The ObjectQL fallback (a host whose protocol has no deleteData): a delete carrying a token was applied unguarded, now 501 before any read. Thrown as { statusCode: 501, message }, the shape the same file's query fallback already throws; resolveThrownHttpError maps it to NOT_IMPLEMENTED (HttpStatusErrorCodeMap[501], an ADR-0112 catalog code, nothing new). Not among the card's bullets, but the card's governing text (a precondition is honoured or refused, never ignored) reaches this second deleter too, and the /meta save's fallback refuses its preconditions 501 the same way (domains/meta.ts). A dispatcher-only narrowing, declared in the changeset. Right. On a createHonoApp host this one renders as 501, because the catch-all reads statusCode; the 409 of item 1 renders as 500 there, which the changeset says.

  7. Runtime strings. The new 501 sentence carries no tracker number; the 400 sentence is REST's own. Right under the runtime-strings rule.

  8. Public surface. No packages/spec change, no new export anywhere, requestIfMatch module-private, no governed path in the file list. Nothing widens. Right.

  9. Known limits, correctly left alone. adapters/hono/src/index.ts still flattens a repeated query name to its last value (searchParams.forEach), so on that host the last ?expectedVersion is judged as the token and item 4 is reachable only by a dispatch() caller that passes an array; and that host's catch-all renders item 1 as 500 (hono adapter: createHonoApp's catch-all renders an error thrown out of dispatch() from statusCode alone, so a declared status/code (a missing record's 404 RECORD_NOT_FOUND, an outage's 503) answers 500 INTERNAL_ERROR #23365's carrier). Both are outside this card's surface. The PATCH twin's identical drop is reported, not fixed (the seat filed runtime: the dispatcher's PATCH /data/:object/:id drops If-Match (a stale update is applied) and refuses a body expectedVersion as an unknown field, where REST refuses the stale update 409 #23462). Right.

  10. The pins (data-delete-expected-version.test.ts, read and not run): the card's four pins plus the repeat refusal and the one-element unwrap, on both doors over a real better-sqlite3 store (five sibling runtime tests already use that client); the stale refusal and the repeat refusal side by side; a Node header record; and the fallback's 501 with a control. Every import is a runtime dependencies entry; no cross-package file read. The PR body's before/after (11 of 20 red on the unfixed source, 20 green fixed) and the three ablations (A, B and C, each red in the expected direction) are the test evidence this review read.

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 success or skipped; none is failure. The six Test Core (n/6) shards are cancelled: they started on this head and were cancelled about five minutes later, in the minute the branch received a later changeset-only commit (commit 85123b46) that superseded the run; the aggregate Test Core job then reported success without those shards. So the test family has no measured verdict on this head, while every gate derived from the file list (ADR-0087 registration, changeset-no-major, empty-changeset, query-options erasure, the type-check ledgers) is green.

② Semver level

  • The diff publishes one package, @objectstack/runtime (source in action-execution.ts and domains/data.ts; the test file and the changeset publish nothing). The changeset bumps exactly that package. Matches.
  • Level: minor, titled fix(runtime)!:, with the BREAKING line "shipped as minor under the launch-window convention". An accept-set narrowing is BREAKING (the (narrowing) arm is BREAKING by the repo's rule), major is refused repo-wide by check-changeset-no-major, and the lane's precedent .changeset/23214-dispatcher-query-arity.md (commit d45d0e938a, PR fix(runtime)!: the dispatcher's read points refuse a repeated single-valued query name, as their RestServer twins do #23263) spells it the same way. Right.
  • The Clause-②: line reads no (narrowing) in the PR body and in the changeset, identically. no is right: nothing is added to any published surface (no new export; repeatedQueryParamMessage was already exported; expectedVersion was already in DeleteDataRequest). (narrowing) is right: three answers move from accept to refuse (items 1, 4 and 6 above). The precedent was yes (narrowing) because it added three @objectstack/rest exports; this diff adds none, so the arm differs for the right reason. Right.
  • ADR-0087: exactly one marker, not-required (no-migration-prescription). No spec key, export, authorable spelling or stored shape moves; the remedy is in the request a caller sends. Right, and the head's Lint · CI, release & engine gates run, which carries the registration gate, is success.
  • FROM → TO: the three bullets state each moved answer, and the stale bullet's sentence that a createHonoApp host renders the 409 as 500 is true at this head (the catch-all's err.statusCode || 500).

③ Boundary flags

Round 0 (os-dev-report 6166635937): the deviations and the open question; then round 1 (6166784291).

  1. File surface beyond the claim (action-execution.ts, callData's delete arm): open question A/B, the dev recommends A. Answered on the card: the seat's supplement claim 6166705764 took A and added the file to the claim's surface with the four in-place-fix conditions checked. This review agrees on the merits: without the arm the card's acceptance is unreachable (ablation B), it is the same token dropped a second time, in shapes already landed, in the same package, with no other holder. Closed, A.
  2. Fifteen open PRs' file lists read beyond the single card read. A read, no write, in service of condition ③ of that rule. Noted; nothing to escalate.
  3. origin/main merged before the PR opened. That merge is the merge-base; the net diff is the four files above. Noted.
  4. Model-free commit trailers; the dev stopped its own gate re-run tree; the worktree was removed after the PR. Housekeeping under the repo's rules, no bearing on the contract. Noted.
  5. Hypothesis (a) correction: slice 1 used repeatedQueryParamMessage in three other domains and assertFilterParamSuppliedOnce in data.ts. Verified at the head: data.ts imports both from @objectstack/rest, the first for this delete and the second for the list's filter slot. Correct; closed.
  6. Round 1 open question: the changeset's repeat bullet says "TO 400 VALIDATION_ERROR", but a createHonoApp host hands dispatch() only the last value of a repeated name, so there the last ?expectedVersion is judged as the token until that adapter keeps repeats. Add one sentence (A) or leave it (B)? Answer: A. The premise is verified at this head (item 9 above). A changeset sentence must be true when it ships, the in-flight adapter flip is not a fact of this head, and the precedent changeset for this same adapter carried exactly that sentence. This is prose-face: a later head that touches only the changeset keeps this record under the prose-face exception, and the sentence's presence is the seat's ACCEPT reading, not a change to the accept set or to the level judged here. For the record, the branch already carries a later changeset-only commit (commit 85123b46), not reviewed here.
  7. The PR body's acceptance notes: the PATCH twin's drop (runtime: the dispatcher's PATCH /data/:object/:id drops If-Match (a stale update is applied) and refuses a body expectedVersion as an unknown field, where REST refuses the stale update 409 #23462, filed by the seat) and the dispatcher's 409 body lacking currentVersion / currentRecord (carrier hono adapter: createHonoApp's catch-all renders an error thrown out of dispatch() from statusCode alone, so a declared status/code (a missing record's 404 RECORD_NOT_FOUND, an outage's 503) answers 500 INTERNAL_ERROR #23365). Both are correctly outside this card and need no escalation.
  8. The test family's cancelled shards on this head (① above) are the one thing this record cannot settle: the landing pre-check reads the check-runs on whatever head lands, and the code on this head is byte-identical to its parent outside the changeset. The seat's landing check owns it; no escalation from this review.

Implemented-by: claude/issue-23265-dispatcher-delete-version
Reviewed-by: session_01Q9PZ5Pzqcus4woVvTZwWNU

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Regen-provenance: 6167020590 · cf3a77af611bfd714028d9890a5622242e655542 → 85123b4628fe67764280238075ca771dd073cf9c · git diff --name-only cf3a77af611bfd714028d9890a5622242e655542 85123b4628fe67764280238075ca771dd073cf9c outside the prose faces → (empty)

domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · 2026-10-14T11:13Z. The prose arm of the record-carry rule: the one hop since the reviewed head changes only .changeset/23265-dispatcher-delete-expected-version.md (patch round 2's sentence, read sentence by sentence in ACCEPT 6166888444 on #23265). The CONTRACT_REVIEW_TIER record 6167020590 (PASS, served model checked against the reviewer's transcript) governs this head.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 11:24
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 11:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 3d231ba Oct 14, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23265-dispatcher-delete-version branch October 14, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runtime: the dispatcher's DELETE /data/:object/:id drops expectedVersion / If-Match, so a stale delete is applied where REST refuses it

2 participants