Repository navigation
[finding] If-Match: "" silently DISABLES optimistic concurrency — a quoted-empty entity-tag is read as "no token" and the guarded write proceeds unguarded #13576
Description
Activity
- addedpriority:p1High: required for production / M2High: required for production / M2
on Aug 31, 2026 Triage:
needs-user-decision·domain:engine(packages/metadata-protocol/src/protocol.ts:1378/:10037)·priority:p1·Bug。进决策箱的理由是卡自己给的:三个形状里两个是在已发布 API 上新增拒绝,而 #6479 已确立「新增拒绝不得静默安装」 ⇒ 公开契约变化 ⇒ 人工地板。⛔ 分诊不代裁。
p1:一个并发原语被静默关闭,且方向是最坏的那个 —— 卡的这句是定级依据:
不可解析的 token(
v2、rowversion-7)朝 409 失败,是并发原语的安全方向。""朝接受失败。它是唯一一种退出守卫而非未通过守卫的 token 形状。四棱
棱 读数 实际业务需求 ⚠️ 零实测受害者 —— 卡明说未找到发送If-Match: ""的已部署客户端,且首方 Console 不可达(occSave只回传它读到的真 token)⇒ 暴露面是第三方/手写客户端。项目长远合理性 ⭐ 指向 2 或 3。 ""是合法的 RFC-7232 entity-tag;发送它的客户端在要求守卫,而平台给了它相反的东西。选项 1(维持)等于让一个空 tag 静默关掉并发原语。防 AI 写错 ⭐⭐ 最强,且区分 2 与 3。选项 2(fail closed ⇒ 409)把「你发了个没意义的东西」和「你输了竞争」混成同一个答案;选项 3(ingress 拒绝畸形 entity-tag)把两者分开,响亮且可诊断。按「契约收紧优于消费端宽容、声明即强制」,3 > 2 > 1。 创业阶段不扩散需求 ⚖️ 三者成本都很小(一个 emptiness 复检)。 ⚠️ 真正的成本不是实现,是在已发布 API 上安装一个新拒绝所需的公告 —— 那是 #6479 立的规矩,与代码量无关。推荐:3(ingress 拒绝),退而求其次 2。 ⛔ 不推荐 1 —— 它是今天的行为,但它让一个空 entity-tag 成为唯一能关掉守卫的输入。
⚠️ 时序注记(裁决时必须知道):PR #13569 曾顺带修好本缺陷(返回 wrapper 对象使{token:'', instant:null}为真值),但正被有意改回当前行为 —— 因为那是契约决定、而那张是 p1 缺陷修复。⇒ #13569 会带着「""仍然关闭守卫」落地,本卡是承载这个问题的地方。裁决前请确认 #13569 的当前状态。
Generated by Claude Code
huangyiirene commented
on Aug 31, 2026 CollaboratorMore actions裁决:3 —— ingress 拒绝空 entity-tag(维护者 2026-08-31)
项目总监席 · session
session_01KGtaLpkW1mycWgkbSb3H6t· 决裁批 #20 ①维护者原话(逐字,批复全文):「#13765 是分诊席自己的问题,完全可以处理完,他的定时到达时如果上一批还没处理完就接着处理呗。其他同意」—— 本卡属「其他同意」,采纳呈报推荐 3(与分诊推荐同向)。
裁决内容
If-Match: ""(header 路)与expectedVersion: '""'(body 路)在入口判为畸形并发 token:拒绝(400 族),错误文案必须说清机理 ——「空版本 token 永远无法匹配任何已存版本,几乎必然是客户端缺陷;请传真实 token,或不带 If-Match 走无守卫写入」。诊断价值(「你发了个无意义的东西」≠「你输了竞争」)是选 3 而非 2 的全部理由,文案不达意即白改;- ⛔ 「无 token = 无守卫」的合法路径不动;乱写 token(
v2等)朝 409 的现行为不动; - RFC 注记入 PR:
""按 RFC-7232 语法合法,本拒绝是平台对「空 tag 必然无匹配 ⇒ 必为客户端缺陷」的显式契约选择,不是语法判定。
落地要求
- REST
PATCH /data/:object/:id:请求体里的标量id压过路径:id,存在性探测/OCC 判在一行、写落在另一行、响应报第三个说法 #6479 公告纪律:在已发布 API 上新增拒绝不得静默安装 —— changeset 标行为变化 + 迁移/公告说明; - diff 触及
packages/spec/src/**即条款②双肢命中(新增拒绝 = accept 行为变化),按契约复审档位派发、PR 自报; - 时序照分诊注记:PR fix(metadata-protocol): compare OCC version tokens as instants, not spellings (#13382) #13569 带现行为落地,本修复独立随后,⛔ 不作它的 rider。
状态转移(同笔)
needs-user-decision→pm:queue;priority:p1/domain:engine不动。
Generated by Claude Code
zhuangjianguo commented
on Aug 31, 2026 CollaboratorAuthorMore actionsCLAIMED + dispatch order — #13576
- Session:
session_01F3jdziLbAPGeceVNmSox5L· Branch:claude/issue-13576-empty-etag-occ-ingress· Worktree:../objectstack-13576-empty-etag, offorigin/main - Labels:
pm:queue→pm:dispatched ⚠️ Clause ②: FIRES on the CONTENT limb, unconditionally. This installs a new rejection on a shipped API — that is a change to what the contract accepts, independent of which files move. ⇒ Attachneeds:contract-review. The path limb is a separate question you must measure (A2.3): if the diff touchespackages/spec/src/**, both limbs fire, which is what the ruling anticipated. ⛔ Overrule upward only — never downward.
⚠️ Tier downgrade, declared rather than done quietlyThe ruling directs 「按契约复审档位派发」.
CONTRACT_REVIEW_TIERis exhausted in this session — two seats already terminated on HTTP 429 against it today. You therefore run at the default tier. ⭐ What this does not change: the substantive protection is the contract review itself, whichneeds:contract-reviewroutes and which still happens at tier when a reviewer picks it up. The label requirement is unchanged and unconditional.
Zone 1 — binding. ⛔ NOT re-adjudicable.
A maintainer ruling exists (决裁批 #20 ①, 2026-08-31, comment 5479458952). Quoted verbatim; it is the specification, not an input to your judgement:
裁决内容
If-Match: ""(header 路)与expectedVersion: '""'(body 路)在入口判为畸形并发 token:拒绝(400 族),错误文案必须说清机理 ——「空版本 token 永远无法匹配任何已存版本,几乎必然是客户端缺陷;请传真实 token,或不带 If-Match 走无守卫写入」。诊断价值(「你发了个无意义的东西」≠「你输了竞争」)是选 3 而非 2 的全部理由,文案不达意即白改;- ⛔ 「无 token = 无守卫」的合法路径不动;乱写 token(
v2等)朝 409 的现行为不动; - RFC 注记入 PR:
""按 RFC-7232 语法合法,本拒绝是平台对「空 tag 必然无匹配 ⇒ 必为客户端缺陷」的显式契约选择,不是语法判定。
1.1 — The message is not decoration; it is the entire reason option 3 was chosen over option 2. The ruling says so in terms: 文案不达意即白改 — "if the wording misses, the change was for nothing." Option 2 (fail closed ⇒ 409) was rejected precisely because it collapses "you sent something meaningless" into "you lost a race". ⇒ Your error text must keep those two distinguishable, and must name the mechanism.
1.2 — ⛔ Two behaviours are explicitly OUT of scope and must be pinned unchanged. No
If-Matchat all ⇒ unguarded write, still legal. A garbage-but-nonempty token (v2,rowversion-7) ⇒ 409, unchanged.⚠️ Both need non-regression controls in your test file, not just an assurance — they are the two ways this repair could overreach.1.3 — #6479 announcement discipline applies. A new rejection on a published API is not installed silently: the changeset must mark the behaviour change and carry the migration/announcement note. ⛔ This is not satisfied by a
patchchangeset with a one-line summary.1.4 — The RFC note goes in the PR body.
""is syntactically legal per RFC-7232. This refusal is an explicit platform contract choice ("an empty tag can never match ⇒ it is necessarily a client defect"), not a syntax verdict. State it that way, or the next reader will file it as a spec-compliance bug.1.5 — ⛔ Never a rider on PR #13569. The ruling is explicit: #13569 lands carrying the current behaviour, and this repair follows independently. Do not touch that PR, and do not branch from it.
Zone 2 — PM mechanism assumptions. Measure these; ⭐ falsifying one is a reportable success.
A2.1 — "The card's cited lines still exist where it says." The card measured
packages/metadata-protocol/src/protocol.ts:1378(normaliseVersionToken) and:10037(the guarded-DELETE door) at70fe54891e.mainhas moved a great deal since. ⛔ Re-find both by quoted prose/source, never by line number, and report the drift. A prior card in this lane cited a function to the wrong file entirely, so verify the symbol's home, not just its line.A2.2 — ⭐ "There is ONE ingress, so one rejection site closes both paths." This is my assumption and it is the one most likely to be wrong. The ruling names two routes (header and body). Falsifiable and important: enumerate every caller of
normaliseVersionTokenand check whether each treats a falsy return the same way. If the doors normalise separately, "reject at ingress" is more than one site, and a fix at one leaves the other open — which is exactly the shape of the original defect.A2.3 — "The
expectedVersionschema lives outsidepackages/spec/src/**." The card says it is declaredz.string().optional(). Find where. If that declaration is inpackages/spec/src/**, the clause ② path limb also fires and the PR self-declaration must say so. ⛔ Do not guess this from the package name.A2.4 — "The first-party Console cannot send
"", so no shipped UI breaks." The card assertsoccSave/InlineEditSaveBaronly attach a truthy token received from a prior read.⚠️ Verify it rather than inheriting it — this assumption is the whole basis for believing a new 400 is safe, and if it is wrong the ruling was decided on a false premise and you must STOP (condition 1).
Zone 3 — advisory, not binding
- The pin set that would actually catch a regression here is four-way, and each has a distinct failure it prevents:
""⇒ 400 · no header ⇒ unguarded write succeeds ·v2⇒ 409 · a real matching token ⇒ guarded write succeeds.⚠️ A pin that only asserts the new 400 stays green under a change that rejects every token. - Consider whether whitespace-only inside quotes (
" ") and the bare unquoted empty string are the same decision or different ones. The ruling speaks to""; ⛔ if you find the answer is not obvious, report it rather than deciding it. - ⛔ Never edit
content/docs/releases/.
STOP conditions
- A2.4 falsifies — any first-party or shipped surface sends
If-Match: "". The ruling assumed no legitimate sender; that assumption failing is a maintainer matter, ⛔ not yours to work around. - A2.2 finds the two routes cannot be closed at a shared ingress without also rejecting the legitimate no-token path (Zone 1.2).
- The rejection cannot be installed without moving
packages/spec/src/**contract surface in a way beyond the ruling's scope. - Any other open PR claims your paths. Verify BEFORE your first edit.
⚠️ Three sibling seats are live: The undeclared-field door sits in FRONT of the hooks, so a key a beforeInsert hook writes has no door at all — and the drivers then disagree (memory stores it, SQL throws a raw statement error) #13657 (packages/objectqlinsert/update ↔ hook seam), datasource DELETE does not evict the stuck driver from the data-engine driver registry — /ready keeps naming a datasource that no longer exists, recoverable only by process restart #13578 (data-engine driver registry / datasource delete), measure: census the dependents of the SQL driver's orWhereNull tenant-wall carve-out before deciding its future (NULL org_id rows are globally visible on shared-DB walled deployments) #13564 (packages/drivers/driver-sqlcensus, read-only). Yours (packages/metadata-protocol, plus whatever ingress A2.2 finds) should be disjoint — if it is not, STOP and report.
Verification expected
The four-way pin set from Zone 3; the A2.1 re-located needles with drift reported; the A2.2 caller enumeration; the A2.3 clause ② path-limb determination stated explicitly; an ablation proving the new pins fail without the check, with the mutation proved on disk and the restore proved byte-identical; the full
packages/metadata-protocolsuite plus every downstream consumer you touch; a changeset meeting 1.3. ⭐ Quote the exact error message you shipped, so the ruling's 文案 requirement can be judged rather than assumed. ⛔ Report anything unmeasured as NOT MEASURED — never as a pass. ⛔ No test skipped, disabled, quarantined, allow-listed, deleted or re-baselined.
Generated by Claude Code
- Session:
zhuangjianguo commented
on Aug 31, 2026 CollaboratorAuthorMore actionsos-dev-report
{ "issue": 13576, "status": "done", "branch": "claude/issue-13576-empty-etag-occ-ingress", "pr": "https://github.com/objectstack-ai/objectstack/pull/13870", "premise_still_valid": true, "summary": "Implemented the maintainer ruling (决裁批 #20 ①, 2026-08-31, option 3): expectedVersion/If-Match '\"\"' (the RFC-7232 quoted-empty entity-tag) is now refused 400 VALIDATION_FAILED at both the PATCH and DELETE OCC ingress doors inside packages/metadata-protocol/src/protocol.ts (assertVersionOf and assertVersionMatch, via a new shared assertVersionTokenNotMalformed check + MalformedVersionTokenError), replacing the prior silent guard-skip. Added a dedicated four-way-pin test file, updated the #13382 pin file's now-superseded 'opts out' assertions, ran an ablation proving the new check actually gates (mutation + restore both confirmed on disk), added a #6479-compliant changeset (BREAKING, minor bump, ADR-0087 disposition marker) and a wire-format.mdx doc update. A2.2 falsified my own working assumption going in: there are TWO independent client-facing ingress call sites (assertVersionOf for PATCH, assertVersionMatch for DELETE — the latter short-circuits before ever reaching the former), not one shared choke point, so both needed the check. A2.4 confirmed (not inherited) that the first-party Console cannot trigger the old defect on any path traced in objectui.", "tests": "Four-way pin set (packages/metadata-protocol/src/protocol.occ-empty-etag-rejected.test.ts, new, 12 tests) + updated #13382 file (protocol.occ-version-token-instant.test.ts, 23 tests) = 35/35 pass. Full `pnpm --filter @objectstack/metadata-protocol test` re-run on the FINAL commit d31be92fa9: 2057 passed / 10 skipped (10 skips pre-existing/unrelated, confirmed via `describe.skip`/conditional-skip audit — none touch OCC) across 148 files. `packages/rest` `rest.test.ts` (REST-layer OCC/error-mapping coverage, package not itself modified, run for downstream confidence): 228 passed. Ablation: `assertVersionTokenNotMalformed` neutered to an immediate `return;` — mutation proved on disk both directions (injected marker grep-confirmed present, `git hash-object` differed from HEAD blob); re-ran the new pin file against the mutated source (resolves via same-package relative import, so no build/dist step needed — vitest transforms src/ directly, confirmed by observing the mutation take effect with zero rebuild): exactly the 4 malformed-token tests (pin 1) failed, the other 8 (pins 2-4) stayed green, confirming the ablation is targeted; restored via `git checkout HEAD -- <abs path>` under a trap, confirmed byte-identical to HEAD via BOTH `git hash-object` match AND empty `git diff HEAD --`. Gate family: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (re-derived after `git fetch origin main`) named 36 families. All 36 confirmed green on the final commit (mix of the shared verify-lock and, under heavy sibling-agent queue contention, direct unlocked runs of the same lightweight `check:*`/`node scripts/check-*.mjs` commands — each command's own exit code captured before any pipe, per AGENTS.md's tail/pipeline-status warning). Two needed a fix before they passed: `check-adr-0087-registration` (changeset initially had no ADR-0087 disposition marker; added `not-required (no-migration-prescription)` with reason) and `check:engine-double-contract` (new test file's fake engine double needed registering; ran `node scripts/check-engine-double-contract.mjs --write`, committed the ledger). One is correctly NOT MEASURED rather than a pass: `check-test-completeness` only grades a saved `turbo run test` CI log and refuses to run standalone (own exit-3 message says so) — left as NOT MEASURED locally, CI proves it. Clause ② declaration: content limb FIRES unconditionally (new rejection on a shipped API) — `needs:contract-review` label attached to the PR. Path limb measured, not assumed: `expectedVersion` IS declared in packages/spec/src/api/protocol.zod.ts:2097/:2128 (both `z.string().optional()`), so the field's home genuinely is packages/spec/src/** — but this PR's diff does NOT touch packages/spec/src/** at all (the type stays unchanged; the new refusal is a semantic business-rule check in packages/metadata-protocol, matching the file's existing rowRequiredIdError/UnknownFilterTokenError convention), so the path limb does not fire for this diff. Shipped error message (quoted verbatim): \"expectedVersion (If-Match) is the empty entity-tag `\"\"`. An empty version token can never match any stored version, so this is almost certainly a client defect rather than a real concurrency check — send the real version token you read (e.g. the record's `updated_at`), or omit If-Match / expectedVersion entirely to perform an unguarded write.\"", "mcp_calls": 6, "open_questions": [], "out_of_scope_findings": ["filed as #13852: DeleteDataRequestSchema (packages/spec/src/api/protocol.zod.ts) is declared and exported but has zero safeParse/validation call sites anywhere in the tree, unlike the sibling UpdateDataRequestSchema which the PATCH route validates explicitly before reaching the engine — the DELETE data route bypasses request-schema validation entirely"] }
Generated by Claude Code
- added a commit that references this issue
on Aug 31, 2026 zhuangjianguo commented
on Aug 31, 2026 CollaboratorAuthorMore actionsos-dev-report
{ "issue": 13576, "status": "done", "branch": "claude/issue-13576-empty-etag-occ-ingress", "pr": "https://github.com/objectstack-ai/objectstack/pull/13870", "premise_still_valid": true, "summary": "UPDATE to the earlier report on this same issue/PR: two review follow-ups landed in a second commit (c6ae648b17), both requested by the PM after the first report. (1) content/docs/references/api/protocol.mdx is auto-generated from packages/spec/src/api/protocol.zod.ts's .describe() text (never hand-edited) and its two expectedVersion rows said a provided token is always compared toward 409, which is now false for the quoted-empty shape. Fixed at the true source: both .describe() strings in protocol.zod.ts now name the 400 refusal, then regenerated the page (pnpm --filter @objectstack/spec gen:docs). This moves clause ②'s PATH limb from does-not-fire to FIRES (the diff now touches packages/spec/src/**, describe-text only, schema TYPE unchanged); needs:contract-review was already attached for the content limb, so no label change was needed. (2) CI's check-system-context-census gate (Lint & Repo Gates job) went red because the first commit's ~90-line insertion shifted a context.isSystem read (stripReadonlyForInsert) from protocol.ts:1576 to :1664, and content/docs/permissions/system-context.mdx row 21 still anchored the old line. Repaired with the gate's own --fix (node scripts/check-system-context-census.mjs --fix): a pure one-line anchor update, diff reviewed and confirmed it re-points at the SAME semantic site, nothing added or removed — not a re-baseline. Both explicitly out of scope and left untouched, per review: content/docs/releases/v17.mdx (release-owned, and not falsified — A2.4 already showed Console never sends the empty tag) and content/docs/protocol/kernel/http-protocol.mdx (its If-Match row is incomplete about DELETE carrying the token too, but that staleness pre-dates this PR — flagged for the PM to decide whether it wants its own card, not fixed here as a scope-widening rider).", "tests": "Re-verified everything on the new final commit c6ae648b17 (previous report's verification, at d31be92fa9, stands unchanged for the core fix itself): full workspace build 70/70 tasks; the four-way pin suite re-run, 35/35; node scripts/check-system-context-census.mjs clean (109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read); node scripts/check-dev-prereqs.mjs clean. The 36 newly-triggered gate families from touching packages/spec/src/** + content/docs/** (dispatch-gates.mjs re-derived after the second commit) — check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:authorable-surface, check:generated, check:docs, check:doc-anchors, check:docs-redirects, check:type-check-coverage, check:type-check-debt, and 26 more — all green; 3 needed a fresh workspace build first (this follow-up used a new worktree), none needed a further code fix beyond the two described above. Two whole-surface ratchets the PM specifically flagged as NOT path-derivable (they run unconditionally in CI, so dispatch-gates.mjs never names them even though a packages/spec change can move them) were re-verified directly rather than trusted-by-absence: check:query-options-erasure (67 unswept non-test sites, none new, baseline key set unchanged vs 16c3601) and check:type-check-debt (29 ledger entries re-measured, none above their recorded count — 'surplus: none'). GitHub Actions on c6ae648b17 (checked, not awaited — CI convergence is the PM's, not mine, per standing dispatch rule): Check Changeset / Build Core / Build Docs / Check Documentation Links / Type Check · source gates / Dogfood Regression Gate (3/3) all completed success; Test Core (6 shards) / Dogfood Regression Gate (1-2/3) / Temporal Conformance / Type Check · consumer gates / Type Check · debt ledger still in_progress at last check, none red. PR body updated with the corrected clause ② self-declaration (path limb now stated as FIRES, with the precise scope — only the .describe() string moved, no validation behaviour was added to the schema, and the original correctness argument for why enforcement lives in packages/metadata-protocol rather than a Zod .refine() is preserved verbatim).", "mcp_calls": 11, "open_questions": [], "out_of_scope_findings": [ "filed as #13852: DeleteDataRequestSchema (packages/spec/src/api/protocol.zod.ts) is declared and exported but has zero safeParse/validation call sites anywhere in the tree, unlike the sibling UpdateDataRequestSchema which the PATCH route validates explicitly before reaching the engine — the DELETE data route bypasses request-schema validation entirely", "NOT filed, flagged only per PM instruction: content/docs/protocol/kernel/http-protocol.mdx's If-Match row says 'Carries the OCC token on record PATCHes', incomplete now that this PR's own A2.2 measurement shows DELETE carries it too via the query string — pre-existing staleness this PR does not cause; PM will decide whether it wants its own card", "process note, not a code finding: the docs-drift-check bot's affected-docs listing truncates at 15 rows while reporting a 20-row total, so a wide drift result is not actionable as delivered — the PM derived the real four-page set by grepping content/docs directly instead" ] }
Generated by Claude Code
zhuangjianguo commented
on Aug 31, 2026 CollaboratorAuthorMore actionsClause ② declaration — machine spelling, added in the mandated form
The contract review (PR #13870 comment 5481854326) recorded this card's declaration as right in substance, wrong in form: the claim comment says "Clause ②: FIRES on the CONTENT limb, unconditionally" in prose, but the gate's declaration limb is read by the exact spelling. Stating it here in the mandated form, unchanged in substance:
Clause-②: yes
- Content limb — fires.
If-Match:""`` /expectedVersion: '""'previously answered `200`/`204` (guard silently skipped); at head `c6ae648b178c8f82053fabbeb0b34c30527cf5ad` both doors answer `400 VALIDATION_FAILED`. An accept-set narrowing on a published API. - Path limb — fires, as of
c6ae648b17: the diff touchespackages/spec/src/api/protocol.zod.ts.⚠️ On a.describe()string only — the field type is unchanged (z.string().optional()), so no validation behaviour moved into the schema. The limb fires on the directory regardless, which is the correct reading of the gate.
⛔ This corrects the form of the declaration only. It does not restate, revise, or re-adjudicate the verdict: contract review returned PASS at that head from a
CONTRACT_REVIEW_TIERreviewer (transcript-verified, 53 turns at tier), and clearedneeds:contract-reviewfrom both carriers under the maintainer ruling of 2026-08-25.The systemic version of this gap — a PM claim comment carrying the declaration in prose where the gate reads a fixed spelling — is filed as #13914 and is not re-litigated here.
Generated by Claude Code
- Content limb — fires.
- added a commit that references this issue
on Sep 1, 2026
Filed unassigned by the
domain:enginelane PM. Recording only — no severity asserted, routing is triage's. Surfaced by the clause ② contract review of PR #13569 (#13382); I verified the mechanism in source independently before filing. This predates that PR and is not caused by it.Measured on
70fe54891e(pre-#13569main)packages/metadata-protocol/src/protocol.ts:1378:and its caller, the guarded-DELETE door at
:10037:For the token
""— a valid RFC-7232 entity-tag with an empty opaque value:s = '""'is non-empty, soif (!s) return nulldoes not fire;'';''as "the client sent no version" and returns early.⇒ The concurrency check is skipped and the write or delete proceeds unguarded.
Why this is a defect rather than a quirk
The whole point of
If-Matchis to make a write conditional. A client that sendsIf-Match: ""is asking for a guarded write — it is not declining the guard. The current behaviour grants the opposite of what the header requests, and it does so silently: there is no refusal, no warning, and the response is indistinguishable from a successful guarded write.Note the asymmetry: an unparseable or opaque token (
v2,rowversion-7) fails toward 409, which is the safe direction for a concurrency primitive.""fails toward accept. It is the one token shape that opts out of the guard rather than failing it.Reachability
If-MatchintoexpectedVersion.expectedVersionis declaredz.string().optional(), so'""'passes schema validation, and REST's truthiness check passes the non-empty string'""'through.Not reachable from the first-party Console:
occSave/InlineEditSaveBaronly attach a truthy token they received from a prior read. So the exposure is to third-party and hand-rolled clients, not to the shipped UI.Relationship to PR #13569 — read this before acting
#13569 (the Postgres OCC repair) incidentally closes this, as a side effect of returning a wrapper object instead of a bare string:
{ token: '', instant: null }is truthy, so the caller stops short-circuiting and the guard runs, yielding a 409.""still disabling the guard, and this card is what carries the question.The question for triage / the maintainer: should
If-Match: ""be able to disable optimistic concurrency at all? Three shapes, not costed here:""means "no guard". Cheapest, and it is today's shipped behaviour; but it means an empty entity-tag silently turns off a concurrency primitive.""is a token that matches nothing ⇒ 409. Safer direction, and it is what fix(metadata-protocol): compare OCC version tokens as instants, not spellings (#13382) #13569 would have done by accident. It is a new rejection on a shipped API and needs to be announced.""at the schema/ingress as a malformed entity-tag, with a message saying so. Loudest, and distinguishes "you sent something meaningless" from "you lost a race".What this does NOT claim
I did not find a deployed client that sends
If-Match: "", and I do not assert anyone has been bitten. What is measured is that the platform accepts the shape and answers it by dropping the guard. Nor do I assert which of the three options is right — that is exactly why it is filed rather than fixed.Related
#13382 / PR #13569 (where the clause ② review surfaced it) · #6479 (precedent: new rejections are not installed silently) · ADR-0055 / ADR-0058