Skip to content

Commit 47389b3

Browse files
fix(metadata-protocol): refuse the quoted-empty If-Match entity-tag at ingress (#13576) (#13870)
* fix(metadata-protocol): refuse the quoted-empty If-Match entity-tag at ingress (#13576) WIP checkpoint before gates/ablation — reject `expectedVersion`/`If-Match: ""` with 400 VALIDATION_FAILED instead of silently skipping the OCC guard. * chore(metadata-protocol): pin new engine doubles + doc the new 400 + adr-0087 marker (#13576) - scripts/engine-double-contract.pinned.json: register the fake engine double introduced by protocol.occ-empty-etag-rejected.test.ts (node scripts/check-engine-double-contract.mjs --write). - content/docs/api/wire-format.mdx: document the new 400 VALIDATION_FAILED refusal for the quoted-empty If-Match entity-tag, alongside the existing OCC/409 documentation. - .changeset/*.md: add the required ADR-0087 disposition marker (not-required / no-migration-prescription) for the declared-breaking changeset. * docs(spec,metadata-protocol): document the empty-tag 400 + repair system-context census rot (#13576) - packages/spec/src/api/protocol.zod.ts: UpdateDataRequestSchema and DeleteDataRequestSchema's expectedVersion .describe() now names the quoted-empty entity-tag ("") refusal alongside the existing 409/omit behaviour, consistent with the wire-format.mdx wording already shipped. - content/docs/references/api/protocol.mdx: regenerated (pnpm --filter @objectstack/spec gen:docs) so both the update-request and delete-request tables carry the new clause — this page is auto-generated from the schema above, never hand-edited. - content/docs/permissions/system-context.mdx: check-system-context-census line-rot repair (node scripts/check-system-context-census.mjs --fix). The earlier commit's ~90-line insertion ahead of stripReadonlyForInsert shifted its `context?.isSystem` read from protocol.ts:1576 to :1664; row 21 now points at the new line. Pure re-anchor, diff reviewed: same semantic site, nothing added or removed. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eb649cb commit 47389b3

9 files changed

Lines changed: 506 additions & 23 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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+

content/docs/api/wire-format.mdx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ Updates specific fields on an existing record. Only include fields you want to c
198198
```
199199

200200
<Callout type="info">
201-
**Optimistic concurrency:** Pass the `updated_at` value you last read as an `If-Match` request header (or an `expectedVersion` field in the body) and the server returns `409 CONCURRENT_UPDATE` if the record changed in the meantime.
201+
**Optimistic concurrency:** Pass the `updated_at` value you last read as an `If-Match` request header (or an `expectedVersion` field in the body) and the server returns `409 CONCURRENT_UPDATE` if the record changed in the meantime. Omitting `If-Match`/`expectedVersion` entirely performs an unguarded write. Sending the empty entity-tag `If-Match: ""` (or `expectedVersion: '""'`) is refused `400 VALIDATION_FAILED` — an empty token can never match any stored version, so it is treated as a client defect rather than either "no guard requested" or a real conflict.
202202
</Callout>
203203

204204
### Response — `200 OK`
@@ -378,6 +378,24 @@ Returned when an `If-Match` / `expectedVersion` token no longer matches the stor
378378
}
379379
```
380380

381+
### Malformed Concurrency Token — `400 Bad Request`
382+
383+
Returned when `If-Match` / `expectedVersion` is the empty entity-tag `""` — a
384+
syntactically legal [RFC&nbsp;7232](https://www.rfc-editor.org/rfc/rfc7232#section-2.3)
385+
token, but one that can never match a stored version. Distinct from both the
386+
409 above (a real token that lost a race) and an omitted `If-Match` (a
387+
deliberate unguarded write): sending `""` is treated as a client defect, since
388+
no stored version can ever equal "nothing".
389+
390+
```json
391+
{
392+
"error": "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.",
393+
"code": "VALIDATION_FAILED",
394+
"fields": [],
395+
"object": "task"
396+
}
397+
```
398+
381399
### Datasource Unavailable — `503 Service Unavailable`
382400

383401
Returned when the object's declared `datasource` has no live driver: the host's
@@ -543,7 +561,7 @@ When the batch is not atomic and some records fail, each failing entry carries a
543561
| `Content-Type` | Yes | `application/json` |
544562
| `X-Request-Id` | No | Client-generated request ID for tracing (honored by the observability dispatcher) |
545563
| `X-Environment-Id` | No | Targets a specific environment/project on unscoped routes |
546-
| `If-Match` | No | Optimistic-concurrency token for `PATCH` / `DELETE` (the `updated_at` you last read) |
564+
| `If-Match` | No | Optimistic-concurrency token for `PATCH` / `DELETE` (the `updated_at` you last read). Omit for an unguarded write; the empty entity-tag `""` is refused `400`, not treated as omitted. |
547565
| `Accept-Language` | No | Locale for translated labels (e.g., `en-US`) |
548566

549567
### Response Headers

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ that silently does not happen.
112112
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10712` |
113113
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10874` |
114114
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9605` |
115-
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1576` |
115+
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1664` |
116116
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9642`, `readonly-strict-errors.ts:66` |
117117
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5639` |
118118
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` |

content/docs/references/api/protocol.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
581581
| :--- | :--- | :--- | :--- |
582582
| **object** | `string` || Object name |
583583
| **id** | `string` || Record ID to delete |
584-
| **expectedVersion** | `string` | optional | Optimistic concurrency token (typically the `updated_at` value the client read). When provided, the server compares it against the current record version and returns 409 CONCURRENT_UPDATE if they differ. Optional — omit to skip the check. |
584+
| **expectedVersion** | `string` | optional | Optimistic concurrency token (typically the `updated_at` value the client read). When provided, the server compares it against the current record version and returns 409 CONCURRENT_UPDATE if they differ. Optional — omit to skip the check. The quoted-empty entity-tag (`""`) is refused 400 VALIDATION_FAILED, not treated as omitted. |
585585

586586

587587
---
@@ -2712,7 +2712,7 @@ Uninstall package response
27122712
| **object** | `string` || The object name. |
27132713
| **id** | `string` || The ID of the record to update. |
27142714
| **data** | `Record<string, any>` || The fields to update (partial update). |
2715-
| **expectedVersion** | `string` | optional | Optimistic concurrency token (typically the `updated_at` value the client read). When provided, the server compares it against the current record version and returns 409 CONCURRENT_UPDATE if they differ. Optional — omit to skip the check. |
2715+
| **expectedVersion** | `string` | optional | Optimistic concurrency token (typically the `updated_at` value the client read). When provided, the server compares it against the current record version and returns 409 CONCURRENT_UPDATE if they differ. Optional — omit to skip the check. The quoted-empty entity-tag (`""`) is refused 400 VALIDATION_FAILED, not treated as omitted. |
27162716

27172717

27182718
---

0 commit comments

Comments
 (0)