Skip to content

Commit 6aea1f5

Browse files
os-muskclaude
andauthored
fix(objectql): insert answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14405)
* wip(objectql): envelope driver unique violations at the insert door (#14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * test(objectql): pin the DUPLICATE_RECORD insert contract and retriage the resync pins (#14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * fix(objectql): keep the driver diagnosis on the operator log line (#14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * chore(objectql): changeset + regenerated system-context census (#14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * test(objectql): type the registry double so tsconfig.test.json compiles the new suite (#14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * chore: regenerate the system-context census on the merged tree (#14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * fix(metadata-protocol): seed-loader operator line reaches through cause; retriage the two runtime disclosure pins (#14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * chore: re-anchor the system-context census after the seed-loader edit (#14095) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * chore: ratchet the error-status unpinned baseline down for DUPLICATE_RECORD (#14095) `DuplicateRecordError` is the first producer to declare this code's status, so the code leaves the unpinned census. Baseline written by `check-error-status-conformance.mjs --update`; shrink-only, one line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bd8795e commit 6aea1f5

13 files changed

Lines changed: 1069 additions & 73 deletions
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql): `insert` answers a driver unique violation with the `DUPLICATE_RECORD` envelope, on every driver (#14095)
6+
7+
The platform recommends "declare a unique index, attempt the insert, swallow the
8+
violation" — it is what lets an idempotent writer be an ordinary job instead of
9+
needing a distributed lock, and `packages/objectql`'s own autonumber-resync doc
10+
argues at length against the read-then-write alternative ("a probe costs a query
11+
on every insert … and is still racy"). **An application could not complete that
12+
pattern**, because the insert door rethrew the DRIVER's error verbatim and left
13+
three bad options: branch on `SQLITE_CONSTRAINT_UNIQUE` (and silently stop being
14+
idempotent the day the deployment moves to Postgres' `23505`, MySQL's
15+
`ER_DUP_ENTRY` or Mongo's `E11000`); pattern-match a message that on the measured
16+
SQLite path is the whole compiled INSERT statement; or use the platform's own
17+
`isUniqueViolationError`, which is correct, dialect-independent — and lives in
18+
`@objectstack/types`, a package an application cannot resolve.
19+
20+
Triage ruling 2026-09-01, verbatim: 「抛一个带既有词表码(`DUPLICATE_RECORD` 已在
21+
ADR-0112 台账里)的平台错误,原驱动错误作 `cause``insert` 在每个驱动上有同一份
22+
契约」.
23+
24+
**What `engine.insert` now raises** for a recognised unique violation, identically
25+
on every driver and on every path a driver create failure leaves the door by
26+
(single row, `bulkCreate` batch, the per-row fallback loop, `insertMany`'s partial
27+
mode, the scoped-repository facade, and the resync's last-chance create):
28+
`DuplicateRecordError``code: 'DUPLICATE_RECORD'` (already a member of
29+
`StandardErrorCode`; no `packages/spec` change was needed), `status: 409` (the
30+
conflict status its sibling refusals `DELETE_RESTRICTED` / `CONCURRENT_UPDATE`
31+
declare), the driver's own error WHOLE on `cause`, `object`, a `developerMessage`
32+
carrying the remedy, and `field` when — and only when — `uniqueViolationColumn`
33+
determinably named the conflicting COLUMN (an index name is never reported as a
34+
column; #6544's contract is not widened here).
35+
36+
**Nothing else moves.** A NOT NULL violation, a deadlock, a missing table and an
37+
unreachable store all leave the door as the very object the driver threw — pinned
38+
on identity, in both the single-row and batch paths. The verdict is the shared
39+
`isUniqueViolationError` predicate; this door adds no dialect knowledge of its own.
40+
`ERR_AUTONUMBER_COLLISION` keeps its narrower identity, because "re-seeded,
41+
re-issued, still refused" says something `DUPLICATE_RECORD` cannot.
42+
43+
Shipped as `minor` rather than a patch because callers observe a different error
44+
object on a public data-API door. Measured consequences, end to end on real
45+
drivers:
46+
47+
- **HTTP status is unchanged at 409** on `driver-sqlite-wasm` and `driver-memory`,
48+
single-column and composite declared indexes alike: REST's declared-status
49+
passthrough honours the envelope's `status`.
50+
- **The wire `code` changes from `UNIQUE_VIOLATION` to `DUPLICATE_RECORD`** (both
51+
registered), and the flat body no longer carries the `field` key on the dialects
52+
that name a column, because the passthrough arm ships neither. Restoring it is a
53+
dedicated `mapDataError` arm in `@objectstack/rest` — another lane, filed
54+
separately, not a rider here.
55+
- **Import row reports improve**: the row `code` was previously whatever dialect
56+
token the driver used (`SQLITE_CONSTRAINT_UNIQUE`, `11000`) and is now
57+
`DUPLICATE_RECORD`.
58+
- **The operator log is unchanged**: the engine logs the driver's own error (the
59+
envelope's `cause`), because the platform logger serializes only `message` and
60+
`stack` — so #8682's "what the database said, including the failing column, is
61+
kept" still holds.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): the seed loader's operator line reaches through `cause`, so an enveloped driver fault still names what the database said (#14095)
6+
7+
The seed channel has two halves by design: the payload quotes a caught sentence only when the producer DECLARES a client refusal, and the log carries the caught sentence ALWAYS — because withholding text that nothing else records is indistinguishable from deleting the diagnostic, which is what makes a disclosure fix a net loss for whoever has to fix the database.
8+
9+
`seedFailureCause` read `err.message` and nothing else. That was complete while every producer put its whole diagnosis there. It stopped being complete the moment one of them started ENVELOPING: `engine.insert` now answers a driver unique violation with `DUPLICATE_RECORD` / `status: 409` and keeps the driver's own error whole on `cause`, so the platform sentence sits on `message` and `UNIQUE constraint failed: dt_acct.email` sits one hop down. Read off `message` alone, the operator line printed the platform sentence and the driver's words reached **neither the response nor the log** — the exact loss the two-halves design exists to prevent, arriving through a producer doing the right thing.
10+
11+
So the log follows the hop: `seedFailureCause` now walks the `cause` chain (bounded at 4, the depth `@objectstack/types`' unique-violation predicate walks) and prints the DEEPEST non-empty sentence — the one no wrapper above it restates. The walk is structural, never a type check: this package must not import `@objectstack/objectql`, and an envelope from any producer earns the same treatment.
12+
13+
`seedCauseLabel` moves with it, because the marker would otherwise go false. It used to ask "was this ERROR's text withheld from the payload?", which was the same question while the printed sentence was always `err.message`. Now the two differ: an enveloped fault has its PLATFORM sentence quoted to the caller and its DRIVER sentence printed to the operator, and the old question answered `Cause` — telling an operator the reporter saw words the reporter never saw. It now compares the sentence about to be printed against the one the payload actually quoted, so `Cause` means "these are the same words". All three populations stay correct: withheld outright, enveloped, and plainly declared.
14+
15+
No behaviour changes for a producer that carries no `cause` — the walk finds nothing and returns `err.message`, byte for byte as before.

content/docs/permissions/system-context.mdx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,18 @@ that silently does not happen.
109109

110110
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
111111
|:--|:---|:---|:---|:---|
112-
| 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:10986` |
113-
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11154` |
114-
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9782` |
112+
| 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:11049` |
113+
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11217` |
114+
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9827` |
115115
| 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:1746` |
116-
| 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:9819`, `readonly-strict-errors.ts:66` |
117-
| 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:5740` |
118-
| 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:3604`, `:3614`, `:3641` |
116+
| 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:9864`, `readonly-strict-errors.ts:66` |
117+
| 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:5761` |
118+
| 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:3605`, `:3615`, `:3642` |
119119
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
120120
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
121-
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6438` |
122-
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11747` |
123-
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11676` |
121+
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6459` |
122+
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11810` |
123+
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11739` |
124124

125125
### 3. Sharing (`plugin-sharing`)
126126

@@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them.
179179

180180
| # | Site | Package | What it does |
181181
|:--|:---|:---|:---|
182-
| 62 | `objectql/src/engine.ts:3411` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
183-
| 63 | `objectql/src/engine.ts:14096` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
182+
| 62 | `objectql/src/engine.ts:3412` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
183+
| 63 | `objectql/src/engine.ts:14159` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
184184
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
185185
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |
186186

@@ -193,9 +193,9 @@ assuming `isSystem` covers it is a documented source of bugs.
193193

194194
| Assumption | Reality | Anchor |
195195
|:---|:---|:---|
196-
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819``1821`, #3760), `flow.zod.ts:685` |
196+
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881``1883`, #3760), `flow.zod.ts:685` |
197197
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
198-
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9765``9782` |
198+
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9810``9827` |
199199
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
200200
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280``281` |
201201
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |

0 commit comments

Comments
 (0)