Skip to content

fix(rest,runtime)!: a /data PATCH or DELETE refuses a version token that can never carry a version, on both doors - #23515

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23492-empty-version-token
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23492-empty-version-token

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23492
Clause-②: no (narrowing)

Both doors of PATCH /data/:object/:id and DELETE /data/:object/:id now judge every optimistic-concurrency token a caller sends, or refuse it 400 VALIDATION_FAILED before the protocol is asked. The two doors are RestServer (packages/rest) and the runtime dispatcher's /data domain (packages/runtime), the only door on a createHonoApp host. A token that can never carry a version is no longer read as "no token", so it can no longer hide a stale If-Match. One rule lands in both doors together, and they stay the statement-for-statement mirrors that PR #23460 and PR #23490 made them.

Dispatched by the domain:cli seat 2 PM (claim 6171438684, branch claude/issue-23492-empty-version-token), session session_01Q9PZ5Pzqcus4woVvTZwWNU.

What was wrong (measured)

Both doors read bodyVersion ?? If-Match on PATCH (?expectedVersion ?? If-Match on DELETE), and forwarded the token only when it was truthy. Measured on the rig these pins extend, on origin/main 3a2adc8835: a real better-sqlite3 store, the real ObjectQL and protocol, RestServer's registered handler and the real dispatch() side by side, with the row read back after every request. The two doors answered identically in every row.

request before after
PATCH, body expectedVersion: '', stale If-Match 200, row written 400 VALIDATION_FAILED, row unchanged
PATCH, body '', no If-Match 200, written 400
PATCH, body false, stale If-Match 200, written 400
PATCH, body false, no If-Match 200, written 400
PATCH, body ' ' (blank), stale If-Match 200, written (forwarded; the protocol reads blank as no token) 400
PATCH, body [], stale If-Match 200, written (see note 1) 400
PATCH, body true 409, judged as 'true' 400
PATCH, body {} 409, judged as '[object Object]' 400
PATCH, body 0, with or without a stale If-Match 200, written (dropped) 409, judged as '0'
PATCH, empty If-Match, no body token 200, written 400
DELETE, ?expectedVersion=, stale If-Match 200, row deleted 400, row kept
DELETE, ?expectedVersion=, no If-Match 200, deleted 400
DELETE, ?expectedVersion= blank, stale If-Match 200, deleted 400
DELETE, empty If-Match, no query token 200, deleted 400
unchanged: body null, stale If-Match 409 409
unchanged: body 5; ?expectedVersion=0 409 409
unchanged: "" (body, query or header) the protocol's 400 the protocol's 400

Note 1: the [] row's "before" was read with this PR's refusal ablated. That leaves the forward test != null, which forwards [] exactly as the truthiness test did. String([]) is '', which the protocol reads as no token.

The rule

There is one function per door, identical in both: versionTokenRefusal(token, spelling), in rest-server.ts and in domains/data.ts. It sorts a token three ways:

  • Absent: undefined or null. The body key or query name falls through to If-Match, and no If-Match either is the legal unguarded write. This is unchanged.
  • Judged: a string with something in it, or a finite number. The door forwards String(token), and the forward test is now != null, not truthiness.
  • Refused 400 VALIDATION_FAILED: anything else: an empty or blank string, a boolean, an array, an object.

The body key is still stripped from the patch whatever its value. PR #23490's pin (no expectedVersion ever reaches the engine as a field) still holds.

Rulings applied, and the judgments this PR made

  • Ruled, not reopened: [finding] If-Match: "" silently DISABLES optimistic concurrency — a quoted-empty entity-tag is read as "no token" and the guarded write proceeds unguarded #13576's maintainer decision chore(ci)(deps): bump actions/configure-pages from 4 to 5 #20 ①, recorded at packages/metadata-protocol/src/protocol.ts (MalformedVersionTokenError). A token shape that can never carry a version is refused 400 VALIDATION_FAILED, neither read as "no token" nor answered 409. This PR applies that rule to the body and query spellings. "Fall through to If-Match" was not offered.
  • The empty If-Match (PM hypothesis (d)) is in scope, on both doors. Why:
    • An empty If-Match is not the absent header that the ruling keeps as an unguarded write. The caller asked for a guard and sent nothing to judge, the same shape as body expectedVersion: '' with no header, which the ruling refuses.
    • RFC 7232 does not admit an empty If-Match value at all, unlike "", which the platform already refuses.
    • No first-party producer sends one. @objectstack/client's data.update and data.delete, and objectui's ApiDataSource and MetadataClient, all omit the header for a falsy ifMatch (grep: if (opts?.ifMatch) / opts.ifMatch ? … : …).
    • Leaving it out would need a second rule ("an empty header is absent") beside the one this PR adds.
  • true, arrays and objects are refused too. false refused while true is answered 409 would be two answers for one type. [] stringifies to '' and wrote unguarded, the defect itself, one spelling over.
  • Blank strings are refused. The protocol reads a blank token as no token (its docblock, "per the ruling's clause ②"), so a blank body token beside a stale If-Match wrote unguarded. That is the same defect.
  • 0 is judged as '0', not refused. It is a number, and every other number was already judged. This leaves the declared z.string() unwidened and unenforced on numbers, which is not this card (see Acceptance notes).

How each door renders the refusal

Both doors build the refusal with the one constructor, validationFailure(sentence, []) from @objectstack/types, and render it with their own classifier. The two doors answer the same status, code and sentence; a parity pin holds them to it.

  • RestServer THROWS it into the route's own catch. mapDataError renders it as { error, code: 'VALIDATION_FAILED', fields: [], object }, the envelope that door already gives the protocol's "" refusal. A hand-built res.status(400).json(...) body was the first draft. check:route-envelope refused it (siblingCode: found 41, declared 39 — a NEW non-conforming body), so no body is hand-built here.
  • The dispatcher RETURNS deps.errorFromThrown(...). The body is { success: false, error: { code: 'VALIDATION_FAILED', message, httpStatus: 400, details: { fields: [] } } }.
  • On a createHonoApp host: measured through the real createHonoApp over the rebuilt runtime dist, with a recording protocol, in the same set-up as packages/qa/http-conformance's hono-*.conformance.test.ts. That measurement was a scratch test, not committed.

Tests

Pins extend PR #23490's and PR #23460's rig: 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 beside a stale If-Match, true, [] beside a stale If-Match, and an empty If-Match alone. Each asserts 400 VALIDATION_FAILED, updateData never asked, and the row unchanged.
  • Per door, DELETE: ?expectedVersion= beside a stale If-Match and beside no header, a blank ?expectedVersion, and an empty If-Match alone. Each asserts 400, deleteData never asked, and the row kept.
  • Per door, protocol parity: the door's refusal has the same status and code as the protocol's own refusal of "". That token is shown to reach the protocol.
  • Per door, controls: a null body token is no token (a stale If-Match beside it is 409). Body 0 is judged as '0' (409), beside an If-Match naming the current version.
  • Cross-door: each refused shape gets the same status, code and sentence on both doors.

Results:

  • 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: Test Files 2 passed (2), Tests 84 passed (84), at 6d399e780b.
  • pnpm --filter @objectstack/rest run test: Test Files 283 passed (283), Tests 5852 passed | 326 skipped (6178), at 6d399e780b.
  • pnpm --filter @objectstack/runtime run test: Test Files 380 passed (380), Tests 5617 passed | 19 skipped (5636), at b763612ee8. The one commit after it changes only how RestServer renders the refusal, and the two pin files above were re-run on it.
  • typecheck for @objectstack/rest and @objectstack/runtime passed at 6d399e780b: tsc --noEmit, then check:test-typecheck OK on both.

Ablations

Each ablation goes through node scripts/ablation-replace.mjs, with a restore trap and a restore proven by blob equal to HEAD and an empty git diff HEAD. Both doors' code under test resolves to source (@objectstack/rest is aliased to src in runtime's vitest config, and the dispatcher is a relative import), so no dist leg applies.

mutation anchor on disk (before / during / after) result
RestServer's refusal dropped (versionTokenRefusal returns undefined first), at 6d399e780b 1 / 0 / 1 27 red, 57 green. Every RestServer [#23492] refusal row is red (PATCH 9 of 11; the 2 green are the null and 0 controls; DELETE 5 of 5), the cross-door rows are red (8 + 4 + 1 [#23462] parity row), and every catch-all row stays green (11 + 5).
the dispatcher's refusal dropped, at b763612ee8 (data.ts unchanged since) 1 / 0 / 1 27 red, 57 green: the exact mirror. The catch-all rows are red, and every RestServer row is green.
RestServer's forward back to truthy (3 sites) 3 / 0 / 3 1 red: RestServer's 0 control.
the dispatcher's forward back to truthy (2 sites) 2 / 0 / 2 1 red: the catch-all's 0 control.

Gates

These were run locally on the final commit 6d399e780b:

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 65 commands, and all 65 exited 0. --ran (with exit codes) reports 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.
  • The four roster gates whose roster sits in one of this PR's directories also exited 0: check-changeset-fixed, check:error-code-casing, check:filter-alias-parity and check:route-ledger-census.
  • pnpm lint was run as a declared narrowing. The same binary and flags (eslint --no-inline-config) ran over the four changed .ts files and exited 0. As a positive control, the same invocation on two of those paths, through --stdin-filename with an injected restricted import, exited 1, so the paths are linted, not ignored. CI runs the whole tree.

Acceptance notes

None of these is filed by this PR; each finding goes to the seat in the report.

  • UpdateDataRequestSchema.expectedVersion is declared z.string(), yet both doors accept a body number and judge String(n). Measured: body 5 is answered 409 on both doors, not refused as off-spec. Refusing numbers is not this card.
  • The protocol's assertVersionTokenNotMalformed still reads '' and blank as no token. So an in-process caller of updateData or deleteData (not through either door) still writes unguarded with such a token. This is a read-only inference; no other public door was measured.
  • The spec's expectedVersion describe names only "" as refused. It does not say that an empty or blank token is refused at the doors. The text is not contradicted, but it is narrower than the behaviour.
  • ?expectedVersion as a query on PATCH is still read by neither door; the spec declares only the body field or If-Match there. This is unchanged and has no carrier.
  • The createHonoApp 500 for protocol-thrown refusals is 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.

Generated by Claude Code

…hat can never carry a version, on both doors

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
… never carry a version

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
…can never carry a version

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
…e envelope the protocol's own refusal gets

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 — 35 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 d1e284113759db34b9dabcd18b9c9da22b32cde9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1da891f6e033ce849e708c8d0e7d49f115928e9d — the merge of head 6d399e780b616c94abcb196c4efe423cd308c367 into base d1e284113759db34b9dabcd18b9c9da22b32cde9, 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 1da891f6e033ce849e708c8d0e7d49f115928e9d && git checkout 1da891f6e033ce849e708c8d0e7d49f115928e9d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d1e284113759db34b9dabcd18b9c9da22b32cde9 6d399e780b616c94abcb196c4efe423cd308c367 && git checkout -B drift-repro d1e284113759db34b9dabcd18b9c9da22b32cde9 && git merge --no-ff 6d399e780b616c94abcb196c4efe423cd308c367

node scripts/docs-audit/affected-docs.mjs --json d1e284113759db34b9dabcd18b9c9da22b32cde9

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

✅ ACCEPT: PR #23515 at 6d399e780b (#23492). It lands after one CONTRACT_REVIEW_TIER PASS and an all-green head · 2026-10-14T16:28Z

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

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

  • PR shape: draft, base main, title fix(rest,runtime)!:, assignee sunhaolin. Line 1 is Fixes #23492, the body's only closing keyword; line 2 is Clause-②: no (narrowing).
  • Scope: 5 files, +313 / −11, inside the claim's surface: the PATCH and DELETE token reads of both doors, the two pin files of PR fix(runtime)!: the dispatcher's DELETE /data/:object/:id honours ?expectedVersion and If-Match, as RestServer's twin does #23460's and PR fix(runtime)!: the dispatcher's PATCH /data/:object/:id honours a body expectedVersion and If-Match, as RestServer's twin does #23490's rig, and one changeset.
  • The rule, read in the diff: one function, versionTokenRefusal, written statement for statement in both doors.
  • Pins and reverse verification:
    • The two rig files gain the refused shapes per door: '', blank, false, true, [] and an empty If-Match on PATCH; ?expectedVersion=, blank and an empty If-Match on DELETE.
    • Each refused shape is pinned beside a stale If-Match and beside no header where it applies. Each asserts 400 VALIDATION_FAILED, the protocol not called, and the row read back unchanged.
    • Further pins: null and 0 controls, parity with the protocol's "", and a cross-door row per shape asserting the same status, code and sentence.
    • Before the fix (rig on 3a2adc8835), every refused shape answered 200 with the row written or deleted, on both doors. After the fix, 84 of 84 pass.
    • Four ablations, each restore proven against HEAD:
      • dropping each door's refusal turns 27 rows red, an exact mirror of the other door;
      • reverting each door's forward to truthy turns that door's 0 control red.
  • Changeset prose, sentence by sentence: @objectstack/rest minor and @objectstack/runtime minor, fix(rest,runtime)!:, Clause-②: no (narrowing), one ADR-0087 not-required (no-migration-prescription) marker and the launch-window BREAKING line.
  • Gates:
    • Local, at 6d399e780b: the dev ran 65 derived gates, 65 run and 0 unmeasured. check:route-envelope was red on a first draft (a hand-built body) and holds on the final head.
    • The full @objectstack/rest suite (283 files) and both packages' typechecks ran at 6d399e780b. The full runtime suite (380 files) ran at b763612ee8; the last commit touches only RestServer's rendering, and both pin files were re-run on it.
    • pnpm lint was taken as the proven narrowing, with a positive control.
    • Gate evidence: the check-runs pinned to 6d399e780b, read at this stamp. There are 37 runs: 22 success, 6 skipped, 9 still running, and none failed.
      • Every skip is on the expected-skip roster: Packed-tarball smoke, Console Pin Gate, Dogfood Regression Gate (${{ matrix.shard }}/3), Build Docs, Temporal Conformance, and Dogfood Verify CLI.
      • Landing reads them again.
  • Docs drift: none owed beyond the note below. The spec's describe line names only "" as refused, which is narrower than what the doors now refuse. That is noted, not owed by this PR.

Accepted deviations:

  • The refused set goes beyond the order's '' / false / 0. It adds true, objects and arrays, and blank strings.
    • The claim's rule is the class "a token shape that can never carry a version", and the spec declares the token a string. A boolean, an object and an array are each of that class.
    • true and {} were already refused, but as 409; they now answer 400. That is the distinction decision chore(ci)(deps): bump actions/configure-pages from 4 to 5 #20 ① chose: a client defect, not a lost race.
    • [] stringified to '' and wrote unguarded. That was this card's defect, under another shape.
    • 0 is judged like every other number, which fails toward 409: the safe direction the ruling keeps for a non-empty token.
  • An empty If-Match is included (hypothesis (d)). It is a header that was sent, not the absent header the ruling's unguarded clause keeps. RFC 7232 admits no empty value. No first-party client emits one: the SDK omits it for a falsy ifMatch. And excluding it would need a second rule. Both doors agree.
  • versionTokenRefusal is duplicated in both packages, not exported. One shared helper would need a new @objectstack/rest export, outside the claim. A cross-door parity pin holds the two equal.
  • The createHonoApp wire reading was a scratch run, deleted before commit, as PR fix(runtime)!: the dispatcher's DELETE /data/:object/:id honours ?expectedVersion and If-Match, as RestServer's twin does #23460 and PR fix(runtime)!: the dispatcher's PATCH /data/:object/:id honours a body expectedVersion and If-Match, as RestServer's twin does #23490 did.
  • pnpm lint as the proven narrowing, with a positive control. The full runtime suite at the previous commit is covered as above.

Open question, answered: should the protocol's assertVersionTokenNotMalformed also refuse a present-but-blank token for in-process callers? A: leave it. No in-process caller that forwards a raw client token was found or measured. Both HTTP doors now refuse first. Acceptance notes, 「承接者:无」. A seat that finds such a caller files it against packages/metadata-protocol.

Out-of-scope findings, one line each:

Landing: one CONTRACT_REVIEW_TIER record is owed, from an isolated subagent on the default model (the maintainer's 「契约复审 opus就够了」) reviewing 6d399e780b. 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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6d399e780b616c94abcb196c4efe423cd308c367
Local-runs: none

Inputs read:

I read diff context at the head through git show only.

① Derived judgments

Accept set, on both doors. The two doors are RestServer (packages/rest) and the dispatcher's /data domain (packages/runtime). Each has one module-private versionTokenRefusal, and the two copies are identical statement for statement (compared in the diff).

  1. PATCH with a body expectedVersion of '' or blank ' ', with or without a stale If-Match. Was 200, written unguarded. Now 400 VALIDATION_FAILED, and the protocol is never asked. Right.

    • Decision chore(ci)(deps): bump actions/configure-pages from 4 to 5 #20 ① is quoted verbatim in protocol.occ-empty-etag-rejected.test.ts. It refuses a token shape that "can never match any stored version", and keeps exactly two things unchanged: no If-Match at all, and a garbage-but-nonempty token failing toward 409. An empty body token that was actually sent is neither of those.
    • The card's acceptance offered "refuse" or "fall through to If-Match" and asked for one rule. This PR applies one rule.
  2. PATCH with a body false, true, object or array. Before:

    • false and [] were written unguarded with a 200.
    • true and {} got 409, judged as 'true' and '[object Object]'.
    • A one-element array was judged as its element.

    All of these are now 400. Right.

    • Each type gets one answer.
    • z.string() declares none of these types.
    • [] stringifies to '', so it is the same defect in another spelling.

    The changeset gives FROM → TO for the non-empty-array and object rows.

  3. PATCH with a body 0. Was dropped: written unguarded with a 200, even beside a stale or matching If-Match. Now judged as '0', which is 409 on a real record. Right. This is a narrowing, and the changeset discloses it.

    • Every other number was already judged.
    • The forward test is now != null on both doors: 3 sites in rest-server.ts (the UpdateDataRequestSchema.safeParse input among them) and 2 in data.ts.
  4. An empty If-Match with no body or query token, on PATCH and DELETE. Was 200, written or deleted unguarded. Now 400. Right.

    • The ruling keeps only an absent header as an unguarded write.
    • An empty value is not a valid RFC 7232 If-Match. Under RFC 9110 §13.1.1, a value that is neither * nor a matching tag evaluates false. No reading makes it an unguarded write.
    • Absence still reads as absence on both header shapes. requestIfMatch turns Headers.get's null into undefined, and the Hono adapter's c.req.header() record holds only headers that were sent.
    • @objectstack/client's data.update and data.delete set the header only when opts?.ifMatch is truthy (packages/client/src/index.ts:7147 and :7241). So no first-party producer in this repo sends an empty one.
    • I did not read objectui's producers: they are outside this review's inputs, and Console Pin Gate was skipped on this head.
  5. DELETE ?expectedVersion=, empty (alone or beside a stale If-Match) or blank. Was 200 with the row deleted. Now 400, and the row is kept. Right: the same rule.

    • The order is the same on both doors. The repeated-query arity gate (VALIDATION_ERROR) still answers first.
    • A one-element or empty array is unwrapped before the token is read: refuseRepeatedQueryParams does it in REST, rawVersion[0] in the dispatcher. So both doors judge the same token.
  6. Unchanged, and still right:

    • no token at all;
    • a body null, which falls through to If-Match (409 when stale);
    • non-blank strings and non-zero numbers, which are judged;
    • "" in any spelling, which gets the protocol's own 400;
    • the body key, which is still stripped from the patch whatever its value.
  7. Precedence. On both doors the refusal comes after authentication and before the API-access gate or callData.

  8. Rendering.

    • REST throws validationFailure(sentence, []) into the route's own catch. mapDataError then renders the flat { error, code, fields: [], object } envelope that the protocol's "" refusal already gets on this door.
    • The dispatcher returns deps.errorFromThrown(…, VALIDATION_FAILED_STATUS) instead of throwing, so a createHonoApp host renders 400, not its catch-all 500.
    • No new body shape: the route-envelope gate is in the green Lint lanes. Right.

Public surface: none changed. Right.

  • versionTokenRefusal is module-private in both files; it has no export.
  • No byte moves in packages/spec or packages/types.
  • Neither package adds, removes or narrows an export.
  • The dispatcher's new import is ../validation-failure.js, the package-internal re-export of @objectstack/types.
  • Copying the function instead of exporting it from @objectstack/rest is the narrower choice. An export would enlarge rest's public surface, which would make this Clause-②: yes. The cross-door rows pin the status, code and sentence as equal on both doors.

Pins. For each refused shape, on each door, the tests assert:

  • 400 VALIDATION_FAILED;
  • the protocol not asked;
  • the row read back unchanged or still present.

There is also a parity pin against the protocol's "" refusal, null and 0 controls, and one cross-door equality row per shape.

Non-blocking nits:

  • A plain-object body token ({}) is refused by the code and named in the changeset, but neither door pins it.
  • The refusal sentence calls a blank string "empty". The ruling asks that the message name the mechanism, and "can never carry a version" does.

② Semver level

The changeset .changeset/23492-empty-version-token-refused.md bumps '@objectstack/rest': minor and '@objectstack/runtime': minor. It matches.

  • These are the two packages whose published bytes change. Both are in the fixed group, which is in pre mode next at 18.0.0-next.1.
  • The diff narrows two released HTTP accept sets and widens nothing, so it is BREAKING.
  • The changeset says BREAKING in bold and ships it as minor under the launch-window convention. That is the same level and wording as the landed siblings 23462-dispatcher-patch-expected-version.md and 23265-dispatcher-delete-expected-version.md.
  • It gives FROM → TO per row and the one-line remedy: send the version you read, or omit both spellings.
  • It carries exactly one ADR-0087 marker, not-required (no-migration-prescription). That is right: no authorable key, spelling, stored shape or export moves, and the remedy is in the request.
  • Check Changeset and the Lint lanes are green on this head.
  • skip-changeset would have been wrong here, and it is not used.

The Clause-②: no (narrowing) line is right.

  • The PR body, the changeset and the claim carry the identical line.
  • Nothing that was refused is now accepted. Every shape that changes goes either from applied or 409 to 400, or from applied to judged.
  • The public surface does not grow.

③ Boundary flags

open_questions[0]: the protocol's assertVersionTokenNotMalformed still reads '' and blank as no token for an in-process updateData or deleteData caller.

  • Answered for this head: option A. packages/metadata-protocol is outside the claim, and both HTTP doors now refuse first. It does not block this PR.
  • Escalated to the seat. At the head, the protocol's docblock credits "''/whitespace keep opting out" to "the ruling's clause ②". The verbatim ruling's clause ② keeps only "no If-Match at all".
  • So after this PR the doors (citing ①) and the protocol (citing ②) hold two readings of one ruling for the same token. That needs a carrier: option B as its own card, or a maintainer word if the seat reads it as a ruling question. I do not decide it here.

Deviations:

Out-of-scope findings:

Implemented-by: claude/issue-23492-empty-version-token
Reviewed-by: session_01Q9PZ5Pzqcus4woVvTZwWNU

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 16:45
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 16:45
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 1f3ac4f Oct 14, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23492-empty-version-token branch October 14, 2026 17:26
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

2 participants