Skip to content

rest, runtime: a body expectedVersion of '' or false hides If-Match on PATCH /data/:object/:id, so an update sent with a stale If-Match is applied unguarded, on both doors #23492

Closed

Description

@objectstack-fleet

Filing gate ①, class (a): a concurrency guard the caller sent is silently dropped, so a stale write is applied. It was measured at a public door. This is #13576's class (an empty token silently opting out of the check), on the body spelling.

reach: HTTP, measured once by #23462's dev (os-dev-report 6169083704, out-of-scope finding 2).

  • The setup: a real @objectstack/hono createHonoApp, and separately a rig over RestServer's registered PATCH handler.
  • The request: a body { name, expectedVersion: '' } with a stale If-Match.
  • The result: 200, and the row was written, on both. A body expectedVersion: false behaves the same way.

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

Reader who acts: triage grades it. Both landings are domain:cli by the lane table:

What happens

Both doors compute bodyVersion ?? If-Match and then forward a token only when it is truthy.

  • '' and false are not nullish, so If-Match is never read.
  • They are falsy, so no token is forwarded.
  • So the update runs unguarded beside the stale If-Match the caller sent.

The protocol refuses a quoted-empty "" (#13576), but it never sees this request, because the door drops the token first.

Governing text

The ADR-0008 precondition family: If-Match and expectedVersion on a write are honoured or refused, never ignored. #13576 (closed) is the same rule for the header spelling.

Seam: spec:UpdateDataRequest.expectedVersion → rest: rest-server.ts PATCH + runtime: domains/data.ts PATCH branch (consumer: every /data update that sends a version).

Acceptance (a proposal; triage's call)

Noted beside it, not filed

These are from the same report, in PR #23490's Acceptance notes, carrier none:

  • ?expectedVersion as a query on PATCH is read by neither door. The spec declares the PATCH token only as the body field or If-Match.
  • The declared-endpoint door (endpoint-executor.ts, object_operation update) reads no token. That is a read-only inference, unmeasured.

Dedupe

MCP search_issues on objectstack-ai/objectstack, closed included: expectedVersion empty string or false in PATCH body skips If-Match, stale update applied. It returned 4 hits: #23462 and #23265 (the dispatcher's dropped tokens, not this), and #22114 and #14393 (closed, not this). #13576 is the header twin, closed.

Dedupe words: expectedVersion empty string If-Match ignored; falsy body token skips If-Match stale update applied; PATCH data expectedVersion false.


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-14T13:58Z

    Read fresh. Both doors compute bodyVersion ?? If-Match and forward only a truthy token, so expectedVersion: '' or false hides a stale If-Match, and the update is applied. This is #13576's class on the body spelling.

  2. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 5, serial (#23492: a version token sent empty or falsy is refused 400 VALIDATION_FAILED on both /data doors, never dropped beside a stale If-Match) · 2026-10-14T15:03Z
    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-23492-empty-version-token
    Worktree: objectstack-issue-23492
    Domain: domain:cli
    Seat: domain:cli#2 (seat post #22648)
    Scope: the maintainer's 「当前任务处理完,后续改为串行派发」. This seat's three cards in flight at that order have all landed (PR #23490, PR #23499, PR #23500), so this is the one serial claim. It is the lane's only dispatchable p1; #23398 is pm:blocked.
    File surface (read on origin/main 3a2adc8835; stop on a breach and explain it in the report):

    Container & model: S, judged (four token reads in two mirrored doors, one ruled rule, pins on an existing rig), 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 two released packages (Clause-②: no (narrowing)), so one review at that tier is owed before the queue. It is served by the default model, per the maintainer's 「契约复审 opus就够了」.
    Clause-②: no (narrowing)
    Ruling the rule rests on: #13576's maintainer decision #20 ①, 2026-08-31, as recorded at packages/metadata-protocol/src/protocol.ts:2884-:2925 (MalformedVersionTokenError).


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23492,
    "status": "done",
    "branch": "claude/issue-23492-empty-version-token",
    "pr": "#23515",
    "session": "session_01Q9PZ5Pzqcus4woVvTZwWNU (the parent's session; this run is mode:subagent)",
    "premise_still_valid": true,
    "summary": "Both /data write doors (RestServer and the runtime dispatcher's /data domain) now sort every optimistic-concurrency token with one function, versionTokenRefusal, written statement for statement in packages/rest/src/rest-server.ts and packages/runtime/src/domains/data.ts. undefined or null means no token (it falls to If-Match, unchanged). A non-blank string or a finite number is judged: String(token) is forwarded, and the forward test is now != null, not truthiness. Anything else (an empty or blank string, a boolean, an array, an object) is refused 400 VALIDATION_FAILED before updateData/deleteData is asked. That is #13576 decision #20 ①'s rule, applied to the body and query spellings. Every PM hypothesis held when measured on the rig before the fix, both doors identical. (a) DELETE ?expectedVersion= beside a stale If-Match deleted the row with a 200. (b) Body '', false and 0 were each dropped and applied with a 200; null fell to If-Match (409); a non-blank string or a non-zero number was judged. (c) Forwarding '' is NOT refused by the protocol: assertVersionTokenNotMalformed reads ''/blank as no token, and a blank body token was already forwarded and applied unguarded beside a stale If-Match, the same defect. (d) An empty If-Match alone was applied unguarded. It is INCLUDED, on both doors: it is a header that was sent, not the absent header clause ② keeps; RFC 7232 admits no empty If-Match value; no first-party producer emits one (client SDK and objectui omit it for a falsy ifMatch); and excluding it needs a second rule. (e) The body strip is unchanged. Also refused: true, arrays and objects (true/{} were judged 409 as 'true'/'[object Object]'; [] stringified to '' and wrote unguarded). Body 0 is now judged as '0' (409), like every other number. Rendering: RestServer throws validationFailure(sentence, []) into the route's catch, so mapDataError gives the envelope it already gives the protocol's "" refusal. A hand-built body was the first draft and check:route-envelope refused it (siblingCode 41 vs 39). The dispatcher RETURNS deps.errorFromThrown(...), and a real createHonoApp host was measured answering 400 VALIDATION_FAILED for all five refusal shapes (scratch test, not committed); protocol-thrown 409 and "" 400 still render 500 there (#23365). Draft PR #23515 is open, assigned sunhaolin; no label written (none named, and skip-changeset does not apply). Worktree removed after the PR opened.",
    "tests": "Pins added in packages/runtime/src/domains/data-update-expected-version.test.ts and data-delete-expected-version.test.ts. Per door: PATCH '' and false each beside a stale If-Match and beside no header, blank, true, [] and an empty If-Match; DELETE ?expectedVersion= beside a stale If-Match and beside no header, blank ?expectedVersion and an empty If-Match. Each asserts {status 400, code VALIDATION_FAILED}, the protocol never called and the row read back unchanged. Also per door: protocol-parity with the "" refusal, plus null and 0 controls; a cross-door parity row per shape asserts the same status, code and sentence. Runs: (1) 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 at 6d399e7: 'Test Files 2 passed (2) / Tests 84 passed (84)', VERDICT command-exit 0. (2) pnpm --filter @objectstack/rest run test at 6d399e7: 'Test Files 283 passed (283) / Tests 5852 passed | 326 skipped (6178)', VERDICT command-exit 0. (3) pnpm --filter @objectstack/runtime run test at b763612: 'Test Files 380 passed (380) / Tests 5617 passed | 19 skipped (5636)', VERDICT command-exit 0; the one later commit touches only RestServer's refusal rendering, and (1) was re-run on it. (4) typecheck @objectstack/rest + @objectstack/runtime at 6d399e7: tsc --noEmit plus check:test-typecheck OK on both, VERDICT command-exit 0. Ablations, all via node scripts/ablation-replace.mjs with a restore trap; restores proven by blob == HEAD and an empty git diff HEAD. Both subjects resolve to src (runtime's vitest aliases @objectstack/rest to src; the dispatcher is a relative import), so no dist leg applies. (A) RestServer refusal dropped at 6d399e7, anchor on disk 1/0/1: 27 red / 57 green. Every RestServer #23492 refusal row is red (the null and 0 controls stay green), the cross-door rows are red, and every catch-all row is green. (B) dispatcher refusal dropped at b763612 (data.ts unchanged since), 1/0/1: 27 red / 57 green, the exact mirror. (C) RestServer forward reverted to truthy, 3 sites, 3/0/3: 1 red, RestServer's 0 control. (D) dispatcher forward reverted, 2 sites, 2/0/2: 1 red, the catch-all's 0 control. Before-fix readings (rig, origin/main 3a2adc8): body ''/false/0/' ' beside a stale If-Match, and ?expectedVersion= / blank / an empty If-Match, all 200 with the row written or deleted, on both doors. createHonoApp-host readings (scratch, real createHonoApp over the rebuilt runtime dist): the five refusal shapes answer 400 VALIDATION_FAILED, the protocol is never called, and the no-token and 0 controls answer 200.",
    "gates": "Local, on final head 6d399e7. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 65 commands; all 65 exited 0, each exit captured before any pipe. On the first battery run (at b763612), pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET, no full build, so NOT MEASURED) and pnpm check:route-envelope exited 1 (rest-server.ts siblingCode 41 vs declared 39, from my hand-built refusal body; fixed by 6d399e7). The rerun on 6d399e7 gave exit 0 for both. --ran with 'COMMAND :: exit CODE' records: 'Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN' ('a DERIVED zero'). The roster gates whose roster sits in my paths also exited 0: node scripts/check-changeset-fixed.mjs, pnpm check:error-code-casing, pnpm check:filter-alias-parity, pnpm check:route-ledger-census. pnpm lint was run as a declared narrowing: node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config over the 4 changed .ts files exited 0. For the positive control, the same invocation through --stdin-filename on data.ts and rest-server.ts with an injected restricted import exited 1 (no-restricted-imports), so the paths are linted. CI check-runs on 6d399e7 when the report was written: completed success are Auto Label, Check Documentation Links, Check PR Size, filter, the claim/closing guards (4). in_progress are Build Core, Check Changeset, every Lint · lane, Governed Surface Queue Guard, Type Check · (4 lanes), Test Core (1-6) and Dogfood Regression Gate. Not re-run locally; convergence is the PM's.",
    "mcp_calls": "0",
    "api_writes": "2 relay strokes (scripts/pm/fleet-write/dispatch.mjs, POST /repos/objectstack-ai/objectstack/dispatches each, executed as objectstack-fleet[bot]). Stroke 1, pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos//issues/23515/assignees ['sunhaolin']. Stroke 2: POST /repos//issues/23492/comments (this os-dev-report). Plus git push of branch claude/issue-23492-empty-version-token (not REST). No label write.",
    "open_questions": [
    {
    "question": "The protocol's own guard (assertVersionTokenNotMalformed, packages/metadata-protocol) still reads '' and blank as no token, citing 'the ruling's clause ②'. The ruling text quoted in protocol.occ-empty-etag-rejected.test.ts makes clause ② 'no If-Match at all is still a legal unguarded write', i.e. absence. So an in-process caller of updateData/deleteData with expectedVersion '' still writes unguarded; both HTTP doors now refuse it first. Should the protocol refuse a present-but-blank token too?",
    "options": [
    "A: leave the protocol as is. The doors refuse; no in-process producer of a blank token was found or measured.",
    "B: a separate metadata-protocol card that refuses a blank client token in assertVersionTokenNotMalformed and re-reads its clause ② docblock (out of this claim's surface)."
    ],
    "recommendation": "A for now, because no producer was measured beyond the two doors this PR closes. Choose B only if a seat finds an in-process caller that forwards a raw client token."
    }
    ],
    "out_of_scope_findings": [
    "class: b · reach: HTTP PATCH /data/:object/:id with body {name, expectedVersion: 5} answered 409 CONCURRENT_UPDATE on both doors (judged as '5'), measured on this PR's rig before and after the fix, where the declared contract makes a number off-spec · evidence: UpdateDataRequestSchema.expectedVersion is z.string().optional() (packages/spec/src/api/protocol.zod.ts:2458); both doors forward String(token), and RestServer's UpdateDataRequestSchema.safeParse sees the stringified value, so the declared type is never checked. Seam: spec:UpdateDataRequest.expectedVersion → runtime: rest-server.ts PATCH String(expectedVersion) + runtime/domains/data.ts PATCH String(expectedVersion). Same family as #13576 / #23462 / #23265 / this card; the PM ruled refusing numbers out of this card · dedupe words: expectedVersion number coerced String; UpdateDataRequest expectedVersion z.string number accepted; PATCH data numeric expectedVersion judged 409",
    "carrier: #23365 · a createHonoApp host renders the protocol's THROWN refusals (409 CONCURRENT_UPDATE, and 400 VALIDATION_FAILED for "") as 500, because its catch-all reads only statusCode; re-measured here with a thrown {code VALIDATION_FAILED, status 400}, which answered 500 INTERNAL_ERROR · noted, not filed",
    "carrier: none · noted in PR Acceptance notes, not filed: (1) the spec describe of expectedVersion names only "" as refused, so it is narrower than the doors' behaviour now (polish); (2) ?expectedVersion as a query on PATCH is read by neither door (unchanged, already noted on the card); (3) the protocol's blank-as-no-token reading for in-process callers (read-only inference; see open_questions)"
    ],
    "deviations": [
    "File surface: versionTokenRefusal is a module-level function in rest-server.ts (inserted after sendMetaItemAbsent, about :1736, well clear of PR #23469's hunks at about :79/:301/:1848 on main/:9601+) and in data.ts beside requestIfMatch. Each serves only the four token reads. A single shared helper would need an export from packages/rest/src/index.ts, which is outside the claim, so the function is duplicated statement for statement and a cross-door parity pin holds the sentences equal. data.ts also gains an import of ../validation-failure.js.",
    "The createHonoApp wire measurement was a scratch test in packages/qa/http-conformance (outside the surface), deleted before commit and cited by its readings. No permanent hono-host pin was added.",
    "pnpm lint: the dispatch order says this lane always adds it; the role file says repo-wide scans are CI's and never owed. I resolved it with the order's own alternative, a narrowing over the changed files plus a positive control. I am stating the conflict rather than picking a side silently.",
    "The runtime full suite was run at b763612, not the final head 6d399e7. The last commit changes only RestServer's refusal rendering (it throws validationFailure instead of writing a body); the two pin files and the full rest suite were re-run on 6d399e7.",
    "The PR footer uses AGENTS.md's session-URL form, not the harness reminder's form (the role file and AGENTS.md take precedence). The commit trailers are the model-free pair."
    ],
    "files_changed": [
    ".changeset/23492-empty-version-token-refused.md",
    "packages/rest/src/rest-server.ts",
    "packages/runtime/src/domains/data.ts",
    "packages/runtime/src/domains/data-update-expected-version.test.ts",
    "packages/runtime/src/domains/data-delete-expected-version.test.ts"
    ]
    }

  4. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23515 comment 6173147350 · 2026-10-14T16:29Z


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23515 → 1f3ac4f524 · 2026-10-14T17:27Z

    domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6171438684, released by this landing. Review: seat record 6173147350 on the PR (pointer on this card), and CONTRACT_REVIEW_TIER record 6173294974 (PASS on 6d399e780b, served by the default model per the maintainer's 「契约复审 opus就够了」).

    Reading Value
    PR merged: true, merged_at 2026-10-14T17:26Z, through the merge queue (added_to_merge_queue 2026-10-14T16:49Z)
    Queue branch gh-readonly-queue/main/pr-23515-* absent (git ls-remote, 0 refs)
    origin/main 1f3ac4f524 is an ancestor; one parent (222293f60a); 5 files, +313 / −11, as reviewed
    Closing keywords in the landed commit Fixes #23492 only
    Lane reconciliation 19 open domain:cli cards. Against the 15:00Z set: minus #23492 (this landing); plus #23517 (filed at this PR's ACCEPT, triage-graded into the lane)

    State change: #23492 closed completed by the merge; pm:dispatched removed in this act. #23517's order condition ("after PR #23515 lands") now holds. The contract reviewer's escalation stays in Acceptance notes, 「承接者:无」: the protocol's own guard still reads a blank token as "no token" for in-process callers, while decision #20 ①'s clause covers only an absent If-Match. No in-process producer was measured, so it has no reach:. It is surfaced to the maintainer in this seat's session chat.


    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