Skip to content

Commit e1fede6

Browse files
docs(adr-0120): record the caller-supplied id collision on the create doors as an accepted residual, document it, pin it (#28694)
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk ## 维护者速读 - 改了什么:ADR-0120 加一条带日期的修订注记(S10 之下),记录「记录 `id` 是全部署唯一键,新建撞上已有 `id` 一律同一种拒绝」这一已接受的残余;HTTP 协议文档 Create Record 一节补上调用方自带 `id` 与撞键的回答;新增两个 dogfood 用例钉住今天的行为。零产品代码改动。 - 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开) - 风险与回滚:只动文档、ADR 与测试,不改运行时;回滚即 revert 本 PR。风险在于今后有人改动新建门的拒绝形状时,新用例会转红,这是有意的。 - 先看:`docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md`(修订注记全文,对照你 2026-11-01 的「同意」)→ `content/docs/protocol/kernel/http-protocol.mdx`(Create Record 新增段)→ `packages/qa/dogfood/test/create-caller-id-collision-walled.dogfood.test.ts`(跨组织一侧的断言)。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/create-caller-id-collision.dogfood.test.ts test/create-caller-id-collision-walled.dogfood.test.ts`,预期 2 个文件、30 个用例全绿。 - 需要你做的:本 PR 触及 `docs/adr/**`(Tier H),请审阅修订注记措辞后亲手合并或入队。 ## What changes A docs + pin PR that records a maintainer ruling. **No product code changes.** - **ADR-0120** gains a dated amendment note under its business-requirement matrix (and a pointer on the status line and in the S10 row), in the record's own amendment form. It states that S10's no-oracle promise covers `'organization'` uniqueness only; that the record `id` is the one deployment-global key; and that a create colliding on it answers one refusal, identical for every visibility of the row that holds the id, naming no organization or owner. It records the ruling chain (ruling record `5934879010` rule 2, extended by the maintainer on 2026-11-01 from upsert to every create door and to the same-organization half) and the options considered and rejected. - **`content/docs/protocol/kernel/http-protocol.mdx`**, Create Record: a caller may supply `id`; the duplicate-`id` answer (`409 UNIQUE_VIOLATION`, nothing written, no owner or organization detail, the same for every visibility, on every create door). - **Two dogfood pins** at the HTTP door, on `POST /data/:object`, per-object batch create and the cross-object `POST /batch`: - `create-caller-id-collision.dogfood.test.ts` — same organization, stock showcase (added to the `shared-showcase` project: no boot options, no metadata or organization writes, assertions scoped to its own rows). - `create-caller-id-collision-walled.dogfood.test.ts` — another organization, walled two-organization boot under `isolated` and `group`; the wall itself is measured in a precondition case rather than assumed. - Each door: a fresh caller-supplied `id` lands as given (control); the hidden-row and visible-row collisions return the same status and the same body, whole; both rows are byte-identical after; the body names no organization, owner, or user. The pins confirm today's behaviour matches the ruling: no answer differed by visibility and none named an organization, so there was nothing to stop and report. ## Tests - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1` on the two new files, under the verify lock: `Test Files 2 passed (2)`, `Tests 30 passed (30)`, lock `VERDICT command-exit 0`. - NOT MEASURED locally: the whole `shared-showcase` project, which the same-organization file joins. Reason: five verify-lock attempts answered `99` (queue-timeout) over about 45 minutes behind a sibling dispatch's full-suite hold; CI's dogfood gate runs the project. The new file itself ran inside that project (on its own boot of the shared stack) and passed in the run above. - Type check, narrowed and declared: `tsc -p` over a temporary config that extends the package's `tsconfig.json` with `files` set to the two new tests (deleted afterwards, tree clean): exit 0, both files in the program (`--listFiles`, 1113 files), zero errors. The package-wide `pnpm --filter @objectstack/dogfood typecheck` is NOT MEASURED locally, for the same lock-queue reason; CI's `TypeScript Type Check` runs it. - No ablation: the diff changes no tested behaviour; it pins today's. The precondition cases are what keep the identity assertions non-vacuous (the hidden row is measured hidden; the fresh-id control is measured honoured). ## Acceptance notes - The walled half uses `bootStack(..., { multiTenant: 'posture-only' })`. That option's docblock in `packages/verify/src/harness.ts` says the stand-in "stamps no `organization_id` and scopes no query"; on this boot the row is stamped with the creating organization and another organization's member reads `404` for it, both asserted in the precondition case. Noted, not filed: a docblock reading, outside this PR's scope. - Doors outside the three pinned here (`createMany`, import insert and upsert, clone `overrides.id`) are named in the ADR note and the doc; they are not pinned by this PR. - No changeset: the diff touches `docs/adr/**`, `content/docs/**` and the private `@objectstack/dogfood` package, none of which ships in any published package's `files[]`. Declared with `skip-changeset` at creation. ## Verification Head `393827105`. It adds two commits on top of `c2c7d9183`, both from the contract-tier review, both prose-only in the same two files: - `41e8ea516`: the ADR-0120 note's second fact and the Create Record paragraph in `http-protocol.mdx` now name the store precondition. The 409 holds on a store that enforces the primary key (the SQL-family drivers); `driver-memory` declares none. - `393827105`: the same two hunks call `driver-memory` the development and test driver, not a production store. This replaces a descriptor that its README does not support. Re-run at `393827105`, exit codes captured before any pipe: - `node scripts/check-adr-links.mjs` :: exit 0 (965 relative link destinations resolve) - `pnpm check:adr-index` :: exit 0 (`docs/adr/README.md is current`) - `pnpm check:doc-anchors` :: exit 0 (855 internal fragment links resolve) - `pnpm check:doc-authoring` :: exit 0 The dogfood run, the narrowed type check and the derived gate families below were taken at `c2c7d9183`. The two commits after it touch neither test file and no gate-read path outside those two prose files. Not run locally by design: `test:repo` (this diff edits none of its tests, and the derivation names none of it) and the repo-wide scans CI owns. <details> <summary>Derived gate families (dispatch-gates --commands, reconciled with --ran)</summary> Each line is `command :: exit code`, captured before any pipe. - `node scripts/check-adr-links.mjs` :: exit 0 - `node scripts/check-adr-links.mjs --self-test` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-doc-route-spelling.mjs --advisory` :: exit 0 - `node scripts/check-doc-route-spelling.mjs --self-test` :: exit 0 - `node scripts/check-docs-section-name.mjs` :: exit 0 - `node scripts/check-docs-section-name.mjs --self-test` :: exit 0 - `node scripts/check-dts-emitted.mjs --self-test` :: exit 0 - `node scripts/check-issue-citations.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs --self-test` :: exit 0 - `node scripts/check-section-landing-index.mjs` :: exit 0 - `node scripts/check-section-landing-index.mjs --self-test` :: exit 0 - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` :: exit 0 - `node scripts/ci/plan-test-core-shards.mjs --self-test` :: exit 0 - `node scripts/ci/prune-turbo-cache.mjs --self-test` :: exit 0 - `node scripts/docs-audit/check-affected-docs.mjs` :: exit 0 - `node scripts/docs-audit/check-drift-comment.mjs` :: exit 0 - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` :: exit 0 - `pnpm --filter @objectstack/lint run check:doc-security-posture` :: exit 0 - `pnpm --filter @objectstack/spec run check:docs` :: exit 0 - `pnpm --filter @objectstack/spec run check:empty-state` :: exit 0 - `pnpm --filter @objectstack/spec run check:liveness` :: exit 0 - `pnpm --filter @objectstack/spec run check:skill-examples` :: exit 3 — NOT MEASURED, PREREQUISITE NOT MET: reads `packages/client-react/dist` declarations, outside the dogfood build closure built here; CI runs it in Lint & Repo Gates / TypeScript Type Check - `pnpm --filter @objectstack/spec run check:strictness-ledger` :: exit 0 - `pnpm --filter @objectstack/spec run check:variant-docs` :: exit 0 - `pnpm --filter @objectstack/spec run check:yaml-examples` :: exit 0 - `pnpm check:adr-anchors` :: exit 0 - `pnpm check:adr-index` :: exit 0 - `pnpm check:adr-symbol-anchors` :: exit 0 - `pnpm check:ci-filter-parity` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:corpus-claim-drift` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:doc-anchors` :: exit 0 - `pnpm check:doc-authoring` :: exit 0 - `pnpm check:doc-frontmatter` :: exit 0 - `pnpm check:docs-audit-scope` :: exit 0 - `pnpm check:docs-redirects` :: exit 0 - `pnpm check:docs-single-h1` :: exit 0 - `pnpm check:docs-spec-enumerations` :: exit 0 - `pnpm check:docs-transcript-drift` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:dts-closure` :: exit 0 - `pnpm check:dual-build-cjs-loads` :: exit 3 — NOT MEASURED, PREREQUISITE NOT MET: reads the dist of seven packages outside the dogfood build closure built here; CI runs it in Lint & Repo Gates / TypeScript Type Check - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:issue-citations` :: exit 0 - `pnpm check:keyed-text-bounds` :: exit 0 - `pnpm check:lean-entry-closure` :: exit 0 - `pnpm check:logger-receiver-detach` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:objectql-double-limit` :: exit 0 - `pnpm check:org-identifier` :: exit 0 - `pnpm check:page-declaration-shape` :: exit 0 - `pnpm check:platform-object-tenancy-census` :: exit 0 - `pnpm check:pm-governed-merges` :: exit 0 - `pnpm check:pm-prior-rulings` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:published-readme-links` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:react-page-adapter-contract` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:registry-log-declared` :: exit 0 - `pnpm check:rest-log-spy-declared` :: exit 0 - `pnpm check:role-word` :: exit 0 - `pnpm check:skill-identifier-liveness` :: exit 0 - `pnpm check:slot-lookup` :: exit 0 - `pnpm check:sourcemap-no-sources-content` :: exit 0 - `pnpm check:system-context-census` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:type-check-debt` :: exit 3 — NOT MEASURED, PREREQUISITE NOT MET: its own closure build never got a turn on the shared verify lock; CI runs it in Lint & Repo Gates / TypeScript Type Check - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:vendor-version-stamps` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE` (FILE = the lines above): ```text ✓ dispatch-gates --ran: 82 derived famil(ies) accounted for — 79 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3). ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4bf48a8 commit e1fede6

5 files changed

Lines changed: 494 additions & 2 deletions

File tree

‎content/docs/protocol/kernel/http-protocol.mdx‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,38 @@ the created record (including server-generated fields) under `record`.
658658
}
659659
```
660660

661+
**Caller-supplied `id`:**
662+
A create may carry its own `id`; when no record holds it, the record is stored under
663+
that `id` and the response echoes it. `id` is the one key that is unique across the
664+
whole deployment — unlike a field's `unique`, which is scoped to the organization —
665+
so a create whose `id` is already held is refused with HTTP 409, and nothing is
666+
written, on a store that enforces the primary key (the SQL-family drivers do; the
667+
in-memory driver, the development and test driver, not a production store, declares
668+
no primary key, and a duplicate `id` lands there):
669+
670+
**Duplicate `id` (HTTP 409):**
671+
```json
672+
{
673+
"error": "A record with this value already exists",
674+
"code": "UNIQUE_VIOLATION",
675+
"field": "id",
676+
"object": "account"
677+
}
678+
```
679+
680+
The refusal is the same whichever record holds the `id`: one you can read, one that
681+
row-level access or the org-wide default hides from you inside your own organization,
682+
or one in another organization. It names neither that record's organization nor its
683+
owner. Every create door answers it the same way: `POST /{base_path}/{object_name}/batch`
684+
with `operation: "create"` (the row's error carries `UNIQUE_VIOLATION` with
685+
`httpStatus: 409`), `createMany`, the cross-object
686+
[`POST /api/v1/batch`](#batch-operations) (the whole batch rolls back), import, and
687+
clone's `overrides.id`. Key on `code`: `field` is best-effort, as the Constraint
688+
Violations note under [Delete Record](#delete-record) describes. A create never
689+
overwrites — change an existing record with [Update Record](#update-record). Why a
690+
global key answers this way is recorded in ADR-0120 (the 2026-11-01 amendment under
691+
its business-requirement matrix).
692+
661693
### Update Record
662694

663695
Update an existing record (partial update).

‎docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0120: Uniqueness scope is an explicit vocabulary — declared-index organization scoping and NULL-safe per-organization uniqueness
22

3-
**Status**: Accepted (2026-08-04, maintainer decision on #4986 / #5030; proposed same day) — implementation not started; the 17.x wave (D7) is the first work package, the protocol-18 items (D2 conversion, bare-`true` rejection) are deliberately deferred to the 18 train
3+
**Status**: Accepted (2026-08-04, maintainer decision on #4986 / #5030; proposed same day) — implementation not started; the 17.x wave (D7) is the first work package, the protocol-18 items (D2 conversion, bare-`true` rejection) are deliberately deferred to the 18 train · **Amended** (2026-11-01, extending ruling record [`5934879010`](https://github.com/objectstack-ai/objectstack/issues/21185#issuecomment-5934879010) rule 2, maintainer 「同意」) — S10's no-oracle promise is scoped to `'organization'` uniqueness; the record `id` is the one deployment-global key, and a create that collides on it is a recorded, accepted existence signal; see the note under the business-requirement matrix
44
**Deciders**: ObjectStack Protocol Architects (maintainer decision requested on #4986)
55
**Amends**: the #3696 decision *"a declared index is materialized verbatim — no tenant column is injected"* as recorded in `packages/spec/src/data/object.zod.ts` (`IndexSchema`), `content/docs/data-modeling/indexing.mdx` §*Two ways to say "unique"*, `content/docs/references/data/object.mdx`, the `syncDeclaredIndexes` doc block in `packages/drivers/driver-sql/src/sql-driver.ts`, and lint R10 `unique/double-declaration` (`packages/lint/src/data-model-rules.ts`). Per Prime Directive #13 this reversal is itself a decision and is recorded here — **the verbatim contract is not abolished; it becomes the `'global'` arm of an explicit vocabulary, and every stored declaration keeps its exact current physical shape.**
66
**Builds on**: [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (D2 conversion layer — this ADR adds one entry), [ADR-0078](./0078-no-silently-inert-metadata.md) (no declarable-but-inert keys — `unique: 'global'` on a declared index is today documented as "changes nothing"; this ADR makes it load-bearing), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — #5030 is a declared-but-unenforced unique constraint), [ADR-0048](./0048-cross-package-metadata-collision.md) (the `COALESCE(col, '')` canonical index key part this ADR reuses), [ADR-0105](./0105-group-tenancy-posture-and-first-class-org-scope.md) (tenancy posture), [ADR-0113](./0113-required-write-contract-vs-column-constraint.md) (precedent: two look-alike spellings split into explicit, separately-owned contracts)
@@ -156,7 +156,7 @@ review gap to be raised on the PR.)
156156
| S7 | Single-org deployment (incl. each db-per-customer database), any per-organization unique | any organization-scoped spelling | **all void** (#5030 headline) | same spellings | **all real** — NULL bucket constrained (D3) |
157157
| S8 | Mixed population — platform template rows (org NULL) + per-org override rows in one object | field `unique: true` | per-org rows: yes · template rows: unconstrained **among themselves** | unchanged | NULL rows form one platform bucket, unique among themselves (D3) — matches the "NULL = the platform tenant" reading the write path already uses (`GLOBAL_TENANT`) |
158158
| S9 | Autonumber + uniqueness — per-organization sequences (`PROD-00001` per org) | declared composite with org column (docs warn against global) | as S3/S6 | `'organization'` scope pairs with the per-organization sequence by construction | always; optional follow-up lint: `'global'` unique over an autonumber field is flagged |
159-
| S10 | Cross-tenant existence-oracle avoidance (#3696 security rationale) | field `true` only | leaks via S3/S6 NULL-void edge? No — leak was global-index rejections; fixed for fields | `'organization'` extends the no-oracle property to declared composites | a tenant's insert can no longer be rejected by (or reveal) another tenant's values on any `'organization'` index |
159+
| S10 | Cross-tenant existence-oracle avoidance (#3696 security rationale) | field `true` only | leaks via S3/S6 NULL-void edge? No — leak was global-index rejections; fixed for fields | `'organization'` extends the no-oracle property to declared composites | a tenant's insert can no longer be rejected by (or reveal) another tenant's values on any `'organization'` index — not the record `id`, which is deployment-global (2026-11-01 amendment below) |
160160
| S11 | Tenancy-less objects — `tenancy` disabled or `managedBy: 'better-auth'` (no tenant column) | all spellings | single-column / listed columns | unchanged; `'organization'` degrades to listed columns alone, exactly as field-level `true` already does | unchanged |
161161
| S12 | Non-unique indexes (`unique: false` / omitted), `partial`, index `type` | verbatim | n/a | untouched — this ADR governs *unique scope* only; `'organization'` composes with `partial` (both key-part forms already parse, #4884) | n/a |
162162
| S13 | **Posture-portable app package** — one metadata app, deployed under `single`, `group`, and `isolated` postures (and db-per-customer environments) | field `true` (void on single-org, #5030) or hand-written org composite (author must know the convention *and* the posture trap) | varies by posture; `single`: **void** | `'organization'` for per-org rules — states the business boundary, not the posture | **correct under every posture** — per-org under `group`/`isolated`, deployment-wide under `single` (§Posture portability) |
@@ -170,6 +170,81 @@ Two properties of this table are the ADR's acceptance criteria:
170170
author edits metadata (S6 opt-in) — the conversion is semantic bookkeeping, not a
171171
migration.
172172

173+
> **Amendment (2026-11-01, extending ruling record [`5934879010`](https://github.com/objectstack-ai/objectstack/issues/21185#issuecomment-5934879010) rule 2) — S10's no-oracle promise covers `'organization'` uniqueness only. The record `id` is the one deployment-global key, and a create that collides on it is a recorded, accepted existence signal: one refusal, the same for every visibility of the row that holds the id.**
174+
>
175+
> S10 promises that a tenant's insert is no longer rejected by, and no longer reveals, another
176+
> tenant's values on an `'organization'` index (and on field-level `unique: true`, its
177+
> synonym). It does not reach the primary key, and it cannot: `id` is not a scoped `unique`
178+
> declaration, it is the row's address — one id space per object across the whole deployment,
179+
> under every posture. A create that names an `id` already held collides on a key that is
180+
> global by construction, the same oracle §*Posture portability* records for a `'global'`
181+
> unique. The facts recorded:
182+
>
183+
> - **Every create door accepts a caller-supplied `id`, and a fresh one lands as given:**
184+
> `POST /data/:object`, `POST /data/:object/batch` with `operation: 'create'`, `createMany`,
185+
> the cross-object `POST /batch`, import in `insert` mode and in `upsert` mode (an upsert
186+
> whose match, run as the caller, finds no row creates), and clone's `overrides.id`.
187+
> - **A create that collides on `id` answers `409 UNIQUE_VIOLATION` and writes nothing**, on a
188+
> store that enforces the primary key — the SQL-family drivers do (`driver-sql`, and
189+
> `driver-sqlite-wasm` and `driver-turso`, which extend it); `driver-memory`, the
190+
> development and test driver, not a production store, declares no primary key, so a
191+
> duplicate `id` lands there (its docblock in
192+
> `packages/drivers/driver-memory/src/memory-driver.ts`). On a per-row door the row's own
193+
> error carries the same code. The answer names no organization and no owner.
194+
> - **The answer is identical whether the row holding the id is visible to the caller, hidden
195+
> from the caller inside the caller's own organization (row-level security, the org-wide
196+
> default), or held by another organization.** The holder never shapes the refusal.
197+
> - **What leaves is one fact — "this id exists in the deployment" — and it is bounded by the
198+
> unguessability of ids, not by the refusal's shape.** The SQL driver mints a 16-character
199+
> nanoid (96 bits) and the importer a UUID, so the signal needs an id the caller already
200+
> holds; ids do travel (URLs, exports, share links, notifications, webhook payloads). No
201+
> refusal shape can bound it, because an id nobody holds answers `201`.
202+
>
203+
> **Ruled.** Ruling record `5934879010` (2026-10-01, rule 2, on the upsert door) answers an
204+
> upsert whose conflict lands on another organization's row with the `UNIQUE_VIOLATION` that
205+
> `create()` answers for the same collision — "ADR-0120 records that oracle as the accepted
206+
> cost of a 'global' key" — and binds "⛔ No new code, and ⛔ no message, field or detail that
207+
> says the row belongs to another organization". On 2026-11-01 the maintainer extended that
208+
> rule (「同意」 to the analysis and option table below, given in the PM seat's session; this
209+
> note is its record) from upsert to **every create door** listed above, and to the
210+
> **same-organization half**: a row hidden in the caller's own organization answers like a
211+
> visible one. Ruling record
212+
> [`5985885287`](https://github.com/objectstack-ai/objectstack/issues/21771#issuecomment-5985885287)
213+
> (a by-id update or delete answers a row the caller cannot read exactly as a nonexistent id)
214+
> is untouched: a create naming a held id has no "nonexistent" answer to give, because the key
215+
> it names is taken.
216+
>
217+
> Options considered and rejected:
218+
>
219+
> - **a — refuse a caller-supplied `id` from a non-system caller (`400`), b — strip it
220+
> silently** (reported in `droppedFields`): both break id-preserving import round-trips and
221+
> every client keyed by the ids it was handed, and the importer's own pre-minted ids would
222+
> need an exemption that re-opens the same door.
223+
> - **c — allow it only by capability** (Modify All, or a new "preserve record id"
224+
> permission): leaves the cross-organization half open to every holder and adds a permanent
225+
> permission key.
226+
> - **d — per-organization primary keys:** every driver's key and every by-id address
227+
> changes — a migration of the whole data layer to close one fact.
228+
> - **e — answer a hidden-row collision as an authorization refusal:** cannot close it; an id
229+
> nobody holds answers `201`, so any refusal stays distinguishable from a fresh id.
230+
>
231+
> Peer platforms carry the same shape: Microsoft Dataverse and ServiceNow both accept a
232+
> caller-supplied record id on create and refuse a duplicate.
233+
>
234+
> **What this binds.** ⛔ No create door answers a collision on `id` differently by the
235+
> visibility of the row that holds it, and ⛔ none adds a message, field or detail naming the
236+
> holder's organization or owner. Closing the signal is a new decision — a superseding ADR or
237+
> a further amendment here — and the bar it must clear is the one a–e failed: import
238+
> round-trips keep working without an exemption that re-opens the door. Callers read the
239+
> contract in
240+
> [`http-protocol.mdx` §*Create Record*](../../content/docs/protocol/kernel/http-protocol.mdx);
241+
> it is pinned at the HTTP door, on `POST /data/:object`, batch create and the cross-object
242+
> `POST /batch`, by
243+
> [`create-caller-id-collision.dogfood.test.ts`](../../packages/qa/dogfood/test/create-caller-id-collision.dogfood.test.ts)
244+
> (same organization, the stock showcase) and
245+
> [`create-caller-id-collision-walled.dogfood.test.ts`](../../packages/qa/dogfood/test/create-caller-id-collision-walled.dogfood.test.ts)
246+
> (another organization, `isolated` and `group`).
247+
173248
## Posture portability: one app package, every tenancy posture
174249

175250
The constraint that shapes the vocabulary (maintainer requirement, #4986): a metadata

0 commit comments

Comments
 (0)