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. added
    bugSomething isn't working
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    priority:p1High: required for production / M2
    and removed on Oct 14, 2026
  3. 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

  4. 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: 承接者:无."
    ]
    }

  5. 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

  6. 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

  7. 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

bugSomething isn't workingdomain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpriority:p1High: required for production / M2target:v18

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions