|
| 1 | +--- |
| 2 | +"@objectstack/metadata-protocol": minor |
| 3 | +--- |
| 4 | + |
| 5 | +fix(metadata-protocol): refuse the quoted-empty `If-Match` entity-tag instead of silently disabling optimistic concurrency (#13576) |
| 6 | + |
| 7 | +**BREAKING** accept-set narrowing at the guarded-write door, shipped as |
| 8 | +`minor` under the repo's launch-window convention for breaking changes. |
| 9 | + |
| 10 | +`If-Match: ""` — a syntactically legal RFC-7232 entity-tag with an EMPTY |
| 11 | +opaque value — was silently accepted as "no version token supplied", which |
| 12 | +**skipped the optimistic-concurrency guard entirely** on both `PATCH |
| 13 | +/data/:object/:id` (via `If-Match` or the body's `expectedVersion` field) and |
| 14 | +`DELETE /data/:object/:id` (via `If-Match` or the query's `expectedVersion`). |
| 15 | +`normaliseVersionToken` strips the RFC-7232 quotes off the token and only |
| 16 | +*then* checks emptiness, so `'""'` (2 chars, non-empty) passed every upstream |
| 17 | +truthiness gate only to normalise to `''` one layer down — the exact falsy |
| 18 | +value every caller's own `if (!token) return` reads as "the client sent |
| 19 | +nothing". It was the one token shape that opted OUT of the guard instead of |
| 20 | +failing it: a garbage-but-nonempty token (`v2`) has always failed *toward* |
| 21 | +`409 CONCURRENT_UPDATE`, the safe direction for a concurrency primitive — |
| 22 | +`""` failed toward silent, unguarded acceptance instead. |
| 23 | + |
| 24 | +**What changes.** Both doors now refuse `expectedVersion`/`If-Match: ""` at |
| 25 | +ingress with `400 VALIDATION_FAILED`: |
| 26 | + |
| 27 | +> expectedVersion (If-Match) is the empty entity-tag `""`. An empty version |
| 28 | +> token can never match any stored version, so this is almost certainly a |
| 29 | +> client defect rather than a real concurrency check — send the real version |
| 30 | +> token you read (e.g. the record's `updated_at`), or omit If-Match / |
| 31 | +> expectedVersion entirely to perform an unguarded write. |
| 32 | +
|
| 33 | +**What does NOT change** (both explicitly pinned as regression controls): |
| 34 | +omitting `If-Match`/`expectedVersion` entirely is still a legal **unguarded** |
| 35 | +write (opt-in semantics, unaffected) — including a bare unquoted empty string |
| 36 | +or whitespace-only value, which is not the malformed shape and stays |
| 37 | +opted-out; and a garbage-but-nonempty token (`v2`) still fails toward `409 |
| 38 | +CONCURRENT_UPDATE`, unchanged. |
| 39 | + |
| 40 | +**Why 400 rather than 409** (a fail-closed alternative was considered and |
| 41 | +rejected — maintainer ruling, 決裁批 #20 ①, 2026-08-31): a 409 would still |
| 42 | +have collapsed two different facts into one answer — "you lost a race" |
| 43 | +(retry-actionable) and "you sent a token that can never carry a version" |
| 44 | +(a client-side bug, not a race). 400 keeps the two legible, which is the |
| 45 | +entire point of refusing the *shape* rather than failing the comparison. |
| 46 | +`""` is syntactically legal per RFC 7232 §2.3 (`*etagc` — zero or more — |
| 47 | +permits an empty opaque-tag); this refusal is a deliberate platform CONTRACT |
| 48 | +choice ("an empty tag can never match ⇒ it is necessarily a client defect"), |
| 49 | +not a syntax verdict. |
| 50 | + |
| 51 | +**Who this affects.** Measured: the first-party Console never sends this |
| 52 | +shape — `occVersionOf` (`plugin-form/src/occSave.tsx`) and its |
| 53 | +`InlineEditSaveBar` counterpart in `objectui` only forward a **truthy** |
| 54 | +`updated_at` string as `ifMatch`, and the `@object-ui/data-objectstack` |
| 55 | +adapter only sets the `If-Match` header when `options.ifMatch` is itself |
| 56 | +truthy — an empty value never reaches the wire on any first-party path. The |
| 57 | +exposure was to third-party and hand-rolled clients sending the RFC-7232 |
| 58 | +empty-tag shape, which previously got an unguarded write where they asked for |
| 59 | +a guarded one. |
| 60 | + |
| 61 | +<!-- adr-0087: not-required (no-migration-prescription) no metadata key, spec symbol, or stored value is renamed/retired/converted — this narrows what a REQUEST-time client-supplied string (`expectedVersion`/`If-Match`) is accepted at the wire ingress, not any declared metadata surface `objectstack migrate meta` would touch --> |
| 62 | + |
0 commit comments