From 12a0c1dc8c9116ff07798e6845c95f8adf427920 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 10:01:57 +0000 Subject: [PATCH 1/4] fix(objectql): envelope the update door's driver unique violation as DUPLICATE_RECORD (#14390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both driver exits of engine.update — the by-id driver.update call and the predicate driver.updateMany call — now answer a recognised unique violation with the ADR-0112 DuplicateRecordError envelope the insert door has carried since #14095. Everything that is not a unique violation passes through untouched; the Update operation failed log line keeps the driver's own diagnosis through the envelope's cause. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...que-violation-duplicate-record-envelope.md | 67 ++ .../objectql/src/duplicate-record-error.ts | 13 +- .../engine-update-duplicate-record.test.ts | 641 ++++++++++++++++++ packages/objectql/src/engine.ts | 72 +- 4 files changed, 784 insertions(+), 9 deletions(-) create mode 100644 .changeset/update-unique-violation-duplicate-record-envelope.md create mode 100644 packages/objectql/src/engine-update-duplicate-record.test.ts diff --git a/.changeset/update-unique-violation-duplicate-record-envelope.md b/.changeset/update-unique-violation-duplicate-record-envelope.md new file mode 100644 index 0000000000..d978d1a092 --- /dev/null +++ b/.changeset/update-unique-violation-duplicate-record-envelope.md @@ -0,0 +1,67 @@ +--- +"@objectstack/objectql": minor +--- + +fix(objectql): `update` answers a driver unique violation with the `DUPLICATE_RECORD` envelope, on every driver (#14390) + +The insert door got this contract in #14095; the update door — one verb over — +did not, and the platform was left with ONE contract for the condition on +`insert` and none on `update`. Measured on a real `ObjectQL` engine over a real +`driver-sqlite-wasm` store with a declared unique index on `email`: driving a +second row onto the first's value through `engine.update` threw a bare `Error` +with no `code`, no `status`, no `cause`, and the whole compiled UPDATE +statement — bound values included — as its message. The REST boundary sanitises +an error with neither `code` nor `status` into `500 INTERNAL_ERROR`, so **the +same user action now answers `409 DUPLICATE_RECORD` on create and +`500 INTERNAL_ERROR` on edit.** A 500 tells a client the server fell over, tells +a form to show a generic failure, and pages whoever watches 5xx rates — for a +conflict the user can fix by typing a different value. "Renaming a record onto +a name someone else already took" is the ordinary form-submission case, and it +was the one left dialect-coupled. + +**What `engine.update` now raises** for a recognised unique violation, +identically on every driver and on BOTH driver exits of the door — the by-id +`driver.update` call and the predicate (`multi: true`) `driver.updateMany` +call — and therefore through the scoped-repository facade a hook reaches as +`ctx.api.object(name).update(...)` / `.updateById(...)`: `DuplicateRecordError` +— `code: 'DUPLICATE_RECORD'`, `status: 409`, the driver's own error WHOLE on +`cause`, `object`, a `developerMessage` carrying the remedy, and `field` when — +and only when — `uniqueViolationColumn` determinably named the conflicting +COLUMN (an index name is never reported as a column). + +**A multi-row update names no row.** The driver's error does not say which of +the N matched rows conflicted, and the envelope does not invent an answer: it +carries exactly the keys the by-id envelope carries — no count, no row index — +and `field` only when the dialect named a column, exactly as the composite-index +case already behaves on insert. + +**Nothing else moves.** A NOT NULL violation, a deadlock, a missing table and an +unreachable store all leave the door as the very object the driver threw — +pinned on identity, on both the by-id and the predicate exits. The verdict is +the shared `isUniqueViolationError` predicate; this door adds no dialect +knowledge of its own. The envelope sits on the two driver exits rather than on +the door's outer `catch`, because that `catch` also sees the `afterUpdate` +dispatch and the roll-up recompute — a unique violation raised by a nested +driver call inside a hook is not this object's to envelope, and is passed +through untouched. + +**The operator log is unchanged**: `Update operation failed` still carries the +driver's own diagnosis (the failing column, the redacted statement marker), +because the engine logs the envelope's `cause`, exactly as the insert door does. + +Shipped as `minor` rather than a patch, for the reason #14095 was: callers +observe a different error object on a public data-API door. Measured +consequences on real drivers: + +- `driver-sqlite-wasm`: the by-id and the predicate refusal both become the + envelope with `field: 'email'` and the raw `SQLITE_CONSTRAINT_UNIQUE` error on + `cause`; the REST status resolution moves from 500 to 409. +- `driver-memory`: its own `UNIQUE_VIOLATION` / 409 refusal is normalised to the + same `DUPLICATE_RECORD` envelope (one code for an application to branch on, + not two), with the driver's error on `cause`; its declared-index sentence + names no single column, so `field` is absent there. + +**Deliberately not in this change**: `upsert` — the engine has no such verb +today (`update.options.upsert` is a retired-key tombstone), and a dialect that +converts a conflict into a merge would need its own measurement first; and the +wire `code` the REST layer speaks for this condition, which is a separate lane. diff --git a/packages/objectql/src/duplicate-record-error.ts b/packages/objectql/src/duplicate-record-error.ts index abc40bd325..971641742c 100644 --- a/packages/objectql/src/duplicate-record-error.ts +++ b/packages/objectql/src/duplicate-record-error.ts @@ -3,8 +3,8 @@ import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/types'; /** - * The ADR-0112 envelope `engine.insert` raises when a driver refuses a row as a - * unique-constraint violation (#14095). + * The ADR-0112 envelope `engine.insert` (#14095) and `engine.update` (#14390) + * raise when a driver refuses a row as a unique-constraint violation. * * ## The defect this retires * @@ -82,7 +82,7 @@ export class DuplicateRecordError extends Error { readonly developerMessage: string; constructor( - /** The object the refused insert targeted. */ + /** The object the refused write targeted. */ public readonly object: string, cause: unknown, /** The conflicting column, when the dialect determinably named one. */ @@ -92,7 +92,7 @@ export class DuplicateRecordError extends Error { this.name = 'DuplicateRecordError'; this.cause = cause; this.developerMessage = - `The driver refused this insert as a unique-constraint violation. Its own error is attached ` + + `The driver refused this write as a unique-constraint violation. Its own error is attached ` + `as \`cause\` — branch on \`code === '${DUPLICATE_RECORD_CODE}'\` (ADR-0112) rather than on a ` + `dialect's code or message, so the handling survives a change of store. To make the write ` + `idempotent, catch this code and treat the row as already present.`; @@ -119,8 +119,9 @@ function buildDuplicateMessage(object: string, field?: string): string { } /** - * The insert door's driver-error exit: the platform envelope for a unique - * violation, or the caller's own error unchanged for anything else. + * A write door's driver-error exit — `insert` (#14095) and `update` (#14390), + * by-id and predicate alike: the platform envelope for a unique violation, or + * the caller's own error unchanged for anything else. * * **Unrecognised is passed through untouched**, which is the whole of the * negative contract: a NOT NULL violation, a deadlock, a missing table and an diff --git a/packages/objectql/src/engine-update-duplicate-record.test.ts b/packages/objectql/src/engine-update-duplicate-record.test.ts new file mode 100644 index 0000000000..4a2880453d --- /dev/null +++ b/packages/objectql/src/engine-update-duplicate-record.test.ts @@ -0,0 +1,641 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #14390 — the update door's ONE error contract for a driver's unique-constraint + * refusal: the insert door's (#14095), one verb over. + * + * ## What was measured, and why it is a defect rather than a preference + * + * A real `ObjectQL` engine on a real `driver-sqlite-wasm` store, an object + * carrying a declared unique index on `email`, two rows, the second driven onto + * the first's value through `engine.update`. What left the door, by-id and by + * predicate (`multi: true`) alike: + * + * ``` + * name=Error code=undefined status=undefined cause=absent keys=[] + * message: update `duly_note` set `id` = '…', `email` = 'a@b.example', … where `id` = '…' - UNIQUE constraint failed: duly_note.email + * ``` + * + * No `code`, no `status`, no `cause`, the compiled UPDATE with its bound values + * as the message — the shape #14095 measured on insert. The REST boundary + * sanitises an error carrying neither `code` nor `status` into + * `500 INTERNAL_ERROR`, so the same user action answered `409 DUPLICATE_RECORD` + * on create and `500 INTERNAL_ERROR` on edit: an application branching on + * `code === 'DUPLICATE_RECORD'` for its create path fell through to the generic + * branch on its edit path, on every driver. + * + * ## What this file pins + * + * Both directions, because only the pair is a contract: + * + * - BOTH driver-error exits of the update door turn a recognised unique + * violation into the envelope — the by-id `driver.update` call and the + * predicate `driver.updateMany` call — and the scoped-repository facade a + * hook reaches as `ctx.api.object(name)` inherits it; and + * - **nothing else moves**: a NOT NULL violation, a deadlock, a missing table + * and an unreachable store leave BOTH exits as the very object the driver + * threw — asserted on IDENTITY, not on a message match, one pin per class + * per exit (triage ruling, 2026-09-02). + * + * Two things the insert file never had to decide: + * + * - **A multi-row write names no row.** The driver's error does not say which + * of the N matched rows conflicted, and the envelope invents nothing: it + * carries exactly the keys the by-id envelope carries, and `field` only + * when the dialect determinably named a column. + * - **Placement.** The envelope sits on the two driver exits, NOT on the + * door's outer `catch`: that `catch` also sees the `afterUpdate` dispatch + * and the roll-up recompute, so a raw unique violation raised by a nested + * driver call inside a hook must NOT come out attributed to this object. + * Pinned below with an `afterUpdate` hook that throws a raw driver shape. + * + * Refusal cases assert `code` AND `status` (ADR-0112), never `toThrow()` alone: + * a bare `toThrow` is green both when the door envelopes correctly and when a + * driver throws a raw error, which is the whole distinction under test. + * + * The driver fixtures are the dialect shapes measured for #14095, restated in + * their UPDATE form (the SQLite message inlines the SET clause's bound values, + * exactly as measured above). Restated rather than shared with the insert file + * because the two files ask different questions of them and a shared fixture + * module would couple their futures. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/types'; +import { ObjectQL, ScopedContext } from './engine'; +import { DuplicateRecordError, DUPLICATE_RECORD_CODE } from './duplicate-record-error'; +import { SchemaRegistry } from './registry'; +import type { IDataDriver } from '@objectstack/spec/contracts'; + +vi.mock('./registry', async () => { + const { createRegistryModuleMock } = await import('./registry-module-mock.js'); + return createRegistryModuleMock(); +}); + +/** + * The double's `getObject`, typed — see the insert pin file for why the + * narrowing lives here rather than as a cast at each call site. + */ +const registryDouble = SchemaRegistry as unknown as { getObject: ReturnType }; + +type Row = Record; + +/* -------------------------------------------------------------------------- + * Driver fixtures — the shapes the supported dialects actually raise on UPDATE. + * ----------------------------------------------------------------------- */ + +/** better-sqlite3 / sql.js: names the COLUMN, behind the compiled statement with its bound values. */ +const sqliteDuplicate = () => + Object.assign( + new Error( + "update `doc` set `email` = 'a@b.example', `updated_at` = '2026-09-02T09:47:44.127Z' " + + "where `id` = 'r2' - UNIQUE constraint failed: doc.email", + ), + { code: 'SQLITE_CONSTRAINT_UNIQUE' }, + ); + +/** node-postgres: the column is in the DETAIL line, not the message. */ +const postgresDuplicate = () => + Object.assign(new Error('duplicate key value violates unique constraint "doc_email_key"'), { + code: '23505', + detail: 'Key (email)=(a@b.example) already exists.', + }); + +/** mysql2: names the INDEX. `uniqueViolationColumn` refuses to read it as a column. */ +const mysqlDuplicate = () => + Object.assign(new Error("Duplicate entry 'a@b.example' for key 'idx_doc_email'"), { + code: 'ER_DUP_ENTRY', + errno: 1062, + }); + +/** + * driver-memory (#13197 / #13239): already an ADR-0112 envelope, in the + * platform's own vocabulary — the declared-index sentence measured on a real + * `InMemoryDriver` for this card, which names the KEY COLUMNS in parentheses + * and no single column, so `uniqueViolationColumn` answers `undefined` for it. + */ +const memoryDuplicate = () => + Object.assign( + new Error( + 'Unique constraint violated on `doc` over (`email`): a record with the values ' + + '{"email":"a@b.example"} already exists. No record was written.', + ), + { code: 'UNIQUE_VIOLATION', status: 409 }, + ); + +/* -------- the negative side: failures that must NOT change shape --------- */ + +const notNullViolation = () => + Object.assign(new Error('NOT NULL constraint failed: doc.title'), { + code: 'SQLITE_CONSTRAINT_NOTNULL', + }); + +const missingTable = () => + Object.assign(new Error('SQLITE_ERROR: no such table: doc'), { code: 'SQLITE_ERROR' }); + +const deadlock = () => Object.assign(new Error('deadlock detected'), { code: '40P01' }); + +const unreachableStore = () => + Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' }); + +/* -------------------------------------------------------------------------- + * Rig + * ----------------------------------------------------------------------- */ + +const SCHEMA = { + name: 'doc', + fields: { + title: { type: 'text' }, + email: { type: 'text' }, + }, + indexes: [{ name: 'idx_doc_email', fields: ['email'], unique: true }], +}; + +/** The rows the store holds; the by-id path's not-found gate reads them back. */ +const STORED: Row[] = [ + { id: 'r1', title: 'a', email: 'a@b.example' }, + { id: 'r2', title: 'b', email: 'c@d.example' }, + { id: 'r3', title: 'b', email: 'e@f.example' }, +]; + +interface DriverOpts { + /** What `update` / `updateMany` reject with. `null` = accept everything. */ + refuse?: (() => unknown) | null; +} + +function makeDriver(opts: DriverOpts = {}) { + const refuse = opts.refuse ?? null; + const driver: any = { + name: 'fake', + version: '0.0.0', + supports: {}, + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + checkHealth: vi.fn().mockResolvedValue(true), + execute: vi.fn(), + find: vi.fn(async () => []), + // The by-id branch reads the prior row before it writes (#7867's + // not-found gate), so the double must be able to find what it holds. + findOne: vi.fn(async (_obj: string, ast: { where?: { id?: unknown } }) => + STORED.find((row) => row.id === ast?.where?.id) ?? null, + ), + create: vi.fn(), + update: vi.fn(async (_obj: string, id: string, data: Row) => { + if (refuse) throw refuse(); + return { ...(STORED.find((row) => row.id === id) ?? { id }), ...data }; + }), + updateMany: vi.fn(async () => { + if (refuse) throw refuse(); + return 2; + }), + delete: vi.fn(), + count: vi.fn(), + }; + return driver as IDataDriver & { update: any; updateMany: any }; +} + +/** A logger that records what the door's `Update operation failed` line carried. */ +function recordingLogger() { + const errors: Array<{ msg: string; err: unknown; meta: unknown }> = []; + const logger = { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn((msg: string, err?: unknown, meta?: unknown) => { + errors.push({ msg, err, meta }); + }), + }; + return { logger, errors }; +} + +function makeRig(opts: DriverOpts = {}, schema: unknown = SCHEMA) { + registryDouble.getObject.mockReturnValue(schema); + const driver = makeDriver(opts); + const { logger, errors } = recordingLogger(); + const engine = new ObjectQL({ logger }); + engine.registerDriver(driver, true); + return { engine, driver, errors }; +} + +/** The rejection, as the value the caller actually receives. */ +async function refusalOf(run: () => Promise): Promise { + return run().then( + () => { + throw new Error('expected the update to be refused'); + }, + (e) => e as any, + ); +} + +/** The by-id door: a scalar payload id, the row the store holds. */ +const byId = (engine: ObjectQL, data: Row = { email: 'a@b.example' }) => + engine.update('doc', { id: 'r2', ...data }); + +/** The predicate door: N matched rows driven onto ONE unique value. */ +const byPredicate = (engine: ObjectQL, data: Row = { email: 'a@b.example' }) => + engine.update('doc', data, { where: { title: 'b' }, multi: true } as any); + +describe('engine.update — a driver unique violation is a DUPLICATE_RECORD envelope (#14390)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /* ====================================================================== * + * (1) The envelope itself, on the by-id door + * ==================================================================== */ + + describe('the envelope, on the by-id door', () => { + it('carries the ADR-0112 code AND status, with the driver error whole on `cause`', async () => { + const raw = sqliteDuplicate(); + const { engine, driver } = makeRig({ refuse: () => raw }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + // The two halves a refusal test must assert — never `toThrow()` alone. + expect(failure.code).toBe(DUPLICATE_RECORD_CODE); + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + // The driver's own diagnosis is preserved rather than replaced — and it is + // the SAME object, not a copy, so nothing about it was lost in transit. + expect(failure.cause).toBe(raw); + expect(failure).toBeInstanceOf(DuplicateRecordError); + expect(failure.name).toBe('DuplicateRecordError'); + // Non-vacuity: the by-id exit is the one that threw. + expect(driver.update).toHaveBeenCalledTimes(1); + expect(driver.updateMany).not.toHaveBeenCalled(); + }); + + it('names the object, and the COLUMN when the dialect determinably named one', async () => { + const { engine } = makeRig({ refuse: sqliteDuplicate }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + expect(failure.object).toBe('doc'); + expect(failure.field).toBe('email'); + expect(failure.message).toContain("'doc'"); + expect(failure.message).toContain("'email'"); + expect(failure.message).toContain('No record was written'); + }); + + it('reads the column out of Postgres DETAIL, which is not on the message at all', async () => { + const { engine } = makeRig({ refuse: postgresDuplicate }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + expect(failure.field).toBe('email'); + }); + + it('names NO field when the dialect named an INDEX — never the index as a column', async () => { + // MySQL's `for key 'idx_doc_email'` is an index name; `uniqueViolationColumn` + // refuses it under the maintainer's 2026-08-08 ruling (#6544). This door + // does not widen that contract. + const { engine } = makeRig({ refuse: mysqlDuplicate }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + expect(failure.field).toBeUndefined(); + expect('field' in failure).toBe(true); // the class declares it; the VALUE is absent + expect(failure.message).not.toContain('idx_doc_email'); + }); + + it('normalises a driver that already speaks an envelope — one code, not two', async () => { + // driver-memory refuses with `UNIQUE_VIOLATION` / 409. A platform envelope, + // but a DIFFERENT one; an application branching on the update door would + // still need two spellings. The door answers one. + const raw = memoryDuplicate(); + const { engine } = makeRig({ refuse: () => raw }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + expect(failure.cause).toBe(raw); + expect((failure.cause as any).code).toBe('UNIQUE_VIOLATION'); + // Measured on a real InMemoryDriver: its declared-index sentence names the + // key columns in parentheses and no single column, so the column reader + // answers `undefined` and the envelope reports no `field` — the same + // answer the predicate gives, asked of the raw error directly. + expect(uniqueViolationColumn(raw)).toBeUndefined(); + expect(failure.field).toBeUndefined(); + }); + + it('is idempotent — an envelope reaching a seam twice does not nest', async () => { + const inner = sqliteDuplicate(); + const already = new DuplicateRecordError('doc', inner, 'email'); + const { engine } = makeRig({ refuse: () => already }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + expect(failure).toBe(already); + expect(failure.cause).toBe(inner); + }); + }); + + /* ====================================================================== * + * (2) The predicate door — and what a multi-row write does NOT claim + * ==================================================================== */ + + describe('the same contract on the predicate (`multi: true`) door', () => { + it('envelopes the `updateMany` refusal on the same terms', async () => { + const raw = sqliteDuplicate(); + const { engine, driver } = makeRig({ refuse: () => raw }); + await engine.init(); + + const failure = await refusalOf(() => byPredicate(engine)); + + expect(driver.updateMany).toHaveBeenCalledTimes(1); + expect(driver.update).not.toHaveBeenCalled(); + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + expect(failure.cause).toBe(raw); + expect(failure.object).toBe('doc'); + expect(failure).toBeInstanceOf(DuplicateRecordError); + }); + + it('carries `field` only when the dialect named a column — and invents no row attribution', async () => { + // Two matched rows were driven onto one value. The driver's error names + // the column and nothing about WHICH row lost; so does the envelope. + const { engine } = makeRig({ refuse: sqliteDuplicate }); + await engine.init(); + + const failure = await refusalOf(() => byPredicate(engine)); + + expect(failure.field).toBe('email'); + // No "one of N", no row index, no count — the envelope carries exactly the + // keys the by-id envelope carries. A fabricated row attribution is worse + // than an absent one (triage ruling, 2026-09-02). + const single = await refusalOf(() => byId(engine)); + expect(Object.keys(failure).sort()).toEqual(Object.keys(single).sort()); + expect(failure).not.toHaveProperty('rows'); + expect(failure).not.toHaveProperty('count'); + expect(failure).not.toHaveProperty('index'); + expect(failure.message).not.toMatch(/\b(one of|of \d+|rows?|matched)\b/i); + expect(failure.message).toBe(single.message); + }); + + it('names NO field on the predicate door when the dialect named an index', async () => { + const { engine } = makeRig({ refuse: mysqlDuplicate }); + await engine.init(); + + const failure = await refusalOf(() => byPredicate(engine)); + + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + expect(failure.field).toBeUndefined(); + }); + + it('normalises driver-memory’s own `UNIQUE_VIOLATION` on the predicate door too', async () => { + const raw = memoryDuplicate(); + const { engine } = makeRig({ refuse: () => raw }); + await engine.init(); + + const failure = await refusalOf(() => byPredicate(engine)); + + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + expect(failure.cause).toBe(raw); + }); + }); + + /* ====================================================================== * + * (3) The facades reach the same door + * ==================================================================== */ + + describe('the scoped-repository facade reaches the same envelope', () => { + it('`ctx.api.object(name).update(data)` — the form a hook reaches', async () => { + // `ScopedContext.object(name).update(data)` delegates to this same door, so + // it inherits the contract rather than declaring a second one. + const raw = sqliteDuplicate(); + const { engine } = makeRig({ refuse: () => raw }); + await engine.init(); + + const repo = new ScopedContext({} as any, engine as any).object('doc'); + const failure = await refusalOf(() => repo.update({ id: 'r2', email: 'a@b.example' })); + + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + expect(failure.cause).toBe(raw); + }); + + it('`updateById(id, data)` — the by-id alias', async () => { + const raw = postgresDuplicate(); + const { engine } = makeRig({ refuse: () => raw }); + await engine.init(); + + const repo = new ScopedContext({} as any, engine as any).object('doc'); + const failure = await refusalOf(() => repo.updateById('r2', { email: 'a@b.example' })); + + expect(failure.code).toBe('DUPLICATE_RECORD'); + expect(failure.status).toBe(409); + expect(failure.cause).toBe(raw); + expect(failure.field).toBe('email'); + }); + }); + + /* ====================================================================== * + * (4) The negative side — the positive controls, one per class PER EXIT + * ==================================================================== */ + + describe('nothing that is not a unique violation changes shape', () => { + const controls: Array<[string, () => unknown]> = [ + ['a NOT NULL violation', notNullViolation], + ['a missing table', missingTable], + ['a deadlock', deadlock], + ['an unreachable store', unreachableStore], + ]; + + for (const [label, make] of controls) { + it(`${label} leaves the by-id door as the very object the driver threw`, async () => { + const raw = make(); + const { engine, driver } = makeRig({ refuse: () => raw }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + // Identity, not a message match: this is the assertion a future + // "helpful" re-wrap of every driver error would have to break. + expect(driver.update).toHaveBeenCalledTimes(1); + expect(failure).toBe(raw); + expect(failure.code).not.toBe('DUPLICATE_RECORD'); + expect(failure).not.toBeInstanceOf(DuplicateRecordError); + expect(failure.status).toBeUndefined(); + }); + + it(`${label} leaves the predicate door unchanged too`, async () => { + const raw = make(); + const { engine, driver } = makeRig({ refuse: () => raw }); + await engine.init(); + + const failure = await refusalOf(() => byPredicate(engine)); + + expect(driver.updateMany).toHaveBeenCalledTimes(1); + expect(failure).toBe(raw); + expect(failure).not.toBeInstanceOf(DuplicateRecordError); + expect(failure.status).toBeUndefined(); + }); + } + + it('a NOT NULL violation is refused as a NOT NULL violation, not as a conflict', async () => { + // SQLite spells NOT NULL and UNIQUE with the same `… constraint failed: + // t.c` shape, so this is the case a message-matching wrap gets wrong. The + // verdict comes from the shared predicate, which deliberately excludes the + // bare `constraint failed` word pair. + expect(isUniqueViolationError(notNullViolation())).toBe(false); + }); + }); + + /* ====================================================================== * + * (5) Placement — the envelope is on the driver exits, not the outer catch + * ==================================================================== */ + + describe('the envelope is this door’s, not the hook phase’s', () => { + it('a raw unique violation thrown INSIDE an afterUpdate hook is not attributed to this object', async () => { + // The door's outer `catch` also encloses the `afterUpdate` dispatch. A + // hook that reaches a store directly (not through `ctx.api`, which + // envelopes on its own door with its own object name) can surface a RAW + // unique violation from some OTHER table. Enveloping at the outer catch + // would stamp `object: 'doc'` on it — a wrong attribution, which is worse + // than none. So the write itself succeeds here, and what the caller gets + // is exactly what the hook threw. + const nested = sqliteDuplicate(); + const { engine, driver } = makeRig({ refuse: null }); + engine.registerHook('afterUpdate', async () => { + throw nested; + }, { object: 'doc' }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + expect(driver.update).toHaveBeenCalledTimes(1); + expect(failure).toBe(nested); + expect(failure).not.toBeInstanceOf(DuplicateRecordError); + expect(failure.code).toBe('SQLITE_CONSTRAINT_UNIQUE'); + expect(failure.status).toBeUndefined(); + }); + }); + + /* ====================================================================== * + * (6) The envelope does not break the consumers of the raw error + * ==================================================================== */ + + describe('every existing reader of the raw error keeps its answer', () => { + it('the shared predicate still says yes, through the `cause` chain', async () => { + const { engine } = makeRig({ refuse: sqliteDuplicate }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + // `isUniqueViolationError` walks `cause`, so a consumer holding the + // envelope gets the same verdict it got from the raw error. + expect(isUniqueViolationError(failure)).toBe(true); + expect(uniqueViolationColumn(failure)).toBe('email'); + }); + + it('the message never opens with a SQL verb', async () => { + // `@objectstack/rest`'s importer runs every row error through + // `sanitizeRowError`, whose backstop DISCARDS any message starting with + // `insert`/`update`/`delete`/`select`/`with`/`replace` as a leaked + // statement — and the raw message here DOES open with `update`. + const { engine } = makeRig({ refuse: sqliteDuplicate }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + expect(/^\s*(insert|update|delete|select|with|replace)\s/i.test(failure.message)).toBe(false); + expect(/^\s*update\s/i.test(String((failure.cause as Error).message))).toBe(true); + }); + + it('carries none of the driver statement or its bound values', async () => { + // #8682's discipline, one layer out: the compiled statement stays where it + // was, on `cause`. REST's declared-4xx arm ships `message` to the client + // verbatim, so quoting the driver here would move the leak onto the wire. + const { engine } = makeRig({ refuse: sqliteDuplicate }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + expect(failure.message).not.toMatch(/update `doc`/i); + expect(failure.message).not.toContain('set `email`'); + expect(failure.message).not.toContain('a@b.example'); + expect(failure.message).not.toContain("'r2'"); + expect(String((failure.cause as Error).message)).toMatch(/update `doc` set/i); + }); + + it('addresses the application author on `developerMessage`, and does not call the write an insert', async () => { + const { engine } = makeRig({ refuse: sqliteDuplicate }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + + expect(failure.developerMessage).toContain('DUPLICATE_RECORD'); + expect(failure.developerMessage).toContain('cause'); + expect(failure.developerMessage).not.toMatch(/this insert/i); + }); + }); + + /* ====================================================================== * + * (7) The operator log keeps the driver's diagnosis + * ==================================================================== */ + + describe('the `Update operation failed` line', () => { + it('logs the driver’s own diagnosis (the envelope’s `cause`), redacted, not the envelope', async () => { + // Measured on a real sqlite store before this change: the line carried + // `UNIQUE constraint failed: duly_note.email [statement and bound values + // redacted]`. It must carry the same after — the platform logger + // serializes `message` and `stack` only, so logging the envelope would + // drop the failing column. + const { engine, errors } = makeRig({ refuse: sqliteDuplicate }); + await engine.init(); + + const failure = await refusalOf(() => byId(engine)); + expect(failure.code).toBe('DUPLICATE_RECORD'); + + const line = errors.find((e) => e.msg === 'Update operation failed'); + expect(line).toBeDefined(); + const logged = line!.err as Error; + expect(logged).toBeInstanceOf(Error); + expect(logged.message).toContain('UNIQUE constraint failed: doc.email'); + expect(logged.message).not.toContain('Duplicate record refused'); + // …and the redaction still holds: no statement, no bound value. + expect(logged.message).not.toMatch(/update `doc` set/i); + expect(logged.message).not.toContain('a@b.example'); + expect(line!.meta).toEqual({ object: 'doc' }); + }); + + it('logs the same way on the predicate door', async () => { + const { engine, errors } = makeRig({ refuse: postgresDuplicate }); + await engine.init(); + + const failure = await refusalOf(() => byPredicate(engine)); + expect(failure.code).toBe('DUPLICATE_RECORD'); + + const line = errors.find((e) => e.msg === 'Update operation failed'); + expect(line).toBeDefined(); + expect((line!.err as Error).message).toContain('violates unique constraint'); + expect((line!.err as Error).message).not.toContain('Duplicate record refused'); + }); + + it('a non-enveloped failure is logged exactly as before — the driver error itself', async () => { + const raw = deadlock(); + const { engine, errors } = makeRig({ refuse: () => raw }); + await engine.init(); + + await refusalOf(() => byId(engine)); + + const line = errors.find((e) => e.msg === 'Update operation failed'); + expect(line).toBeDefined(); + expect(line!.err).toBe(raw); + }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 0c1b225bfc..36bec3e680 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -10229,6 +10229,38 @@ export class ObjectQL implements IObjectQLEngine { return this.insert(object, rows, { ...(options ?? {}), __partialRowErrors: true } as any); } + /** + * Update one record by id, or every record a predicate selects. + * + * # The error contract on a unique violation (#14390) + * + * A driver's unique-constraint refusal leaves this door as the ADR-0112 + * envelope `DuplicateRecordError` — `code: 'DUPLICATE_RECORD'`, `status: 409`, + * the driver's own error whole on `cause`, and `field` when the dialect + * determinably named the conflicting COLUMN — identically on every driver + * and on both driver exits (the by-id `driver.update` and the predicate + * `driver.updateMany`). It is the insert door's contract (#14095), one verb + * over: before this, the same user action answered `409 DUPLICATE_RECORD` on + * create and `500 INTERNAL_ERROR` on edit, because the raw driver error + * carried neither `code` nor `status` and the REST boundary sanitised it. + * + * A predicate write names no row. `field` is what `uniqueViolationColumn` + * reads off the driver's error; WHICH of the N matched rows conflicted is a + * question that error does not answer, and this door invents no answer to it + * (triage ruling, 2026-09-02: a fabricated row attribution is worse than an + * absent one). + * + * ⛔ Every other driver failure is rethrown UNCHANGED — a NOT NULL violation, + * a deadlock, a missing table, an unreachable store. The verdict is + * `isUniqueViolationError` (`@objectstack/types`), the one predicate the repo + * has for the question; this door adds no dialect knowledge of its own. The + * operator log line (`Update operation failed`) keeps carrying the driver's + * own diagnosis, read through the envelope's `cause`. + * + * The envelope sits on the two driver exits, not on the outer `catch`: that + * `catch` also sees the `afterUpdate` dispatch and the roll-up recompute, and + * a violation raised by a nested driver call in there is not this object's. + */ async update(object: string, data: any, options?: EngineUpdateOptions & WriteObservabilityOptions): Promise { object = this.resolveObjectName(object); this.logger.debug('Update operation starting', { object }); @@ -11156,7 +11188,22 @@ export class ObjectQL implements IObjectQLEngine { updateSchema, hookContext.input.data as Record, opCtx.data as Record, opCtx.context, updateMsgCtx, ); - result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); + // [#14390] The by-id driver exit — where a driver's refusal + // leaves this door, and where a recognised unique violation + // stops being the driver's error. `envelopeUniqueViolation` + // returns everything else untouched (a NOT NULL, a deadlock, a + // missing table, an unreachable store), so only the conflict + // changes shape; the contract is stated on `update()` above. + // Wrapped HERE rather than at the outer `catch` below, and + // deliberately: that `try` also encloses the `afterUpdate` + // dispatch and the roll-up recompute, so an envelope applied + // there would attribute a violation raised by a nested driver + // call inside a hook to THIS object. + try { + result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); + } catch (driverError) { + throw envelopeUniqueViolation(driverError, object); + } } else { // [#6262] A bulk SET clause must not carry `id`. Reaching this // branch AT ALL means `resolveEngineUpdateDispatch` returned @@ -11353,7 +11400,18 @@ export class ObjectQL implements IObjectQLEngine { opCtx.data as Record, opCtx.context, updateMsgCtx, ); // `updateMany` presence is part of the ladder verdict resolved above. - result = await driver.updateMany!(object, ast, hookContext.input.data as Record, hookContext.input.options as any); + // [#14390] The predicate driver exit, enveloped on the same + // terms as the by-id exit above. A multi-row write names no + // row: `field` is whatever `uniqueViolationColumn` reads off + // the driver's error, and NOTHING is invented about which of + // the N matched rows conflicted — a fabricated row attribution + // is worse than an absent one (triage ruling, 2026-09-02), + // exactly as the composite-index case already behaves on insert. + try { + result = await driver.updateMany!(object, ast, hookContext.input.data as Record, hookContext.input.options as any); + } catch (driverError) { + throw envelopeUniqueViolation(driverError, object); + } isPredicateWrite = true; } @@ -11469,7 +11527,15 @@ export class ObjectQL implements IObjectQLEngine { // `set` clause exactly as an INSERT does in its `values` list. Same // redaction, same one argument — the message, the level and the // `object` are unchanged. - this.logger.error('Update operation failed', redactBoundStatement(e) as Error, { object }); + // + // [#14390] …and, as on the insert door (#14095), the line still + // carries what the DATABASE said now that the caller receives an + // envelope: the platform logger serializes `message` and `stack` + // only, so logging the envelope would silently drop the failing + // column and the driver's own frames. The log takes the `cause`; + // `e` is what is rethrown one line down, unchanged. + const logged = e instanceof DuplicateRecordError ? e.cause : e; + this.logger.error('Update operation failed', redactBoundStatement(logged) as Error, { object }); throw e; } }); From 9e3ca44ca0bdc4e357320e59e17482a3c507eb8a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 10:08:01 +0000 Subject: [PATCH 2/4] docs: re-anchor the system-context census after the update-door line shifts (#14390) Regenerated with node scripts/check-system-context-census.mjs --fix; never hand-edited. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- content/docs/permissions/system-context.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 88108c416a..53aaa23a69 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,8 +109,8 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 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:11128` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11296` | +| 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:11160` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11343` | | 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9895` | | 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` | | 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:9943`, `readonly-strict-errors.ts:66` | @@ -119,8 +119,8 @@ that silently does not happen. | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 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` | | 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:6460` | -| 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:11889` | -| 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:11818` | +| 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:11955` | +| 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:11884` | ### 3. Sharing (`plugin-sharing`) @@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| | 62 | `objectql/src/engine.ts:3413` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14238` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 63 | `objectql/src/engine.ts:14304` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | From eb144eaf6c6a0e8a2822adef8ad075857f09f99d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 10:11:40 +0000 Subject: [PATCH 3/4] docs: regenerate the system-context census on the merged tree (#14390) Discharges the os-regen deferral the merge of origin/main recorded for content/docs/permissions/system-context.mdx; regenerated with node scripts/check-system-context-census.mjs --fix, never hand-edited. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 53aaa23a69..f22c3b58a1 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**. | 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) | | 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` | | 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` | -| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:434`, `:488`, `:492`, `:565`, `:595` | +| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:440`, `:494`, `:498`, `:571`, `:601` | | 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` | | 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` | From b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 13:09:24 +0000 Subject: [PATCH 4/4] docs: regenerate the system-context census on the re-merged tree (#14390) Discharges the os-regen deferral recorded by the second merge of origin/main for content/docs/permissions/system-context.mdx; regenerated with node scripts/check-system-context-census.mjs --fix, never hand-edited. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- content/docs/permissions/system-context.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index f22c3b58a1..9137516ae9 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -64,7 +64,7 @@ not on any flag. ## How the flag is set `isSystem` is **server-constructed and never client-supplied**. Inbound HTTP -cannot set it (`packages/rest/src/rest-server.ts:1445`, `:1474`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1522`, `:1551`), and neither can an action body (`packages/runtime/src/domains/actions.ts:404`). It is written by internal callers only, as an option on the engine call: @@ -103,7 +103,7 @@ that silently does not happen. | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | | 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` | | 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` | -| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1477` | +| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1554` | ### 2. Write pipeline and data integrity @@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**. |:--|:---|:---|:---|:---| | 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` | | 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` | -| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:950`, `:1059`, `:3163`, `:3309`, `:3476`, `:3547`, `:3736`, `:3776` | +| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:963`, `:1072`, `:3176`, `:3322`, `:3489`, `:3560`, `:3749`, `:3789` | | 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` | | 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` | | 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` | @@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4629`, `:5992`, `:6240`, `:6671`, `:6864` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4716`, `:6079`, `:6327`, `:6758`, `:6951` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:276`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "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) | | "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` | | "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` | -| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1445`, `:1474`; `domains/actions.ts:404` | +| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1522`, `:1551`; `domains/actions.ts:404` | ---