Skip to content

Commit d31be92

Browse files
committed
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.
1 parent be968a2 commit d31be92

3 files changed

Lines changed: 38 additions & 2 deletions

File tree

‎.changeset/occ-empty-etag-rejected-at-ingress.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,6 @@ truthy — an empty value never reaches the wire on any first-party path. The
5757
exposure was to third-party and hand-rolled clients sending the RFC-7232
5858
empty-tag shape, which previously got an unguarded write where they asked for
5959
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

‎scripts/engine-double-contract.pinned.json‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,21 @@
771771
"verb": "update",
772772
"pinned": 1
773773
},
774+
{
775+
"file": "packages/metadata-protocol/src/protocol.occ-empty-etag-rejected.test.ts",
776+
"verb": "delete",
777+
"pinned": 1
778+
},
779+
{
780+
"file": "packages/metadata-protocol/src/protocol.occ-empty-etag-rejected.test.ts",
781+
"verb": "findOne",
782+
"pinned": 1
783+
},
784+
{
785+
"file": "packages/metadata-protocol/src/protocol.occ-empty-etag-rejected.test.ts",
786+
"verb": "update",
787+
"pinned": 1
788+
},
774789
{
775790
"file": "packages/metadata-protocol/src/protocol.occ-version-token-instant.test.ts",
776791
"verb": "delete",

0 commit comments

Comments
 (0)