You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 7b62f0a
Browse filesBrowse the repository at this point in the historyBrowse files
fix(metadata-protocol): batchData's by-id upsert chooses its arm by what the caller can read (#28721)
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk
Clause-②: no
Metadata-protocol: none
Skills-docs: none — no file under `skills/**` teaches how the batch
`upsert` chooses between update and create (a `git grep -i upsert` of
`skills/` names the import and seed upserts and the generated-route
table only), so no skill line changes.
## 维护者速读
- 改了什么:`POST /data/:object/batch` 的 `upsert`(protocol 的 `batchData`)里带
`id` 的行,改为按调用方能读到什么来选「更新」还是「新建」。调用方读得到的记录照旧走更新,仍由引擎判写权限;读不到的 id
按新建处理,答复与同 id 的 create 完全一致。只改 `protocol-batch-data.ts` 这一处分叉。
- 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)
- 风险与回滚:只换答复、不改接受集。调用方读不到的那一行,现在收到同 id 的 create 会收到的答复;读得到的行、无 id
的行、原子批的回滚、无上下文的进程内调用都不变。回滚即 revert 本 PR,不涉及数据迁移。
- 先看:`packages/metadata-protocol/src/protocol-batch-data.ts` 的 `case
'upsert'` 分叉(风险 hunk:`objectReadable` 与调用方 `findOne` 两行)和新函数
`callerMayReadObject`;其次 `protocol.upsert-existence.test.ts`。
- 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run
--maxWorkers=1 test/upsert-by-id-caller-read.dogfood.test.ts`,预期 28/28
通过(单租户 showcase 19 个,两组织隔离启动 9 个)。
- 需要你做的:无,席位落地。
## What changes
`batchData`'s by-id `upsert`
(`packages/metadata-protocol/src/protocol-batch-data.ts`, the `case
'upsert'` fork of `runBatchDataLoop`) now chooses its arm by what the
**caller** can read, under the context the arm's write runs with:
1. the object-level read admission, asked once per loop of the
`security` service's `canReadObject` (the engine middleware's own read
gate, `callerMayReadObject`); a context-less call, or a host whose
security service lacks the method, is not asked, and the row read below
still enforces there;
2. the caller's own `findOne` by id, where RLS, sharing and the
organization wall apply. Inside the atomic arm that context carries the
transaction.
A row the caller can read keeps the update arm, where `engine.update`
still judges the write. Any other id takes the create arm, the same
`engine.insert` call and row builder that `case 'create'` uses, so the
row answers exactly as a create naming that id answers on the same door.
No new code, field, detail or sentence; nothing names another
organization. There is still no fallback insert after an update failure.
The fork's code comment records why this reverses the earlier existence
fork, citing ruling 5985885287 (#21771) and the 2026-11-01 maintainer
ruling that extends ruling 5934879010 (#21185, rule 2). `BatchDataHost`
trades `probeRecord` (no batch body reads it any more) for
`getServicesRegistry`; the OCC path and `probeRecord`'s own readers are
untouched. The read's options are typed (`EngineQueryOptionsParsed`),
not erased.
## Tests
- `packages/metadata-protocol/src/protocol.upsert-existence.test.ts`,
rewritten (11 cases): an unreadable row takes the insert arm and its row
refusal deep-equals a batch create's for the same id, store untouched;
the fork reads with the caller's context, never the system's; an object
the security service refuses sends every row to the insert arm without a
row read, asked once per loop; an admitted object still forks on the row
read; a throwing admission fails the row and writes nothing; a
context-less call is not asked; no fallback insert after an update
failure; missing id inserts; readable row updates with the caller's
context; atomic rollback with the causal row's create refusal and both
reads on the transaction; mixed batch.
- `packages/qa/dogfood/test/upsert-by-id-caller-read.dogfood.test.ts`,
new (28 cases), at the HTTP door, atomic and non-atomic. Stock showcase:
create without edit on a private row, create and edit on a private row,
a create-only principal with no read; each answers the batch create's
body for the same id (row `id` echo aside), with the plain create's `409
UNIQUE_VIOLATION` code and status, row byte-identical, and an unknown id
is created. A readable row with edit is updated; a readable row without
edit keeps the update arm's refusal; a refused-both control answers one
refusal for an existing and an unknown id. Walled boot (`multiTenant:
true`, `isolated`, invitation-only, two organizations created over
`/auth/organization/create`): an editor and a creator in organization B
on organization A's row get the create's answer, which names neither
organization A's id, name nor slug; the editor updates its own
organization's row and the creator gets the update arm's refusal.
- `protocol.readonly-insert.test.ts`: one comment names the fork's read.
## Acceptance notes
- Clause-② `no`: the accept set does not move. A row the caller can read
takes the same arm as before. A row it cannot read never reaches the
update arm: it takes the create arm and answers as a create naming that
id. Only the refusal that row receives changes, to the create's (the
precedent is the order change that landed as `no` in #28615). No
`(narrowing)` arm was found.
- On `driver-memory`, which declares primary-key uniqueness out of its
scope, a create naming an existing id lands a second row; the upsert's
create arm now does what the create does there. The fork's comment
states it. No store enforcing the key (SQL, sqlite-wasm, Turso) is
affected.
## Verification
Readings below were taken at `adf4e0cb4`, before `origin/main` was
merged (`12dd59a7e`, delta still exactly these six files). The merged
head's run (closure rebuild, package suite, focused pins, one-shot
ablation with restore proof, `check:type-check-debt`, the gate union and
the full dogfood battery) is in flight under the verify lock; its
readings are below (written by the seat).
**Final-head readings.** The run started at `12dd59a7e` (merge of
`origin/main`) and ended at `9fb84ac12`, whose only addition is a
`packages/core` pin file. Every step ran under the verify lock and
reported VERDICT command-exit 0.
- Closure build: exit 0.
- metadata-protocol full suite: Test Files 292 passed | 4 skipped (296),
Tests 21958 passed | 26 skipped.
- metadata-protocol typecheck and dogfood typecheck: exit 0.
- `upsert-by-id-caller-read.dogfood.test.ts`: 28/28 (stock showcase 19;
walled two-org 9).
- `protocol.upsert-existence.test.ts`: 11/11.
- `check:type-check-debt`: exit 0.
- Full dogfood battery `pnpm --filter @objectstack/dogfood exec vitest
run --maxWorkers=1`: Test Files 372 passed | 3 skipped (375), Tests 3022
passed | 28 skipped (3050).
- One-shot ablation, with the fork reverted to the system-context probe:
- unit 8 failed | 3 passed;
- dogfood 10 failed | 18 passed (exactly the hidden-row and cross-org
cases);
- restore proven (blob == HEAD, preflight `--absent` exit 0).
- Gate union at `9fb84ac12`: 67 derived, 67 run, 0 NOT-MEASURED.
- CI at `9fb84ac12`: 55 success, 9 skipped, 0 failure. The earlier `Test
Core (4/8)` red at `12dd59a7e` came from the core row-serving-door pin,
which did not yet classify the new `canReadObject` caller; `9fb84ac12`
fixes it.
<details><summary>Touched packages: tests and typecheck at adf4e0c
(verify lock VERDICT command-exit 0)</summary>
```text
pnpm --filter @objectstack/metadata-protocol build EXIT=0
dogfood upsert-by-id-caller-read.dogfood.test.ts (--maxWorkers=1) EXIT=0 Test Files 1 passed (1) · Tests 28 passed (28)
pnpm --filter @objectstack/metadata-protocol exec vitest run EXIT=0 Test Files 291 passed | 4 skipped (295) · Tests 21948 passed | 26 skipped (21974)
pnpm --filter @objectstack/metadata-protocol typecheck EXIT=0
pnpm --filter @objectstack/dogfood typecheck EXIT=0
pnpm check:type-check-debt EXIT=0 0 raw tsc error(s) total, none above its recorded number
focused unit files (upsert-existence + readonly-insert) EXIT=0 Tests 22 passed (22)
```
</details>
<details><summary>Gates: dispatch-gates.mjs --commands (65, identical at
12dd59a)</summary>
```text
62 of 65 EXIT=0 at bf59c1e.
pnpm check:query-options-erasure EXIT=1 -> the caller read was typed; EXIT=0 at adf4e0c ("none new")
pnpm check:dual-build-cjs-loads EXIT=3 PREREQUISITE NOT MET (unbuilt packages) -> EXIT=0 after the closure build
pnpm check:type-check-debt EXIT=3 PREREQUISITE NOT MET (lock) -> EXIT=0 under the lock (above)
node scripts/check-platform-checklist.mjs --changed-from origin/main EXIT=0 (32 anchors judged, 0 lost)
```
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(metadata-protocol): the by-id `upsert` of `batchData` chooses its arm by what the caller can read
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
Security hardening of `POST /data/:object/batch` with `operation: 'upsert'` (the protocol's `batchData`). A row that carries an `id` now chooses between its update and create arms by what the calling principal can read, the rule the other by-id write doors already follow: a record the caller cannot read is treated as one that does not exist.
11
+
12
+
-**Unchanged:** a record the caller can read is updated, under the caller's own write permissions, exactly as before; a row without an `id` is created; atomic batches roll back on the first failing row; system and context-less calls fork as their reads run.
13
+
-**Changed:** any other `id` takes the create arm, so the row answers exactly as a `create` naming that `id` answers on the same door.
14
+
-**Your fix:** none. A client reconciling batch results reads each row's `errors[0].code` as before.
0 commit comments