Skip to content

Commit 252f71b

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): a single-record update binds the PATH row, not the body's id (#6479) (#6709)
`updateData` probed existence and validated OCC against the path `:id`, built `{ where: { id: request.id } }`, and then handed the request body to the engine verbatim — where a truthy scalar `data.id` outranks `where.id`. A body of `{"id":"rec_2"}` on `PATCH /data/task/rec_1` therefore probed rec_1, version-checked rec_1, WROTE rec_2, and answered `id: rec_1` beside rec_2's readback: a silent cross-row write straight past the caller's own `If-Match`. The path id is now merged over the payload before dispatch (`{ ...request.data, id: request.id }`) — the same shape the bulk ingress in `rest-server.ts` has always used, so the repo's two single-write ingresses give one answer (#4550 / #4434). Triage ruling A of 2026-08-08; routes B (400 on mismatch) and C (schema ban) were explicitly rejected, and neither the engine's payload-first dispatch (#5748) nor its by-id payload strip (#6435) is touched. A non-record payload (`undefined`, `null`, an array) passes through untouched so this ingress is never kinder than the producer about a malformed call. Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw Co-authored-by: Claude <noreply@anthropic.com>
1 parent a5d2573 commit 252f71b

3 files changed

Lines changed: 416 additions & 1 deletion

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): a single-record update binds the row the CALLER named, not the row the body names (#6479)
6+
7+
`PATCH /data/:object/:id` decided which row to write **twice, differently**. The
8+
protocol's `updateData` probed existence and validated `If-Match` /
9+
`expectedVersion` against the path `:id`, built `{ where: { id: request.id } }`,
10+
and then handed the request body to the engine verbatim — where the dispatch
11+
reads the payload first, so a truthy scalar `data.id` outranks `where.id`.
12+
13+
So `PATCH /data/task/rec_1` with a body of `{"id":"rec_2","title":"x"}`:
14+
15+
- probed **rec_1** for existence (404 gate, #4435);
16+
- version-checked **rec_1** against the caller's `If-Match`;
17+
- **wrote rec_2**; and
18+
- answered `{ id: "rec_1", record: <rec_2's readback> }` — a receipt whose two
19+
halves name different rows.
20+
21+
rec_2 was never probed and never version-checked, so the most common client
22+
shape there is — GET a record, edit a field, PUT the whole body back — performed
23+
a **silent cross-row write straight past its own optimistic-concurrency check**
24+
whenever the body carried another row's id (a mis-clicked list row, a stale
25+
refresh, a generated client that copied the wrong field).
26+
27+
`updateData` now merges the path id over the payload before dispatch
28+
(`{ ...request.data, id: request.id }`) — the same shape the **bulk** ingress has
29+
always used for this question (`ql.update(op.object, { ...data, id }, …)`), so the
30+
two ingresses give one answer instead of two. The probed row, the OCC-checked
31+
row, the written row and the receipt's `id`/`record` are now the same row: the
32+
one in the URL.
33+
34+
Nothing else moves:
35+
36+
- **The engine is untouched.** ObjectQL's payload-first dispatch (#5748) and its
37+
by-id payload strip (#6435) are unchanged and still correct for a caller who
38+
hands ObjectQL a payload and nothing else; this was a gap at the REST/protocol
39+
ingress, which had already named the row.
40+
- **No new rejection, no request-shape change.** A body `id` equal to the path
41+
id behaves exactly as before, and a differing one is now simply overridden
42+
rather than refused — `UpdateDataRequestSchema` still accepts the same bodies.
43+
- **Non-record payloads pass through untouched** (`undefined`, `null`, an array),
44+
so the engine's own diagnostics for a malformed call still surface unchanged.
45+
46+
Callers that deliberately relied on the body's `id` redirecting a
47+
single-record PATCH must address the intended row in the URL instead — the bulk
48+
endpoint has never honoured a body id either.

packages/metadata-protocol/src/protocol.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5883,7 +5883,55 @@ export class ObjectStackProtocolImplementation implements
58835883
// listener never breaks the write (the engine catches + logs).
58845884
const dropped: DroppedFieldsEvent[] = [];
58855885
opts.onFieldsDropped = (e: DroppedFieldsEvent) => { dropped.push(e); };
5886-
const result = await this.engine.update(request.object, request.data, opts);
5886+
// [#6479] At THIS ingress the row is the one the caller named — `request.id`,
5887+
// the path `:id` — and nothing in the payload gets to move it.
5888+
//
5889+
// The engine's dispatch reads the PAYLOAD first: a truthy scalar `data.id`
5890+
// outranks `options.where.id` (`engine-update-dispatch.ts`, case *"a SCALAR
5891+
// data.id still wins over a scalar where.id"* — `expectId: 'rec_1'`). That
5892+
// rule is correct and deliberate for a caller who hands ObjectQL a payload
5893+
// and nothing else (#5748 / PR #5919, ruling A); it is a HOLE here, because
5894+
// this caller has already named the row twice — in the URL and in `where` —
5895+
// and the three gates around this line all judge THAT row:
5896+
//
5897+
// probe → `probeRecord(object, request.id)` (existence, #4435)
5898+
// OCC → `assertVersionOf(…, request.id, …)` (If-Match / expectedVersion)
5899+
// receipt → `{ id: request.id, record: result }`
5900+
//
5901+
// Passing `request.data` verbatim let a body `{"id":"rec_2"}` on
5902+
// `PATCH /data/task/rec_1` bind rec_2: probed rec_1, OCC-checked rec_1,
5903+
// WROTE rec_2, and answered `id: rec_1` beside rec_2's readback. rec_2 was
5904+
// never probed and never version-checked, so a client that GETs a record,
5905+
// edits it and PUTs the whole body back — with the wrong row's id picked up
5906+
// from a mis-clicked list or a stale refresh — performed a silent cross-row
5907+
// write past its own `If-Match`.
5908+
//
5909+
// The fix is the shape the BULK ingress has always used for the same
5910+
// question (`rest-server.ts`, batch `update`: `ql.update(op.object,
5911+
// { ...data, id }, …)` — the operation's id after the spread, so it wins).
5912+
// Two ingresses, one answer (#4550 / #4434). It changes no engine verdict:
5913+
// the call still dispatches `by-id`, on the id `where` already carried.
5914+
//
5915+
// Deliberately NOT route B (400 on mismatch) or route C (ban `id` in
5916+
// `UpdateDataRequestSchema`) — both were rejected by the 2026-08-08 triage
5917+
// ruling on #6479; B installs a new rejection on a shipped API and C
5918+
// changes the accepted request shape.
5919+
//
5920+
// A non-record payload is passed through UNTOUCHED (`undefined`, `null`, an
5921+
// array): the engine reads `data.id` unguarded on purpose, so `undefined`
5922+
// is its `TypeError`, and an ingress that answered a non-record payload
5923+
// more kindly than the producer would be the very looseness
5924+
// `engine-update-dispatch.ts` exists to prevent. Those shapes carry no
5925+
// scalar `id` to outrank `where.id` either, so the invariant holds for them
5926+
// through `opts.where` alone.
5927+
const writeData = (
5928+
request.data !== null
5929+
&& typeof request.data === 'object'
5930+
&& !Array.isArray(request.data)
5931+
)
5932+
? { ...(request.data as Record<string, unknown>), id: request.id }
5933+
: request.data;
5934+
const result = await this.engine.update(request.object, writeData, opts);
58875935
return {
58885936
object: request.object,
58895937
id: request.id,

0 commit comments

Comments
 (0)