Skip to content

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

Closed

Description

@objectstack-fleet

Filing gate ①, class (a): the dispatcher's twin of a REST door drops a concurrency guard the caller sent, so a stale write is applied. It was measured at a public door. This is the second position of the family #23265 opened (the dispatcher's /data writes drop the optimistic-concurrency token). That card carries the DELETE position, fixed in PR #23460; this card carries the remaining PATCH position.

reach: HTTP, measured once by #23265's dev (os-dev-report 6166635937, out-of-scope finding 1). The setup was a real @objectstack/hono createHonoApp over the real runtime dispatcher, a better-sqlite3 store and the real protocol.

  • PATCH /api/v1/data/task/ID with a stale If-Match answered 200 and wrote the row (its name went from the before value to the after value).
  • The same PATCH with a stale expectedVersion in the body answered 500 "Unknown field 'expectedVersion' on object 'task'" and wrote nothing.

Filed by domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU, from that report. ⛔ Not a claim. Lane and grade are triage's.

Reader who acts: triage grades it. The landing is the dispatcher's PATCH branch in packages/runtime/src/domains/data.ts (about :145), plus callData's update arm in packages/runtime/src/action-execution.ts (about :366). Both forward no token. Both are domain:cli by the lane table.

What happens

  • REST: RestServer's PATCH /data/:object/:id (packages/rest/src/rest-server.ts, about :9015-:9060) reads expectedVersion from the body, else If-Match. It strips the token from the patch and hands it to updateData, which refuses a stale update 409 CONCURRENT_UPDATE.
  • The dispatcher: its PATCH branch reads neither. It passes the body through as the patch, so a body expectedVersion reaches the engine as a field (500, unknown field), and If-Match is dropped. callData's update arm forwards no token either.

Governing text

The ADR-0008 precondition family: If-Match and expectedVersion on a write are honoured or refused, never ignored. AGENTS.md 〈Route & surface ownership〉: one door's two transports answer one request alike.

Seam: spec:UpdateDataRequest expectedVersion → runtime: domains/data.ts PATCH branch + action-execution.ts callData update arm (consumer: every createHonoApp host's /data update).

Acceptance

Dedupe

MCP search_issues on objectstack-ai/objectstack, closed included:


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · priority:p1 + target:v18 · domain:cli · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-14T10:56Z

    Read fresh. On main, packages/runtime/src/domains/data.ts:145-146 (the dispatcher's PATCH /data/:object/:id branch) has no read of If-Match or expectedVersion near it. REST's twin refuses a stale update with 409 CONCURRENT_UPDATE.

  2. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 (#23462: the dispatcher's PATCH /data/:object/:id reads expectedVersion from the body, else If-Match, strips it from the patch and forwards it through callData to updateData, so a stale update is refused as REST refuses it) · 2026-10-14T11:52Z
    Session: session_01Q9PZ5Pzqcus4woVvTZwWNU
    Account: sunhaolin (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-23462-dispatcher-patch-version
    Worktree: objectstack-issue-23462
    Domain: domain:cli
    Seat: domain:cli#2 (seat post #22648)
    File surface (read on origin/main 3d231ba787, which carries PR #23460; stop on a breach and explain it in the report):

    Container & model: S, judged (one branch, one arm, the landed family shape to mirror; the body-token strip is a reading of REST's twin), mode:subagent, model: default tier (dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"). Ceiling CONTRACT_REVIEW_TIER, reason: an accept-set narrowing on a released package (Clause-②: no (narrowing)), one review at that tier owed before the queue (no contract face; as PR #23460).
    Clause-②: no (narrowing)
    Responsibility: the dispatcher's PATCH branch and callData's update arm in packages/runtime (this lane) | RestServer's PATCH twin reads, strips and forwards the token; none on the dispatcher | every createHonoApp host's /data update that sends a version; measured once at 6166635937 (a stale If-Match update was applied)
    Thread-read: 6166766561
    Serial constraints cleared:


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23462,
    "status": "done",
    "branch": "claude/issue-23462-dispatcher-patch-version",
    "pr": "#23490",
    "session": "session_01Q9PZ5Pzqcus4woVvTZwWNU (subagent: the dispatching seat's session, as the claim names it)",
    "premise_still_valid": true,
    "summary": "The dispatcher's PATCH /data/:object/:id (packages/runtime/src/domains/data.ts) now reads the body's expectedVersion (when the body is an object), else If-Match through the private requestIfMatch reader PR #23460 added, strips the key from the patch whatever its value, and forwards String(token) when truthy: REST's own statements (rest-server.ts:9014-9063, read only), so both doors hand updateData the same request. callData's update arm (action-execution.ts) forwards params.expectedVersion to updateData, and its ObjectQL fallback refuses a token 501 NOT_IMPLEMENTED before any read or write, in the delete arm's shape; every other update caller (mcp.ts, the declarative update action, endpoint-executor) sends {object,id,data} and is unchanged. The premise held at origin/main 3d231ba exactly as filed: before the fix, through dispatch(), a stale If-Match answered 200 and wrote the row, and a body expectedVersion was refused 400 INVALID_FIELD (the 500 the card measured on the createHonoApp wire). Changeset .changeset/23462-dispatcher-patch-expected-version.md: @objectstack/runtime minor, fix(runtime)!:, FROM -> TO, one ADR-0087 marker not-required (no-migration-prescription), the launch-window BREAKING line, and PR #23460's createHonoApp-renders-500 wording (no wire 409 promised).",
    "tests": "Pin file packages/runtime/src/domains/data-update-expected-version.test.ts (PR #23460's rig: real better-sqlite3 store, real ObjectQL + ObjectStackProtocolImplementation, RestServer's registered PATCH handler and HttpDispatcher.dispatch() side by side, the store read after every request): 5 rows per door (no token; current body token updates and the patch carries no expectedVersion key; stale body token 409 CONCURRENT_UPDATE, row unchanged; If-Match both ways; body token judged before If-Match), 6 side-by-side token shapes (stale, number, null beside stale and current If-Match, '' beside stale If-Match, body only the current token) asserting equal status+code+row and no key reaching the engine, the Node header-record row, and 3 fallback rows (body token and If-Match 501 NOT_IMPLEMENTED with find/update never called; unguarded update still reaches the fallback). BEFORE (unfixed source, 94f0cf0): 'Tests 13 failed | 7 passed (20)' (every RestServer row and both controls green). AFTER (19d1b8d, with the landed DELETE file): 'Test Files 2 passed (2) / Tests 40 passed (40)'. ABLATIONS, each via node scripts/ablation-replace.mjs WRAP mode + a driver restore trap, anchor x1 -> x0, 'mutation landed', restore proven by blob == HEAD and empty git diff HEAD; subject imported relatively (../http-dispatcher.js -> src), so no dist rebuild in the path: A data.ts forward dropped '10 failed | 10 passed'; B callData update-arm forward dropped '8 failed | 12 passed' (501 rows stay green, the refusal is its own statement); C fallback refusal if(false) '2 failed | 18 passed'; D (beyond the dispatch's three, for 'never written as a field') the strip dropped '9 failed | 11 passed'. Expected direction red each time; each went red. A's first attempt was a no-op the tool refused before running anything (replacement 'data,' was a prefix of the anchor, so its count could not rise); redone with a marker comment. FINAL HEAD 75d46f1 (merges origin/main 1baf976; incoming #23458 #23466 #23464 touch nothing on this path; then pnpm install --frozen-lockfile, rm -rf packages/runtime/.objectstack, closure rebuilt pnpm --filter '@objectstack/runtime...' build VERDICT 0): pnpm --filter @objectstack/runtime run typecheck exit 0 (check:test-typecheck: 27 files / 190 errors / 68 pinned signatures held; new test in tsconfig.test.json --listFilesOnly 1 hit, tsconfig.json 0); vitest run --project local --maxWorkers=2: 'Test Files 377 passed (377) / Tests 5507 passed | 19 skipped (5526)'.",
    "gates": "Local, all at final head 75d46f1, exit captured before any pipe: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack = 65 commands; 64 exit 0; 'pnpm check:dual-build-cjs-loads :: exit 3' PREREQUISITE NOT MET (needs a whole-repo pnpm build) = NOT MEASURED, CI runs it. --ran reconciliation (record with exits): '65 derived, 64 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3), 0 UNRUN'. The five roster gates whose roster sits under a touched directory, also run: check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:route-ledger-census, each exit 0. pnpm lint as the declared narrowing: (1) the diff touches no eslint config/ignore file (0 paths); (2) pnpm exec eslint --no-inline-config over data.ts, action-execution.ts and the new test exit 0, empty output; (3) the config is untyped and per-file (eslint.config.mjs:326-328 'never enables type-aware linting'; 6 active rules on data.ts, none cross-file). CI (PR #23490 check-runs on 75d46f1, read once at report time, not waited on): 36 runs: 4 completed success, 6 completed skipped, 18 in_progress, 8 queued; Test Core (1/6..6/6) queued, Build Core queued, Governed Surface Queue Guard in_progress; Lint & Repo Gates and TypeScript Type Check not yet listed. in_progress is the honest value.",
    "deviations": [
    "Ablation D (strip dropped) added beyond the dispatch's three, to prove the card's 'a body token is never written as a field' pin can fail; additive.",
    "Ablation A's first attempt measured nothing (refused by ablation-replace's own evidence check before the command ran); redone, both legs reported.",
    "Lint: the dispatch's 'this lane always adds pnpm lint' meets the role file's 'repo-wide scans are CI's, never owed'; took the dispatch's own alternative, the proven narrowing with three pieces of evidence, which satisfies both.",
    "Merged origin/main (1baf976) once before opening the PR, per AGENTS.md section 10; the gate list was derived pre-merge at ae61e9d and re-derived at 75d46f1 by --ran (65 both times).",
    "Harness attribution reminder asked a model-named Co-Authored-By trailer and a different PR footer; followed AGENTS.md's model-free trailer pair and the session-URL footer (role file precedence). Not a deviation from the binding text; reported.",
    "Report comment follows the landed os-dev-report shape (marker line + raw JSON, no footer) so the PM's mechanical parse is unchanged."
    ],
    "files_changed": [
    ".changeset/23462-dispatcher-patch-expected-version.md",
    "packages/runtime/src/action-execution.ts",
    "packages/runtime/src/domains/data-update-expected-version.test.ts",
    "packages/runtime/src/domains/data.ts"
    ],
    "mechanism_readings": {
    "a": "Confirmed: rest-server.ts:9014-9063 reads body expectedVersion else If-Match, strips the key when present, forwards String(token) when truthy; mirrored; no @objectstack/rest export added.",
    "b": "Confirmed: requestIfMatch and the delete arm's forward + 501 shape reused; no second spelling.",
    "c": "Measured both doors (scratch, not committed). Token shapes: doors agree (number/object stringified and judged; null falls to If-Match; false or '' stripped and forwarded as no token, so If-Match unread; quoted-empty "" refused 400 VALIDATION_FAILED by the protocol on both). Non-record body: doors differ, not about the token: RestServer's UpdateDataRequestSchema gate answers 400 VALIDATION_FAILED for array/string/number and 200 for null; the dispatcher answers 500 with nothing written. Pre-existing, different defect class, reported as finding 1, not fixed.",
    "d": "Confirmed: dispatch() throws ConcurrentUpdateError read 409/CONCURRENT_UPDATE. Real createHonoApp (scratch: adapter source over runtime dist built at 19d1b8d): stale body token and stale If-Match each 500 INTERNAL_ERROR with the conflict sentence, row unchanged; current token and no token 200, updated. #23365 remains open for the 500; changeset carries PR #23460's wording, no wire 409 promised.",
    "e": "No hono-* pin added, so no surface supplement needed. Both PATCH hand-offs are already pinned at the adapter (hono.test.ts 'PATCH /api/data/account parses JSON body'; hono-catch-all-raw-request.test.ts raw Request for PUT/PATCH, where If-Match rides), and the domain's reading of both is pinned here through dispatch(). The one added fact would be the wire status, today #23365's 500; pinning it pins that defect. End-to-end reading taken as a scratch run instead, as PR #23460 did."
    },
    "mcp_calls": "0",
    "api_writes": "2 relay strokes (each one POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft, #23490) + POST /repos//issues/23490/assignees [sunhaolin], run 38568895132 success, read-back 12154 bytes sent = stored; (2) comment -> POST /repos//issues/23462/comments (this report). Zero label writes (none expected). git push x5 (not REST).",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: a · reach: HTTP, a real createHonoApp (scratch over runtime dist at 19d1b8d): PATCH /api/v1/data/task/ID with an array, a string or a null JSON body answered 500 INTERNAL_ERROR (the engine's own error: 'no such column: 0', 'Cannot use 'in' operator to search for 'organization_id' in text', 'Cannot read properties of null (reading 'id')'), nothing written; RestServer's twin answers 400 VALIDATION_FAILED (its UpdateDataRequestSchema gate) for array/string/number and 200 for null (patched as {}). A caller's mistake is answered as an internal fault, and one route's two transports answer differently. Pre-existing: the dispatcher's PATCH branch never had REST's body-schema gate (before this PR the same bodies passed through unchanged); POST create likely has the same gap (REST's create gate), unmeasured. Seam: spec:UpdateDataRequestSchema.data (record) -> runtime: domains/data.ts PATCH branch (no schema gate). Dedupe words: 'dispatcher PATCH non-record body 500 VALIDATION_FAILED', 'UpdateDataRequestSchema data record dispatcher createHonoApp', 'dispatcher data write body schema gate RestServer parity'.",
    "class: a · reach: HTTP, a real createHonoApp (scratch): body {name, expectedVersion: ''} with a stale If-Match answered 200 and wrote the row; the same on RestServer's registered PATCH handler (scratch rig: 200, row written); a false body token likewise. REST's 'bodyVersion ?? If-Match' then a truthy test: an empty or false body key is not nullish, so If-Match is never read, and falsy, so no token is forwarded, and the update runs unguarded beside the stale If-Match the caller sent. It is the #13576 class (an empty token silently opting out of the check) on the body spelling; the protocol refuses the quoted-empty "" but never sees this one. Mirrored on purpose by PR #23490 to keep the doors alike, so the fix lands in RestServer and the dispatcher together. Seam: spec:UpdateDataRequest.expectedVersion -> runtime: rest-server.ts PATCH :9021-9024 + domains/data.ts PATCH branch. Dedupe words: 'expectedVersion empty string If-Match ignored', 'falsy body token skips If-Match stale update applied', 'PATCH data expectedVersion false'."
    ],
    "noted_not_filed": [
    "?expectedVersion as a query on PATCH is read by neither door (both apply a stale update sent that way); the spec declares the PATCH token as the body field or If-Match, so an undeclared spelling. In the PR's Acceptance notes; carrier: 承接者:无.",
    "The declared-endpoint door (endpoint-executor.ts, object_operation update) hands ctx.body as data and reads no token; read-only inference, unmeasured. In the PR's Acceptance notes; carrier: 承接者:无."
    ]
    }

  4. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: PR #23490 at 75d46f1034 (#23462). It lands after one CONTRACT_REVIEW_TIER PASS and an all-green head · 2026-10-14T13:13Z

    domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6167715147. Report 6169083704. Thread-read: 6169083704.

    Checklist, read against GitHub and origin/main, not against the report:

    • PR shape: draft, base main, title fix(runtime)!:, assignee sunhaolin. Line 1 is Fixes #23462, the body's only closing keyword; line 2 is Clause-②: no (narrowing).
    • Scope: 4 files, +445 / −2, all inside the claim's surface. check-governed-merges --pr 23490: NOT governed, 447 changed lines. No content/docs/releases/ change.
    • The fix, read in the diff beside its twin (rest-server.ts:9020-:9058 on main 0d3ea295d1):
    • Pins and reverse verification:
    • Changeset prose, checked sentence by sentence against the diff and the measurements: @objectstack/runtime minor, fix(runtime)!:, Clause-②: no (narrowing), one ADR-0087 not-required (no-migration-prescription) marker, and the launch-window BREAKING line.
      • Stale If-Match: FROM 200 and written, TO 409 CONCURRENT_UPDATE thrown out of dispatch(). It keeps PR fix(runtime)!: the dispatcher's DELETE /data/:object/:id honours ?expectedVersion and If-Match, as RestServer's twin does #23460's sentence that a createHonoApp host still renders it 500 with the row unchanged; the dev measured exactly that on a real createHonoApp. No wire 409 is promised.
      • Body expectedVersion: FROM refused as an unknown field, TO the version token, stripped from the patch and read before If-Match.
      • ObjectQL fallback: a token on the fallback is now 501.
      • SDK sentence: @objectstack/client's data.update sends data as the PATCH body (packages/client/src/index.ts:7962-:7966). Each FROM → TO holds on every host it names.
    • Gates: the dev ran the 65 derived gates locally at 75d46f1034, as follows:
      • 64 exited 0. check:dual-build-cjs-loads exited 3 on a missing prerequisite (a whole-repo build): NOT MEASURED, and CI runs it.
      • The full runtime suite (377 files, 5,507 tests) and the runtime typecheck exited 0.
      • pnpm lint was taken as the proven narrowing, with three pieces of evidence: no eslint config or ignore file touched, eslint over the three files exits 0, and the config is untyped and per-file.
      • CI on 75d46f1034 at this stamp: 37 runs, of which 20 success, 6 skipped and 11 still running. Every skip is on the expected-skip roster: Dogfood Verify CLI, Temporal Conformance, Console Pin Gate, Build Docs, Dogfood Regression Gate, and Packed-tarball smoke. Landing reads the check-runs again.
    • Docs drift: none owed. As for PR fix(runtime)!: the dispatcher's DELETE /data/:object/:id honours ?expectedVersion and If-Match, as RestServer's twin does #23460, the docs state the protocol's concurrency rule without naming a transport, and the dispatcher now honours it.

    Accepted deviations:

    Out-of-scope findings, one line each:

    Landing: one CONTRACT_REVIEW_TIER record is owed, from an isolated subagent reviewing 75d46f1034. Then pr_ready + automerge_enable go through the relay once every check on the current head is success or a roster skip.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23490 comment 6169313057 · 2026-10-14T13:19Z

    The seat record of PR #23490 now lives on the PR, as review-checklist.md has required since 2659901c5e. This seat's earlier ACCEPT on this card, 6169192789, carries the same verdict and is now history only.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23490 → 650933ec76 · 2026-10-14T13:48Z

    domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6167715147, released by this landing. Review: seat record 6169313057 on the PR (pointer ACCEPT → PR #23490 comment 6169313057 on this card), and CONTRACT_REVIEW_TIER record 6169384075 (PASS on 75d46f1034, served by the default model per the maintainer's 「契约复审 opus就够了」).

    Reading Value
    PR merged: true, merged_at 2026-10-14T13:47Z, through the merge queue (added_to_merge_queue 2026-10-14T13:29Z)
    Queue branch gh-readonly-queue/main/pr-23490-* absent (git ls-remote, 0 refs)
    origin/main 650933ec76 is an ancestor; one parent (8693110c4c); 4 files, +445 / −2, as reviewed
    Closing keywords in the landed commit Fixes #23462 only
    Lane reconciliation 17 open domain:cli cards. Against the 12:10Z set: minus #23462 (this landing), plus #23479 (filed 12:07Z, p2 pm:queue). #23491 and #23492 (filed at this PR's ACCEPT) are still finding, ungraded

    State change: #23462 closed completed by the merge; pm:dispatched removed in this act. #23492's order condition ("after PR #23490 lands") now holds. The contract reviewer's escalation (the declared-endpoint door's object_operation update reads no token) stays in Acceptance notes, 「承接者:无」: it is unmeasured inference with no named producer, so it has no reach: (filing-gate.md:15, :21).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions