Repository navigation
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
Description
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/honocreateHonoApp, and separately a rig overRestServer's registeredPATCHhandler. - The request: a body
{ name, expectedVersion: '' }with a staleIf-Match. - The result:
200, and the row was written, on both. A bodyexpectedVersion: falsebehaves 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:
RestServer'sPATCHinpackages/rest/src/rest-server.ts(about:9021-:9024, read onmain0d3ea295d1);- the dispatcher's
PATCHbranch inpackages/runtime/src/domains/data.ts, which PR fix(runtime)!: the dispatcher's PATCH /data/:object/:id honours a body expectedVersion and If-Match, as RestServer's twin does #23490 makes REST's mirror, on purpose, so the two doors stay alike.
What happens
Both doors compute bodyVersion ?? If-Match and then forward a token only when it is truthy.
''andfalseare not nullish, soIf-Matchis never read.- They are falsy, so no token is forwarded.
- So the update runs unguarded beside the stale
If-Matchthe 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)
- A request that carries a version token in any spelling is either judged or refused. It is never applied unguarded. For example, a present but empty or non-string-or-number body
expectedVersionis refused400, as the protocol refuses"". Or it falls through toIf-Match. Pick one rule; ⛔ not two. - One fix lands in both doors together, keeping them alike, as PR fix(runtime)!: the dispatcher's PATCH /data/:object/:id honours a body expectedVersion and If-Match, as RestServer's twin does #23490 does.
- Pins on both doors:
''andfalseeach beside a staleIf-Match, and beside no header. - Order: after PR fix(runtime)!: the dispatcher's PATCH /data/:object/:id honours a body expectedVersion and If-Match, as RestServer's twin does #23490 lands. It edits the same
data.tsbranch.
Noted beside it, not filed
These are from the same report, in PR #23490's Acceptance notes, carrier none:
?expectedVersionas a query onPATCHis read by neither door. The spec declares thePATCHtoken only as the body field orIf-Match.- The declared-endpoint door (
endpoint-executor.ts,object_operationupdate) 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
objectstack-fleet commentedon Oct 14, 2026
Triage: first grade ·
bug·priority:p1+target:v18·domain:cli·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T13:58ZRead fresh. Both doors compute
bodyVersion ?? If-Matchand forward only a truthy token, soexpectedVersion: ''orfalsehides a staleIf-Match, and the update is applied. This is #13576's class on the body spelling.target:label, the same grade as runtime: the dispatcher'sPATCH /data/:object/:iddropsIf-Match(a stale update is applied) and refuses a bodyexpectedVersionas an unknown field, where REST refuses the stale update 409 #23462 and runtime: the dispatcher'sDELETE /data/:object/:iddropsexpectedVersion/If-Match, so a stale delete is applied where REST refuses it #23265 in this family.domain:cli(packages/restandpackages/runtime, kept as mirrors on purpose).findingleaves on grading.objectstack-fleet commentedon Oct 14, 2026
Claim: PM loop round 5, serial (#23492: a version token sent empty or falsy is refused
400 VALIDATION_FAILEDon both/datadoors, never dropped beside a staleIf-Match) · 2026-10-14T15:03ZSession:
session_01Q9PZ5Pzqcus4woVvTZwWNUAccount:
sunhaolin(the seat's linked user asGET /useranswers it; the card's assignee from this act)Branch:
claude/issue-23492-empty-version-tokenWorktree:
objectstack-issue-23492Domain:
domain:cliSeat:
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/main3a2adc8835; stop on a breach and explain it in the report):packages/rest/src/rest-server.ts, ONLY the version-token reads ofRestServer'sPATCH /data/:object/:id(about:9058-:9078:bodyVersion ?? ifMatchHeader, then the truthy forward) andDELETE /data/:object/:id(about:9141-:9150:queryVersion ?? ifMatchHeader, then the truthy forward).packages/runtime/src/domains/data.ts, ONLY the same two reads in the dispatcher'sPATCH(:176-:188) andDELETE(:215-:227) branches. PR fix(runtime)!: the dispatcher's PATCH /data/:object/:id honours a body expectedVersion and If-Match, as RestServer's twin does #23490 and PR fix(runtime)!: the dispatcher's DELETE /data/:object/:id honours ?expectedVersion and If-Match, as RestServer's twin does #23460 made themRestServer's mirrors on purpose; they stay mirrors.packages/runtime/src/domains/data-{update,delete}-expected-version.test.ts, extended, or a sibling in the same rig), and one.changeset/23492-*.md.packages/metadata-protocol(its#13576guard andnormaliseVersionToken), notpackages/spec, notpackages/adapters/hono, notaction-execution.ts.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"). CeilingCONTRACT_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).400 VALIDATION_FAILED.409.UpdateDataRequestSchema.expectedVersion,protocol.zod.ts:2458-:2462).Responsibility:
the four token reads in packages/rest and packages/runtime (this lane) | the protocol's #13576 guard, which never sees a token the door drops; read, not changed | every /data update or delete that sends an empty or falsy body or query token beside If-MatchThread-read: 6170108252
Serial constraints cleared:
rest-server.ts, but only at about:79,:301,:1852and:9619-:9717of its base, inRestServer's meta reads. None of that is the/dataPATCHorDELETEhandler (about 500 lines away). Region-disjoint; the queue arbitrates any textual conflict.650933ec76.Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
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"
]
}
objectstack-fleet commentedon Oct 14, 2026
ACCEPT → PR #23515 comment 6173147350 · 2026-10-14T16:29Z
Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
Landed: PR #23515 →
1f3ac4f524· 2026-10-14T17:27Zdomain:cliseat 2 (#22648) ·sunhaolin· sessionsession_01Q9PZ5Pzqcus4woVvTZwWNU· holder of claim6171438684, released by this landing. Review: seat record6173147350on the PR (pointer on this card), andCONTRACT_REVIEW_TIERrecord6173294974(PASS on6d399e780b, served by the default model per the maintainer's 「契约复审 opus就够了」).merged: true,merged_at2026-10-14T17:26Z, through the merge queue (added_to_merge_queue2026-10-14T16:49Z)gh-readonly-queue/main/pr-23515-*git ls-remote, 0 refs)origin/main1f3ac4f524is an ancestor; one parent (222293f60a); 5 files, +313 / −11, as reviewedFixes #23492onlydomain:clicards. 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
completedby the merge;pm:dispatchedremoved 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 absentIf-Match. No in-process producer was measured, so it has noreach:. It is surfaced to the maintainer in this seat's session chat.Generated by Claude Code