Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .changeset/occ-empty-etag-rejected-at-ingress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
"@objectstack/metadata-protocol": minor
---

fix(metadata-protocol): refuse the quoted-empty `If-Match` entity-tag instead of silently disabling optimistic concurrency (#13576)

**BREAKING** accept-set narrowing at the guarded-write door, shipped as
`minor` under the repo's launch-window convention for breaking changes.

`If-Match: ""` — a syntactically legal RFC-7232 entity-tag with an EMPTY
opaque value — was silently accepted as "no version token supplied", which
**skipped the optimistic-concurrency guard entirely** on both `PATCH
/data/:object/:id` (via `If-Match` or the body's `expectedVersion` field) and
`DELETE /data/:object/:id` (via `If-Match` or the query's `expectedVersion`).
`normaliseVersionToken` strips the RFC-7232 quotes off the token and only
*then* checks emptiness, so `'""'` (2 chars, non-empty) passed every upstream
truthiness gate only to normalise to `''` one layer down — the exact falsy
value every caller's own `if (!token) return` reads as "the client sent
nothing". It was the one token shape that opted OUT of the guard instead of
failing it: a garbage-but-nonempty token (`v2`) has always failed *toward*
`409 CONCURRENT_UPDATE`, the safe direction for a concurrency primitive —
`""` failed toward silent, unguarded acceptance instead.

**What changes.** Both doors now refuse `expectedVersion`/`If-Match: ""` at
ingress with `400 VALIDATION_FAILED`:

> 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.

**What does NOT change** (both explicitly pinned as regression controls):
omitting `If-Match`/`expectedVersion` entirely is still a legal **unguarded**
write (opt-in semantics, unaffected) — including a bare unquoted empty string
or whitespace-only value, which is not the malformed shape and stays
opted-out; and a garbage-but-nonempty token (`v2`) still fails toward `409
CONCURRENT_UPDATE`, unchanged.

**Why 400 rather than 409** (a fail-closed alternative was considered and
rejected — maintainer ruling, 決裁批 #20 ①, 2026-08-31): a 409 would still
have collapsed two different facts into one answer — "you lost a race"
(retry-actionable) and "you sent a token that can never carry a version"
(a client-side bug, not a race). 400 keeps the two legible, which is the
entire point of refusing the *shape* rather than failing the comparison.
`""` is syntactically legal per RFC 7232 §2.3 (`*etagc` — zero or more —
permits an empty opaque-tag); this refusal is a deliberate platform CONTRACT
choice ("an empty tag can never match ⇒ it is necessarily a client defect"),
not a syntax verdict.

**Who this affects.** Measured: the first-party Console never sends this
shape — `occVersionOf` (`plugin-form/src/occSave.tsx`) and its
`InlineEditSaveBar` counterpart in `objectui` only forward a **truthy**
`updated_at` string as `ifMatch`, and the `@object-ui/data-objectstack`
adapter only sets the `If-Match` header when `options.ifMatch` is itself
truthy — an empty value never reaches the wire on any first-party path. The
exposure was to third-party and hand-rolled clients sending the RFC-7232
empty-tag shape, which previously got an unguarded write where they asked for
a guarded one.

<!-- 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 -->

22 changes: 20 additions & 2 deletions content/docs/api/wire-format.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ Updates specific fields on an existing record. Only include fields you want to c
```

<Callout type="info">
**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.
**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.
</Callout>

### Response — `200 OK`
Expand Down Expand Up @@ -378,6 +378,24 @@ Returned when an `If-Match` / `expectedVersion` token no longer matches the stor
}
```

### Malformed Concurrency Token — `400 Bad Request`

Returned when `If-Match` / `expectedVersion` is the empty entity-tag `""` — a
syntactically legal [RFC&nbsp;7232](https://www.rfc-editor.org/rfc/rfc7232#section-2.3)
token, but one that can never match a stored version. Distinct from both the
409 above (a real token that lost a race) and an omitted `If-Match` (a
deliberate unguarded write): sending `""` is treated as a client defect, since
no stored version can ever equal "nothing".

```json
{
"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.",
"code": "VALIDATION_FAILED",
"fields": [],
"object": "task"
}
```

### Datasource Unavailable — `503 Service Unavailable`

Returned when the object's declared `datasource` has no live driver: the host's
Expand Down Expand Up @@ -543,7 +561,7 @@ When the batch is not atomic and some records fail, each failing entry carries a
| `Content-Type` | Yes | `application/json` |
| `X-Request-Id` | No | Client-generated request ID for tracing (honored by the observability dispatcher) |
| `X-Environment-Id` | No | Targets a specific environment/project on unscoped routes |
| `If-Match` | No | Optimistic-concurrency token for `PATCH` / `DELETE` (the `updated_at` you last read) |
| `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. |
| `Accept-Language` | No | Locale for translated labels (e.g., `en-US`) |

### Response Headers
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ that silently does not happen.
| 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` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10874` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9605` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Object name |
| **id** | `string` | ✅ | Record ID to delete |
| **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. |
| **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. |


---
Expand Down Expand Up @@ -2712,7 +2712,7 @@ Uninstall package response
| **object** | `string` | ✅ | The object name. |
| **id** | `string` | ✅ | The ID of the record to update. |
| **data** | `Record<string, any>` | ✅ | The fields to update (partial update). |
| **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. |
| **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. |


---
Expand Down
Loading
Loading