From d80d4d00929e8d2b9c0d5bd1a62d0896b96b68e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:30:15 +0000 Subject: [PATCH 01/14] wip(objectql): post-hook declared-field door on insert and update (#13657) --- packages/objectql/src/engine.ts | 95 +++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 158532ae8e..248bdac03d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -9489,6 +9489,73 @@ export class ObjectQL implements IObjectQLEngine { if (undeclaredPerRow[i] !== undefined) continue; await this.triggerHooks('beforeInsert', rowHookContexts[i]); } + + // ── [#13657] The POST-hook half of the declared-field door ─────────── + // + // #8737 moved the door above ahead of the hooks so that no work — no + // autonumber, no secret row — is done for a payload about to be refused. + // That is still right, and this does NOT move it (moving it re-opens + // #8682). What it left uncovered is the payload the HOOKS produce: the + // door judged the caller's keys, then `beforeInsert` ran and could add + // any key at all, and from there the three drivers disagreed — + // `memory` ACCEPTED and stored a shadow column, `driver-sql` threw a raw + // `SQLITE_ERROR` with no `status`, `sqlite-wasm` threw a bare `Error` + // with neither. One app, one hook, three meanings decided by which + // driver a deployment happens to run. + // + // So the same function runs a second time over the POST-hook rows. Same + // predicate, not a second one — a hook-written key and a caller-written + // key are the same defect (`declared = enforced`, PD #10) and must read + // identically to a caller: `INVALID_FIELD` / 400, on every driver. + // + // ## Why the platform's own stamps survive it + // + // The wildcard audit hook (`sys_stamp_audit_insert`, `object: '*'`, in + // plugin.ts) writes `created_at` and `updated_at` UNCONDITIONALLY — + // deliberately, because driver-sql creates those two as built-in columns + // on every table whether or not the object declares them. They are + // exactly two of `PLATFORM_PROVISIONED_COLUMNS`, which this function has + // tolerated since #8682 for that same reason, so the platform's own + // hook is covered by the tolerance that already existed rather than by + // an exemption invented here. Its other three stamps (`created_by`, + // `updated_by`, `tenant_id`) are each guarded by an explicit + // `hasField(objectName, …)`, and every other before-hook that ships in + // this repo either writes a DECLARED key or is structurally unable to + // introduce one (pinyin's `__search` returns early unless the object + // declares the companion; the storage file-reference pass rewrites only + // keys already present). ⛔ Do not add a per-key exemption list here: the + // tolerated set is the platform-provisioned one, and a hook that needs + // more than that is writing a field its object should declare. + // + // ## Placement: immediately after the dispatch, before every producer + // + // #8682's rule applied one step in — a refusal must cost nothing — so + // this runs before `resolveSystemInsertOrganization`, before the + // credential channel (`encryptSecretFields` writes a `sys_secret` row), + // before validation and before `applyAutonumbers`. Pre-statement, so the + // driver is never reached: that is also what puts #8682's Half B + // statement-and-values leak out of reach on this path, since both SQL + // refusals only ever came from the driver's own error string. + // + // Merged into `undeclaredPerRow` rather than thrown directly so partial + // mode behaves exactly as it does for a caller-written key: the row is + // culled, its siblings are unaffected, and the seed into `rowErrors` + // below happens before the first pass with a side effect. A row the + // PRE-hook door already refused ran no hook, so its verdict is kept as + // it stands — re-reporting it would replace the original error object + // with an equal one for no reason. + const postHookUndeclared = undeclaredWriteFieldErrors( + object, + this._registry.getObject(object) as { fields?: unknown } | undefined, + rowHookContexts.map((rowCtx) => rowCtx.input.data), + ); + for (let i = 0; i < postHookUndeclared.length; i++) { + if (undeclaredPerRow[i] === undefined) undeclaredPerRow[i] = postHookUndeclared[i]; + } + if (!partialRowMode) { + const postRefusal = postHookUndeclared.find((e) => e !== undefined); + if (postRefusal) throw postRefusal; + } // Thread the open transaction (if any) into the driver-facing // options so that knex's `.transacting(trx)` is honoured. Without // this, calls inside a `engine.transaction(...)` block would deadlock @@ -10467,6 +10534,34 @@ export class ObjectQL implements IObjectQLEngine { } } + // ── [#13657] The POST-hook half of the declared-field door ────────── + // + // The insert path's twin, applied to the second write verb — same + // function, same envelope, same reason (see the long-form note at the + // `insert()` call site, which is not restated here). The #8738 door + // above stays exactly where it is; this ADDS the check the hooks' own + // output never had. + // + // Placed at the CONFLUENCE of the two branches, which is what makes one + // call sufficient: the by-id branch mutates `hookContext.input.data` in + // place through `triggerHooks`, and the predicate branch's + // `dispatchPerRowBeforeHooks` accumulates each row context's payload + // back onto the SAME `batchCtx.input.data` after every dispatch (its D3 + // half). So by this line `hookContext.input.data` is the final + // post-hook payload on either path, and it is still pre-statement: + // ahead of both readonly strips, `evaluateValidationRules` and every + // `driver.update` / `driver.updateMany` dispatch below. + // + // Single verdict, thrown: `update()` takes one payload and has no + // partial-row mode to carry a per-row verdict into — the same shape + // #8738 wrote down for the pre-hook door one screen up. + const postHookUndeclaredUpdate = undeclaredWriteFieldErrors( + object, + this._registry.getObject(object) as { fields?: unknown } | undefined, + [hookContext.input.data], + )[0]; + if (postHookUndeclaredUpdate) throw postHookUndeclaredUpdate; + hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any); try { From 558dabc6c87ca1de80de8548ef425dc260009299 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:46:20 +0000 Subject: [PATCH 02/14] test(objectql): pin the post-hook declared-field door across three driver flavours (#13657) --- .../engine-post-hook-undeclared-field.test.ts | 393 ++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 packages/objectql/src/engine-post-hook-undeclared-field.test.ts diff --git a/packages/objectql/src/engine-post-hook-undeclared-field.test.ts b/packages/objectql/src/engine-post-hook-undeclared-field.test.ts new file mode 100644 index 0000000000..1792f45f80 --- /dev/null +++ b/packages/objectql/src/engine-post-hook-undeclared-field.test.ts @@ -0,0 +1,393 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #13657 — the declared-field door's POST-HOOK half. +// +// #8682 / #8738 put the door in and PR #8737 moved it AHEAD of the `before*` +// hooks and ahead of statement construction, so that no work (above all, no +// autonumber) is consumed by a payload about to be refused. That is correct and +// this suite does not move it — `engine-undeclared-field-preflight.test.ts` and +// `engine-undeclared-update-field.test.ts` still pin it where it is. +// +// What that placement left uncovered is the payload the HOOKS produce. The door +// judged the caller's keys; `beforeInsert` then ran and could add any key at +// all; and from there the three shipped drivers did three different things — +// measured on `@objectstack/*` 17.1.0, one `beforeInsert` hook setting +// `ctx.input.tax_rate = 10` on an object that does not declare it: +// +// memory ACCEPTED — `tax_rate: 10` stored and returned on read +// driver-sql refused — raw `SQLITE_ERROR`, no `status` +// sqlite-wasm refused — a bare `Error`, no `code` and no `status` +// +// One app, one hook, three meanings decided by which driver a deployment +// happens to run, and nothing in the app can tell which one it is. The +// `memory` outcome is the security half: `fieldPermissions` is a POSITIVE +// declaration keyed by field name (`FieldMasker.detectForbiddenWrites` reports +// only fields explicitly `editable: false`), so a key the object never declares +// can carry no entry, is never an offender, and lands in storage outside +// field-level security — where no view, formula, index or permission can name +// it. +// +// ## What this suite pins, and how it reads the drivers +// +// The repair is an engine-level refusal, so the claim under test is +// UNREACHABILITY: after the door, no driver is reached at all, which is why all +// three answer identically. The three doubles below reproduce the three +// measured post-door behaviours — accept-and-store, raw `SQLITE_ERROR`, bare +// `Error` — and every convergence test asserts BOTH halves: one identical +// ADR-0112 envelope out, and zero writes recorded on each double. ⚠️ Stated +// honestly: these are the three doors this suite EXERCISES (the engine's +// `create` / `bulkCreate` / `update` / `updateMany` dispatches through +// `IDataDriver`). It does not boot the real `driver-sql` or `sqlite-wasm` +// packages — objectql depends on neither — so the claim pinned here is that the +// engine refuses before any driver dispatch, not that those two packages were +// re-measured. + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from './engine.js'; + +function silentLogger() { + const logger: any = { + trace() {}, debug() {}, info() {}, warn() {}, error() {}, fatal() {}, + child() { return logger; }, + }; + return logger; +} + +/** + * The three post-door driver behaviours the card measured, as doubles. + * + * `writes` is the load-bearing observable: a refusal that merely produced the + * right envelope while still dispatching would leave the divergence — and + * #8682's Half B statement leak — exactly where they were. + */ +type Flavour = 'memory' | 'sql' | 'wasm'; + +function makeDriver(flavour: Flavour) { + const writes: Array<{ fn: string; data: Record }> = []; + const stored = new Map>(); + + /** What each flavour does when an undeclared key reaches it. */ + const refuse = (object: string, data: Record, undeclared: string): never => { + if (flavour === 'sql') { + // driver-sql / knex: the bound statement AND its values, then the + // database's own diagnostic. `code` is the backend's, `status` absent. + const err: any = new Error( + `insert into \`${object}\` (\`${undeclared}\`) values (10) returning * - table ${object} has no column named ${undeclared}`, + ); + err.code = 'SQLITE_ERROR'; + throw err; + } + // sqlite-wasm: a bare Error — no `code`, no `status`. + throw new Error(`no such column: ${undeclared}`); + }; + + const declaredByThisDouble = new Set(['id', 'name', 'description', 'account_number', 'created_at', 'updated_at']); + + const driver: any = { + name: flavour, version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(_o: string) { return [...stored.values()]; }, + async findOne(_o: string, ast: any) { + const id = ast?.where?.id; + return (typeof id === 'string' ? stored.get(id) : [...stored.values()][0]) ?? null; + }, + async create(object: string, data: Record) { + writes.push({ fn: 'create', data: { ...data } }); + const bad = Object.keys(data).find((k) => !declaredByThisDouble.has(k)); + // `memory` is the ACCEPTING flavour: it spreads the payload, so the stray + // key is persisted and read back — the shadow column. + if (bad && flavour !== 'memory') refuse(object, data, bad); + const id = (data.id as string) ?? `rec_${writes.length}`; + const row = { id, ...data }; + stored.set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + writes.push({ fn: 'update', data: { ...data } }); + const bad = Object.keys(data).find((k) => !declaredByThisDouble.has(k)); + if (bad && flavour !== 'memory') refuse(object, data, bad); + const row = { ...(stored.get(id) ?? { id }), ...data, id }; + stored.set(id, row); + return row; + }, + async updateMany(object: string, _ast: any, data: Record) { + writes.push({ fn: 'updateMany', data: { ...data } }); + const bad = Object.keys(data).find((k) => !declaredByThisDouble.has(k)); + if (bad && flavour !== 'memory') refuse(object, data, bad); + for (const [id, row] of stored) stored.set(id, { ...row, ...data, id }); + return stored.size; + }, + async delete() { return true; }, + async deleteMany() { return 0; }, + async count() { return stored.size; }, + async bulkCreate(object: string, rows: Record[]) { + const out: Record[] = []; + for (const r of rows) out.push(await driver.create(object, r)); + return out; + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, writes, stored }; +} + +interface EngineOptions { + flavour?: Flavour; + /** What the `beforeInsert` / `beforeUpdate` hook stamps onto the payload. */ + hookWrites?: Record; + /** Register the hook against `'*'` (the platform's own audit-hook shape). */ + wildcard?: boolean; +} + +async function makeEngine(options: EngineOptions = {}) { + const engine = new ObjectQL({ logger: silentLogger() }); + const { driver, writes, stored } = makeDriver(options.flavour ?? 'memory'); + engine.registerDriver(driver, true); + await engine.init(); + // Two objects, because the registry's own injection decides which half of the + // door a key meets. Measured on this engine, `registerObject` returns `acct` + // with `created_at, created_by, organization_id, owner_id, + // owning_business_unit_id, updated_at, updated_by` ADDED to the authored + // three — so on an ordinary object the audit family is DECLARED and the + // door's own tolerance never has to carry it. + engine.registry.registerObject({ + name: 'acct', + fields: { + id: { name: 'id', type: 'text', primaryKey: true, readonly: true }, + name: { name: 'name', type: 'text' }, + description: { name: 'description', type: 'text' }, + account_number: { name: 'account_number', type: 'autonumber' }, + }, + } as any, 'test'); + // `bare` takes the `systemFields: false` hard opt-out (seed / migration + // tables — `resolveInjectedSystemColumns` injects NOTHING for it). This is + // the object on which the platform's own unconditional `created_at` / + // `updated_at` stamp actually meets the door, so it is where the + // `PLATFORM_PROVISIONED_COLUMNS` tolerance is load-bearing rather than + // shadowed by injection. + engine.registry.registerObject({ + name: 'bare', + systemFields: false, + fields: { + id: { name: 'id', type: 'text', primaryKey: true, readonly: true }, + name: { name: 'name', type: 'text' }, + description: { name: 'description', type: 'text' }, + }, + } as any, 'test'); + + const hookRuns: string[] = []; + if (options.hookWrites) { + const stamp = (ctx: any) => { + hookRuns.push(String(ctx.input.data?.name ?? ctx.input.id ?? '?')); + Object.assign(ctx.input.data, options.hookWrites); + }; + const scope = options.wildcard ? {} : { object: 'acct' }; + engine.registerHook('beforeInsert', stamp, scope as any); + engine.registerHook('beforeUpdate', stamp, scope as any); + } + return { engine, writes, stored, hookRuns }; +} + +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + return null; +} + +const FLAVOURS: Flavour[] = ['memory', 'sql', 'wasm']; + +// ─────────────────────────────────────────────────────────────────────────── +describe('#13657 — a beforeInsert-written undeclared key is refused', () => { + it.each(FLAVOURS)('%s: the ADR-0112 envelope, identical on every driver', async (flavour) => { + const { engine, writes } = await makeEngine({ flavour, hookWrites: { tax_rate: 10 } }); + + const refusal = await refusalOf(() => engine.insert('acct', { name: 'ok' })); + + // The caller path's answer, not any driver's. On `origin/main` this read: + // memory -> no refusal at all; sql -> code 'SQLITE_ERROR', status + // undefined; wasm -> code undefined, status undefined. + expect(refusal?.code).toBe('INVALID_FIELD'); + expect(refusal?.status).toBe(400); + expect(refusal?.field).toBe('tax_rate'); + expect(refusal?.object).toBe('acct'); + expect(refusal?.message).toBe("Unknown field 'tax_rate' on object 'acct'"); + // Unreachability — the reason all three agree. + expect(writes).toHaveLength(0); + }); + + it('memory no longer stores the shadow column', async () => { + const { engine, stored } = await makeEngine({ flavour: 'memory', hookWrites: { tax_rate: 10 } }); + + await refusalOf(() => engine.insert('acct', { name: 'ok' })); + + // The card's headline `memory` reading: `tax_rate: 10` stored and returned + // on read, outside `fieldPermissions` by construction. + expect(stored.size).toBe(0); + }); + + it('A2.5 — the refusal carries no bound statement and no values', async () => { + const { engine } = await makeEngine({ flavour: 'sql', hookWrites: { tax_rate: 10 } }); + + const refusal = await refusalOf(() => engine.insert('acct', { name: 'ok' })); + + // #8682 Half B: both SQL refusals used to carry the full bound INSERT with + // its values. Refusing pre-statement puts that out of reach on this path. + expect(refusal?.message).not.toMatch(/insert into/i); + expect(refusal?.message).not.toMatch(/values/i); + expect(refusal?.message).not.toContain('10'); + }); + + it('refusing costs no autonumber — #8737`s rule, one step in', async () => { + const { engine } = await makeEngine({ flavour: 'memory' }); + const before: any = await engine.insert('acct', { name: 'ok-1' }); + + // Bind the offending hook only now, so the first two writes are clean. + engine.registerHook('beforeInsert', (ctx: any) => { + if (ctx.input.data?.name === 'bad') ctx.input.data.tax_rate = 10; + }, { object: 'acct' }); + + await refusalOf(() => engine.insert('acct', { name: 'bad' })); + const after: any = await engine.insert('acct', { name: 'ok-2' }); + + // No gap: the refused row never reached `applyAutonumbers`. + expect(before.account_number).toBe('0001'); + expect(after.account_number).toBe('0002'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#13657 — a beforeUpdate-written undeclared key is refused', () => { + it.each(FLAVOURS)('%s: by-id update answers the same envelope', async (flavour) => { + const { engine, writes } = await makeEngine({ flavour }); + const row: any = await engine.insert('acct', { name: 'ok' }); + writes.length = 0; + + engine.registerHook('beforeUpdate', (ctx: any) => { ctx.input.data.tax_rate = 10; }, { object: 'acct' }); + // `update(object, data, options)` — the id rides INSIDE the payload. + const refusal = await refusalOf(() => engine.update('acct', { id: row.id, name: 'renamed' })); + + expect(refusal?.code).toBe('INVALID_FIELD'); + expect(refusal?.status).toBe(400); + expect(refusal?.field).toBe('tax_rate'); + expect(refusal?.message).toBe("Unknown field 'tax_rate' on object 'acct'"); + expect(writes).toHaveLength(0); + }); + + it.each(FLAVOURS)('%s: the PREDICATE (multi) branch answers it too', async (flavour) => { + const { engine, writes } = await makeEngine({ flavour }); + await engine.insert('acct', { name: 'a' }); + await engine.insert('acct', { name: 'b' }); + writes.length = 0; + + // The per-row before dispatch accumulates each row's payload back onto the + // batch context, so the door at the confluence sees the final payload. + engine.registerHook('beforeUpdate', (ctx: any) => { ctx.input.data.tax_rate = 10; }, { object: 'acct' }); + const refusal = await refusalOf(() => + engine.update('acct', { description: 'x' }, { multi: true, where: {} } as any), + ); + + expect(refusal?.code).toBe('INVALID_FIELD'); + expect(refusal?.status).toBe(400); + expect(writes).toHaveLength(0); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// A2.2 — the census control. These are the writes shipped hooks actually make; +// refusing any of them would turn a hole-closing fix into a regression. +describe('#13657 — what shipped before-hooks write still passes', () => { + it('the platform audit stamp survives on a `systemFields: false` object', async () => { + // Faithful to `ObjectQLPlugin.registerAuditHooks` (`plugin.ts`), whose + // `sys_stamp_audit_insert` is registered with `object: '*'` and writes + // `created_at` / `updated_at` UNCONDITIONALLY — deliberately, because + // driver-sql creates those two as built-in columns on every table whether + // or not the object declares them. + // + // `bare` is the case that would break: `systemFields: false` means the + // registry injects nothing, so these two keys are undeclared at the door + // and are carried solely by `PLATFORM_PROVISIONED_COLUMNS`. A post-hook + // check on the RAW declared set would refuse the platform's own hook here + // and turn this fix into a regression on every seed and migration table. + const now = new Date().toISOString(); + const { engine, stored } = await makeEngine({ + flavour: 'memory', wildcard: true, hookWrites: { created_at: now, updated_at: now }, + }); + + const row: any = await engine.insert('bare', { name: 'ok' }); + + expect(row.name).toBe('ok'); + expect(stored.size).toBe(1); + }); + + it('the tolerated set is EXACTLY `id`, `created_at`, `updated_at` — nothing wider', async () => { + // Measured on the object where injection cannot mask the answer. + for (const key of ['id', 'created_at', 'updated_at']) { + const { engine } = await makeEngine({ + flavour: 'memory', wildcard: true, + hookWrites: { [key]: key === 'id' ? 'rec_fixed' : new Date().toISOString() }, + }); + const refusal = await refusalOf(() => engine.insert('bare', { name: 'ok' })); + expect(refusal, `platform-provisioned '${key}' must pass the post-hook door`).toBeNull(); + } + // …and nothing else. `tenant_id` is the NEAREST MISS and the reason this + // assertion is not decorative: the platform's own audit hook writes it — + // but only behind an explicit `hasField(objectName, 'tenant_id')` guard, + // and the registry does not inject it under any setting. So on an object + // that does not declare it the key must be REFUSED, which is exactly what + // makes that guard in `plugin.ts` load-bearing rather than incidental. + for (const key of ['tenant_id', 'created_by', 'tax_rate']) { + const { engine } = await makeEngine({ + flavour: 'memory', wildcard: true, hookWrites: { [key]: 'v' }, + }); + const refusal = await refusalOf(() => engine.insert('bare', { name: 'ok' })); + expect(refusal?.code, `undeclared '${key}' must be refused`).toBe('INVALID_FIELD'); + expect(refusal?.field).toBe(key); + } + }); + + it('on an ORDINARY object the injected audit family is DECLARED, so it passes as such', async () => { + // The other half of the same fact: `acct` gets `created_by` / `updated_by` + // / `owner_id` / `organization_id` injected by `registerObject`, so the + // platform's `hasField`-guarded stamps meet a declared field and never + // depend on the tolerance above. + const { engine, stored } = await makeEngine({ + flavour: 'memory', wildcard: true, hookWrites: { created_by: 'usr_1', owner_id: 'usr_1' }, + }); + + await engine.insert('acct', { name: 'ok' }); + + expect(stored.size).toBe(1); + expect([...stored.values()][0]?.created_by).toBe('usr_1'); + }); + + it('POSITIVE CONTROL — a hook writing a DECLARED key is accepted and stored', async () => { + // The shape every shipped app hook has (`input.probability = 100` in + // app-crm, `data.priority = 'normal'` in app-todo, `ctx.input.title = …` in + // app-showcase). If this ever fails, the door is refusing app code. + const { engine, stored } = await makeEngine({ + flavour: 'memory', hookWrites: { description: 'derived-by-hook' }, + }); + + const row: any = await engine.insert('acct', { name: 'ok' }); + + expect(row.description).toBe('derived-by-hook'); + expect([...stored.values()][0]?.description).toBe('derived-by-hook'); + }); + + it('POSITIVE CONTROL — the door still fires for a CALLER-supplied key (#8682 unmoved)', async () => { + const { engine, hookRuns } = await makeEngine({ + flavour: 'memory', hookWrites: { description: 'derived-by-hook' }, + }); + + const refusal = await refusalOf(() => engine.insert('acct', { name: 'bad', zzz_nope: 1 } as any)); + + expect(refusal?.code).toBe('INVALID_FIELD'); + expect(refusal?.field).toBe('zzz_nope'); + // Still refused BEFORE the hooks — the pre-hook door has not moved. + expect(hookRuns).toEqual([]); + }); +}); From 41f9f1e7bdb732be28fbf0c77129309edd16ff88 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:57:00 +0000 Subject: [PATCH 03/14] test(objectql): tighten three fixtures whose hooks wrote fields their objects never declared (#13657) --- .../engine-undeclared-update-field.test.ts | 12 +++++++++++- packages/objectql/src/engine.test.ts | 6 +++++- ...panion-read-projection-conformance.test.ts | 19 +++++++++++++++++-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/objectql/src/engine-undeclared-update-field.test.ts b/packages/objectql/src/engine-undeclared-update-field.test.ts index 9a97ef48bc..f3bc28a947 100644 --- a/packages/objectql/src/engine-undeclared-update-field.test.ts +++ b/packages/objectql/src/engine-undeclared-update-field.test.ts @@ -328,8 +328,18 @@ describe('#8738 — the declared-field door on update()', () => { // inject `created_at` / `updated_at` itself and the case would prove // nothing about the door. `id` is the one name the registry does NOT // inject, so it is the door's tolerance being read here, and only its. + // [#13657] `description` is declared alongside `name` because this + // harness's own `beforeUpdate` hook STAMPS it (`derived-for-…`), and the + // post-hook door now judges the hook's output too. Without the + // declaration the fixture would be refused for the hook's key and this + // test would stop measuring the thing it names. The subject is unchanged: + // `id` is still the one name the registry does not inject, so the + // tolerance being read here is still the door's and only its. const { engine, writes } = await makeEngine({ - stubFields: { name: { name: 'name', type: 'text' } }, + stubFields: { + name: { name: 'name', type: 'text' }, + description: { name: 'description', type: 'text' }, + }, }); const refusal = await refusalOf(() => engine.update('acct', { diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index a949690203..fba0c3f585 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -405,7 +405,11 @@ describe('ObjectQL Engine', () => { beforeEach(async () => { engine.registerDriver(mockDriver, true); await engine.init(); - vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: { title: { type: 'text' } } } as any); + // [#13657] `stamped` is declared because this suite's own + // `beforeInsert` hook writes it, and the post-hook door now judges + // the hook's output against this map. The subject — one dispatch + // per row, single-record context shape — is untouched. + vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: { title: { type: 'text' }, stamped: { type: 'text' } } } as any); }); it('fires beforeInsert/afterInsert once per row with the single-record context shape', async () => { diff --git a/packages/objectql/src/search-companion-read-projection-conformance.test.ts b/packages/objectql/src/search-companion-read-projection-conformance.test.ts index 7ce0abec44..cd1d666ec1 100644 --- a/packages/objectql/src/search-companion-read-projection-conformance.test.ts +++ b/packages/objectql/src/search-companion-read-projection-conformance.test.ts @@ -237,11 +237,26 @@ async function makeEngine(declared: boolean): Promise { return { engine, store }; } -/** The `plugin-pinyin-search` write hook, in miniature. */ +/** + * The `plugin-pinyin-search` write hook, in miniature. + * + * [#13657] Carries `stampCompanion`'s DECLARATION GUARD + * (`companion-projection.ts`: `if (!schema?.fields?.[SEARCH_COMPANION_FIELD]) + * return;`). It was missing here, which made this double LOOSER than the hook + * it stands in for — the #4550 failure shape one layer down: in the + * `declared: false` arm it stamped a column the real hook returns early on, so + * the arm exercised a write that cannot happen in shipped code. (In that arm + * the real plugin does not bind these hooks at all: the column is undeclared + * because pinyin is OFF.) The arm's subject is unchanged — the stored row + * still carries the blob, seeded directly by the fixture, and every door must + * still project it away. + */ function bindCompanionStamp(engine: ObjectQL): void { - const stamp = (ctx: { input?: { data?: unknown } }): void => { + const stamp = (ctx: { object?: string; input?: { data?: unknown } }): void => { const data = ctx?.input?.data; if (!data || typeof data !== 'object' || Array.isArray(data)) return; + const schema: any = ctx?.object ? engine.registry.getObject(ctx.object) : undefined; + if (!schema?.fields?.[SEARCH_COMPANION_FIELD]) return; const row = data as Row; if (!('name' in row)) return; row[SEARCH_COMPANION_FIELD] = BLOB; From 60aa03e2e91b1e9e74bd9c3928f8083903088ef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:08:08 +0000 Subject: [PATCH 04/14] test(runtime): repoint the L2 body driver-split pin at the convergence #13657 creates --- ...eld-write-driver-split.integration.test.ts | 203 +++++++++++------- 1 file changed, 131 insertions(+), 72 deletions(-) diff --git a/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts b/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts index 1ee0e021c5..01619a6ff4 100644 --- a/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts +++ b/packages/runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts @@ -21,17 +21,26 @@ * It neither rejects the unknown key nor strips it. * 3. `engine.ts` hands the row to `driver.create` / the driver's update. * - * So the driver decides, and the two families disagree: + * ...and, until #13657, the driver decided — so the two families disagreed: * - * • SQL — the stray column reaches the statement and the WHOLE write fails. - * Nothing is stored, and the error names a column, not a field, far from + * • SQL — the stray column reached the statement and the WHOLE write failed. + * Nothing was stored, and the error named a column, not a field, far from * the body that wrote it. * • Schemaless (memory, and MongoDB on the same `...data` spread) — the key - * IS persisted, as an undeclared column nothing downstream reads. + * WAS persisted, as an undeclared column nothing downstream reads. + * + * [#13657] That divergence is CLOSED. The declared-field door now has a + * POST-hook half (`undeclaredWriteFieldErrors`, run again over the payload the + * `before*` hooks produced, before any statement is built), so a body-written + * undeclared key is refused by the object's FIELD MAP — `INVALID_FIELD` / 400, + * identically on both families, with no driver reached and therefore none left + * to disagree. Link 3 above is the one that changed; links 1 and 2 are intact + * and this file still proves them, because the door sits BELOW them. * * The lint messages, the `ScriptBodySchema` / `ActionSchema.body` notes and - * `content/docs/automation/hook-bodies.mdx` all describe this split; if any of - * them drifts back to "silently never lands", one half of this file fails. + * `content/docs/automation/hook-bodies.mdx` all describe what happens here; if + * any of them drifts back to "silently never lands" — or forward to "the + * answer depends on your driver" — this file fails. * * Every case runs the FULL chain — real QuickJS sandbox, real hook body, real * engine, real driver — so link 1 is proved rather than assumed: if @@ -74,15 +83,19 @@ * sentences fail until they are rewritten — which is the half a sentence could * never do for itself (#6664, ruling C). * - * Why it has to stay: the whole point of this file is a PRODUCT divergence - * between two driver families — writing an undeclared field is rejected as a - * WHOLE statement by the SQL family, and accepted verbatim by the schemaless - * family. Pinning a divergence needs both arms. The SQL arm is `SqlDriver`; the - * schemaless arm needs a backend that has no schema to check the key against, - * and `InMemoryDriver` is the cheapest honest one (MongoDB behaves the same on - * the same `...data` spread, but would put a real database in CI's path). - * Delete this arm and the guardrail silently becomes a one-sided assertion - * about SQL — the divergence stops being pinned at all. + * Why it has to stay — and the reason survives #13657 intact, one word over. + * This file used to pin a PRODUCT DIVERGENCE between two driver families + * (rejected as a whole statement by SQL, accepted verbatim by the schemaless + * family); it now pins the CONVERGENCE that replaced it. Either way the claim + * is about both families at once, so it needs both arms. The SQL arm is + * `SqlDriver`; the schemaless arm needs a backend that has no schema to check + * the key against, and `InMemoryDriver` is the cheapest honest one (MongoDB + * behaves the same on the same `...data` spread, but would put a real database + * in CI's path). Delete this arm and the guardrail silently becomes a one-sided + * assertion about SQL — and "identical on every driver", the whole point of + * #13657, stops being pinned at all. ⚠️ If anything, the schemaless arm matters + * MORE now: it is the family that used to accept the key, so it is the arm that + * would witness a regression first. * * Why the freeze does not forbid it: #5499 froze *investment* in driver-memory * (defect fixes, feature work). Using it as a reference implementation is not @@ -202,11 +215,13 @@ const CORRECT_HOOK = { * never exercised. * * The subject is unchanged and still measured on both families: a key a BODY - * writes is added AFTER the door and still reaches the driver verbatim, so - * `applyMutationsToInput` → `validateRecord`'s `if (!def) continue` → the - * driver is intact, and it is what `content/docs/automation/hook-bodies.mdx` - * ("What still happens at runtime") and the two lint messages describe. The - * caller-payload half now has its own cases below, pinning the door. + * writes is added AFTER the PRE-hook door, so `applyMutationsToInput` → + * `validateRecord`'s `if (!def) continue` is still intact and still proved + * here. [#13657] What it reaches is no longer the driver: the POST-hook half of + * the door refuses it first, on both families. The caller-payload half has its + * own cases below, pinning the pre-hook door — which #13657 deliberately did + * NOT move, since #8737 put it ahead of the hooks so a refused payload consumes + * no autonumber. */ const UPDATE_TYPO_HOOK = { name: 'deal_stage_typo_update', @@ -228,7 +243,7 @@ const UPDATE_TYPO_HOOK = { */ const ABSENT_TENANCY_TABLE = 'sys_organization'; -describe('#4271 an undeclared field written by an L2 body — the real runtime split', () => { +describe('#4271 / #13657 an undeclared field written by an L2 body — one answer on both families', () => { let engine: ObjectQL | null = null; let dir: string | null = null; /** [#10629] The expected-noise capture belonging to the latest boot. */ @@ -242,7 +257,18 @@ describe('#4271 an undeclared field written by an L2 body — the real runtime s // failure here can never leave the engine running. Unconditional on purpose: // a memory boot declares an EMPTY expectation, so this still fails loudly if // a boot ever forgets to install a capture at all. - expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + // + // [#13657] `required` is narrowed to nothing — the documented remedy for + // "a table read on only SOME of a file's paths", which is what + // `sys_organization` became here. The single-tenant probe runs on the way + // to the STATEMENT, and the post-hook door now refuses the body-written + // typo before that: the refusal paths never read the table, while the + // control and the pre-image read on update still do. Requiring it would + // redden the refusal cases for a read they are correct not to perform. + // ⛔ Narrowed, NOT relaxed: the capture still withholds the refusal + // wherever it does fire, and a capture that was never installed still + // fails through the `??` branch below. + expect(noise?.silentChannels([]) ?? ['no capture was installed']).toEqual([]); noise = null; }); @@ -283,73 +309,106 @@ describe('#4271 an undeclared field written by an L2 body — the real runtime s return engine; } - // ─── SQL: a loud failure that loses the whole write ──────────────────────── + // ─── [#13657] Both families, one answer ─────────────────────────────────── - describe('SQL driver (better-sqlite3, real table)', () => { - it('fails the WHOLE insert at the driver — it is not a silent no-op', async () => { - const e = await bootSql(TYPO_HOOK); - // The body runs clean in the sandbox; the throw comes from the database. - await expect(e.insert('deal', { stage: 'open', amount: 10 })) - .rejects.toThrow(/stagee/); + /** + * [#13657] What this block used to pin, and why it does not any more. + * + * Until #13657 these were two arms because the runtime gave two answers to + * one question. A key an L2 body wrote was added AFTER the declared-field + * door (#8682 / #8738, moved ahead of the hooks by #8737), so nothing between + * `applyMutationsToInput` and the driver judged it, and the DRIVER decided: + * + * SQL the stray column entered the statement and the WHOLE write + * failed — a raw `SQLITE_ERROR`, no `status`, and the bound + * statement AND its values quoted back in the message; + * schemaless `InMemoryDriver.create` spread `...data`, so the key was + * PERSISTED as an undeclared column nothing downstream reads — + * and, because `fieldPermissions` is keyed by declared field + * name, one that field-level security can never gate. + * + * One app, one body, two meanings decided by which driver a deployment + * happened to run — and nothing in the app could tell which. #13657 added the + * POST-hook half of the door, so the key is now refused by the object's FIELD + * MAP before any statement is built. There is no driver left to disagree. + * + * ⚠️ The file's purpose is unchanged: it still exists to stop one sentence + * from drifting, and it still runs the FULL chain — real QuickJS sandbox, + * real body, real engine, real driver — on BOTH families. What changed is the + * sentence. `it.each` over the two boots is deliberate: writing the assertion + * ONCE and running it on both is what makes "identical on every driver" a + * property this file can state, rather than two arms a reader has to compare + * by eye. + */ + const FAMILIES: Array<[string, (hook?: unknown) => Promise]> = [ + ['SQL (better-sqlite3, real table)', (h) => bootSql(h)], + ['schemaless (memory)', (h) => bootMemory(h)], + ]; + + describe('an L2 BODY-written undeclared key — refused identically on both families', () => { + it.each(FAMILIES)('%s: insert answers the ADR-0112 envelope', async (_name, bootFamily) => { + const e = await bootFamily(TYPO_HOOK); + + const err: any = await e.insert('deal', { stage: 'open', amount: 10 }).catch((x: unknown) => x); + + // The caller path's answer, on both families. Before #13657 this read + // `code: 'SQLITE_ERROR', status: undefined` on SQL and no error at all on + // memory. + expect(err?.code).toBe('INVALID_FIELD'); + expect(err?.status).toBe(400); + expect(err?.field).toBe('stagee'); + expect(err?.message).toBe("Unknown field 'stagee' on object 'deal'"); }, 30000); - it('stores NOTHING — the declared columns of that row are lost too', async () => { - const e = await bootSql(TYPO_HOOK); + it.each(FAMILIES)('%s: insert stores NOTHING — no row, and no shadow column', async (_name, bootFamily) => { + const e = await bootFamily(TYPO_HOOK); + await expect(e.insert('deal', { stage: 'open', amount: 10 })).rejects.toThrow(); - // The half that makes "silently never lands" actively misleading: an - // author told the column vanishes would expect a row with `amount: 10`. + + // Both halves the old wording got wrong, now one fact: SQL loses the + // write (as it always did) and memory no longer keeps the stray key. expect(await e.find('deal', { where: {} } as any)).toHaveLength(0); }, 30000); - it('fails an UPDATE the same way, and leaves the row untouched', async () => { - const e = await bootSql(UPDATE_TYPO_HOOK); + it.each(FAMILIES)('%s: update is refused too, and the row is untouched', async (_name, bootFamily) => { + const e = await bootFamily(UPDATE_TYPO_HOOK); const row = await e.insert('deal', { stage: 'open', amount: 10 }); - // The caller's payload is entirely DECLARED, so the door passes it; the - // body then adds the typo, and `validateRecord`'s update branch - // `continue`s past the unknown key rather than rejecting it, so the - // driver is still what refuses the write. - await expect(e.update('deal', { id: row.id, stage: 'negotiating' } as any)) - .rejects.toThrow(/stagee/); - const after: any = (await e.find('deal', { where: { id: row.id } } as any))[0]; + + // The caller's payload is entirely DECLARED, so the PRE-hook door passes + // it; the body then adds the typo, and the POST-hook door is what refuses + // it — on both families, before any driver is consulted. + const err: any = await e.update('deal', { id: row.id, stage: 'negotiating' } as any) + .catch((x: unknown) => x); + + expect(err?.code).toBe('INVALID_FIELD'); + expect(err?.status).toBe(400); + const after: any = (await e.find('deal', rowById(row.id)))[0]; expect(after.stage).toBe('open'); expect(after).not.toHaveProperty('stagee'); }, 30000); - it('control: the same body with the field spelled right writes normally', async () => { - const e = await bootSql(CORRECT_HOOK); - const row = await e.insert('deal', { stage: 'open', amount: 10 }); - // Proves the failures above are about the undeclared column and not a - // broken fixture — and that `applyMutationsToInput` does reach the driver. - expect((await e.find('deal', { where: { id: row.id } } as any))[0].stage).toBe('won'); - }, 30000); - }); + it.each(FAMILIES)('%s: the refusal quotes no statement and no values', async (_name, bootFamily) => { + const e = await bootFamily(TYPO_HOOK); - // ─── Schemaless: the stray key is persisted, not dropped ─────────────────── + const err: any = await e.insert('deal', { stage: 'open', amount: 10 }).catch((x: unknown) => x); - // `InMemoryDriver.create` spreads `...data`; `MongoDbDriver.create` spreads - // `...toStorageForms(object, rest)`. Neither consults the declared field set, - // so memory stands in for the whole schemaless family here. - describe('schemaless driver (memory)', () => { - it('accepts the insert and PERSISTS the undeclared key', async () => { - const e = await bootMemory(TYPO_HOOK); - const row = await e.insert('deal', { stage: 'open', amount: 10 }); - const stored: any = (await e.find('deal', { where: { id: row.id } } as any))[0]; - // The other direction the old wording got wrong: it lands. - expect(stored.stagee).toBe('won'); - expect(stored.stage).toBe('open'); - expect(stored.amount).toBe(10); + // #8682's Half B survived on this path: the SQL refusal carried the full + // bound INSERT with its values in the message. Refusing pre-statement is + // what puts it out of reach — pinned on both families so the property is + // about the ENGINE's answer, not about one driver's error string. + expect(String(err?.message)).not.toMatch(/insert into/i); + expect(String(err?.message)).not.toMatch(/\bvalues\b/i); }, 30000); - it('persists it on UPDATE too', async () => { - const e = await bootMemory(UPDATE_TYPO_HOOK); + it.each(FAMILIES)('%s: CONTROL — the same body spelled right writes normally', async (_name, bootFamily) => { + const e = await bootFamily(CORRECT_HOOK); + const row = await e.insert('deal', { stage: 'open', amount: 10 }); - await e.update('deal', { id: row.id, stage: 'negotiating' } as any); - const stored: any = (await e.find('deal', { where: { id: row.id } } as any))[0]; - expect(stored.stagee).toBe('won'); - // The declared key of the same write landed as well — the body's typo - // costs the schemaless family nothing, which is the half of the split - // that makes "it fails" the wrong thing to tell an author here. - expect(stored.stage).toBe('negotiating'); + + // Proves the refusals above are about the undeclared column and not a + // broken fixture — and that `applyMutationsToInput` still reaches the + // driver, which is the link the whole file exists to keep proved. + expect((await e.find('deal', rowById(row.id)))[0].stage).toBe('won'); }, 30000); }); From 759939100b4b83e1892b4c52982c2627642fc883 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:09:53 +0000 Subject: [PATCH 05/14] docs,lint: the L2 body undeclared-key answer is one envelope on every driver (#13657) --- content/docs/automation/hook-bodies.mdx | 13 ++++--- .../lint/src/validate-hook-body-writes.ts | 38 +++++++++++-------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/content/docs/automation/hook-bodies.mdx b/content/docs/automation/hook-bodies.mdx index 368b0d3a55..a009ad9895 100644 --- a/content/docs/automation/hook-bodies.mdx +++ b/content/docs/automation/hook-bodies.mdx @@ -204,19 +204,22 @@ A structured `writes` declaration was considered and dropped ([#3700](https://gi #### What still happens at runtime -An unknown field is **not** caught at runtime, and it does not fail quietly either. The write-path validator walks the object's *declared* fields, so an undeclared key is neither rejected nor stripped, and the sandbox's mutations are copied back onto the payload verbatim. What happens next is the driver's call: +An unknown field **is** caught at runtime, and the answer is the same on every driver. The sandbox's mutations are copied back onto the payload verbatim (`applyMutationsToInput` is a plain `Object.assign`) and the write-path validator still walks only the object's *declared* fields — but since [#13657](https://github.com/objectstack-ai/objectstack/issues/13657) the declared-field door runs a **second** time, over the payload the `before*` hooks produced, before any statement is built: -- **SQL drivers** put the stray column into the statement, so the **whole write fails** with a driver-level error (`table deal has no column named stagee`) — nothing is stored, and the error surfaces far from the authoring mistake. -- **Schemaless drivers** (memory, MongoDB) silently persist the stray key alongside the real ones. +``` +INVALID_FIELD / 400 / Unknown field 'stagee' on object 'deal' +``` + +Identical on `memory`, `driver-sql` and `sqlite-wasm`, because none of them is reached. Before #13657 the driver decided instead, and the two families disagreed — SQL failed the whole write with an untyped `SQLITE_ERROR`, while schemaless drivers silently persisted the stray key as a column nothing downstream reads (and which field-level security, keyed by *declared* field name, could never gate). One app, one body, two meanings decided by which driver a deployment happened to run. -Neither outcome is the one you wanted, and the advisory warning is the earliest signal you get. +The runtime refusal is now the backstop; the advisory warning is still the earliest signal you get, and the one that names the mistake where it was made. Because the existence check is advisory, and every write-side check here is literal-only: - **Treat `hook-body-write-unknown-field` as a build failure by convention.** It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo. - **Check by hand what the parser cannot see.** Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or `"*"` hook, every field must exist on every target. - **Prefer a flow `update_record` node when the write set is fixed — and for *this* check most of all.** A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271) the field-existence check gates there too — `flow-node-write-unknown-field` is an **error**, not the advisory warning a body gets, because a node's `fields` is a literal map next to a literal `objectName`: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort. (The *writability* check now has a hook-side counterpart — see [Writing a `readonly` field](#writing-a-readonly-field) below — but it covers only the `ctx.api` channel.) -- **Exercise the hook against a real object before shipping** — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you. +- **Exercise the hook against a real object before shipping** — the mistake surfaces on the first write, identically on every driver. ### Signature conventions diff --git a/packages/lint/src/validate-hook-body-writes.ts b/packages/lint/src/validate-hook-body-writes.ts index 9547a548b4..48b0c65ebd 100644 --- a/packages/lint/src/validate-hook-body-writes.ts +++ b/packages/lint/src/validate-hook-body-writes.ts @@ -4,24 +4,30 @@ // // An L2 body that writes a field the target object never declares — // `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: 'won' })` — -// runs clean in the QuickJS sandbox and reaches the driver UNFILTERED: +// runs clean in the QuickJS sandbox and reaches the write path UNFILTERED: // `applyMutationsToInput` (runtime/src/sandbox/body-runner.ts) is a plain // `Object.assign`, and `validateRecord` walks declared fields on insert and -// `continue`s past a key with no field def on update. What happens after that -// is DRIVER-DEPENDENT, and neither half is acceptable: +// `continue`s past a key with no field def on update. // -// • SQL — the stray column enters the knex statement and the WHOLE write -// fails with a driver-level error (`table deal has no column named -// stagee`). The write is lost, and the error surfaces far from the -// authoring mistake that caused it. -// • Schemaless (memory, MongoDB) — the driver spreads the payload, so the -// stray key IS persisted: an undeclared column nothing downstream reads. +// [#13657] What happens after that used to be DRIVER-DEPENDENT, and neither +// half was acceptable — SQL failed the whole write with an untyped +// `SQLITE_ERROR` far from the authoring mistake, while schemaless drivers +// (memory, MongoDB) spread the payload and PERSISTED the stray key as a column +// nothing downstream reads. #13657 closed that: the declared-field door now +// runs a second time over the payload the `before*` hooks produced, so the key +// is refused `INVALID_FIELD` / 400 identically on every driver, before any +// statement is built. // -// Either way the mistake is invisible where it is MADE — the #4001 family, if -// not literally its silent-no-op shape. Both runtime outcomes are pinned by +// ⚠️ That does NOT retire this rule — it changes what it is worth. The runtime +// refusal arrives at WRITE time, on whichever record first exercises the +// branch; this rule arrives at AUTHOR time and names the field, the object and +// the body. The mistake is still invisible where it is MADE, which is the +// #4001 family and the whole reason for a build-time check. +// +// The runtime answer is pinned by // `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts` -// so this rule's wording cannot drift from what the runtime does; the same -// split is documented in `content/docs/automation/hook-bodies.mdx`. +// so this rule's wording cannot drift from what the runtime does; the same is +// documented in `content/docs/automation/hook-bodies.mdx`. // // The read side (`hook.condition`, ADR-0032) and the capability surface are // statically checked; until this rule, the write side was the one blind face @@ -817,9 +823,9 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] { path, message: `body writes '${w.field}' to its input, but ${objDesc} ${declares}. The sandboxed script runs ` + - `clean and the value is copied back onto the record payload unfiltered — on a SQL driver the ` + - `stray column then fails the WHOLE write with a driver-level error far from here; on a ` + - `schemaless driver (memory, MongoDB) it is persisted as an undeclared key (#4271).`, + `clean and the value is copied back onto the record payload unfiltered, so the write is then ` + + `REFUSED at run time — INVALID_FIELD / 400, identically on every driver (#4271, #13657). The ` + + `record is never written, and the refusal names the field far from the body that wrote it.`, hint: fixHint(w.field, unionCandidates(targetSets)), }); } else { From b44a6585138c1c0816a7681232a05009edd84162 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:29:26 +0000 Subject: [PATCH 06/14] chore: changeset for the post-hook declared-field door (#13657) --- .changeset/post-hook-undeclared-field-door.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .changeset/post-hook-undeclared-field-door.md diff --git a/.changeset/post-hook-undeclared-field-door.md b/.changeset/post-hook-undeclared-field-door.md new file mode 100644 index 0000000000..583ccaf6a3 --- /dev/null +++ b/.changeset/post-hook-undeclared-field-door.md @@ -0,0 +1,35 @@ +--- +'@objectstack/objectql': patch +'@objectstack/lint': patch +--- + +Refuse an undeclared field a `before*` hook writes, identically on every driver + +The declared-field door (#8682 on insert, #8738 on update) runs before the +`before*` hooks — deliberately, so a payload about to be refused never consumes +an autonumber (#8737). That left the payload the hooks themselves produce +unjudged: a key a `beforeInsert` / `beforeUpdate` hook or an L2 (`language:'js'`) +body wrote went straight to the driver, and the drivers disagreed. `memory` +accepted it and stored a shadow column; `driver-sql` threw a raw `SQLITE_ERROR` +with no `status` and the bound statement and its values quoted back in the +message; `sqlite-wasm` threw a bare `Error` with neither. One app and one hook +meant different things on two deployments, and nothing in the app could tell +which one it was running on. + +The same check now runs a second time over the post-hook payload, before any +statement is built, so a hook-written undeclared key is refused with the caller +path's envelope — `INVALID_FIELD` / **400**, `Unknown field 'x' on object 'y'` — +on every driver, because none of them is reached. The existing pre-hook door is +unchanged and stays exactly where it is. + +This is a security fix as well as a consistency one: `fieldPermissions` is keyed +by declared field name and reports only fields explicitly marked non-editable, so +a key the object never declares can carry no entry and could never be gated by +field-level security. On `memory`-family stores such a value was persisted where +no view, formula, index or permission could name it. + +The platform's own stamps are unaffected. `created_at` / `updated_at` — the two +the built-in audit hook writes unconditionally, because SQL drivers create them +as built-in columns on every table — are already tolerated by this check +alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is +guarded by an explicit declaration test in the hook that writes it. From b5fd717e17f722fc3af2043eca3a2f1507f368f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:00:50 +0000 Subject: [PATCH 07/14] lint: keep the tracker id out of the runtime message string (#13657) --- packages/lint/src/validate-hook-body-writes.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/validate-hook-body-writes.ts b/packages/lint/src/validate-hook-body-writes.ts index 48b0c65ebd..5f2fcd5762 100644 --- a/packages/lint/src/validate-hook-body-writes.ts +++ b/packages/lint/src/validate-hook-body-writes.ts @@ -823,8 +823,11 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] { path, message: `body writes '${w.field}' to its input, but ${objDesc} ${declares}. The sandboxed script runs ` + + // The post-hook declared-field door (#13657) is what refuses it; the + // id stays in this comment rather than in the string, which reaches + // authors and operators who cannot resolve a tracker number. `clean and the value is copied back onto the record payload unfiltered, so the write is then ` + - `REFUSED at run time — INVALID_FIELD / 400, identically on every driver (#4271, #13657). The ` + + `REFUSED at run time — INVALID_FIELD / 400, identically on every driver (#4271). The ` + `record is never written, and the refusal names the field far from the body that wrote it.`, hint: fixHint(w.field, unionCandidates(targetSets)), }); From 30c02e704bd161d02f7f5583b704d8f856cfbed5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:05:43 +0000 Subject: [PATCH 08/14] test(objectql): drop an unused parameter from the driver double's refuse helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEST_DEBT for @objectstack/objectql went 251 -> 252 in CI. The +1 is one TS6133 ('data' is declared but its value is never read) in the new engine-post-hook-undeclared-field.test.ts: the refuse helper took the payload it never reads, because the SQL arm quotes a fixed value in its statement string on purpose — that string reproduces the shape driver-sql used to leak, not the double's own payload. Fixed at the source rather than by raising the entry: the ledger is shrink-only and the gate names raising it maintainer-only. objectql's own typecheck excludes **/*.test.ts, which is why the package's local green said nothing about this file and the debt ledger is what caught it. --- .../engine-post-hook-undeclared-field.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/objectql/src/engine-post-hook-undeclared-field.test.ts b/packages/objectql/src/engine-post-hook-undeclared-field.test.ts index 1792f45f80..31552e297e 100644 --- a/packages/objectql/src/engine-post-hook-undeclared-field.test.ts +++ b/packages/objectql/src/engine-post-hook-undeclared-field.test.ts @@ -66,8 +66,15 @@ function makeDriver(flavour: Flavour) { const writes: Array<{ fn: string; data: Record }> = []; const stored = new Map>(); - /** What each flavour does when an undeclared key reaches it. */ - const refuse = (object: string, data: Record, undeclared: string): never => { + /** + * What each flavour does when an undeclared key reaches it. + * + * Takes only the object and the offending key: the SQL arm quotes a fixed + * value in its statement string on purpose, because the point of that arm is + * the shape driver-sql used to leak (`insert into … values (10)`), not this + * double's own payload. + */ + const refuse = (object: string, undeclared: string): never => { if (flavour === 'sql') { // driver-sql / knex: the bound statement AND its values, then the // database's own diagnostic. `code` is the backend's, `status` absent. @@ -96,7 +103,7 @@ function makeDriver(flavour: Flavour) { const bad = Object.keys(data).find((k) => !declaredByThisDouble.has(k)); // `memory` is the ACCEPTING flavour: it spreads the payload, so the stray // key is persisted and read back — the shadow column. - if (bad && flavour !== 'memory') refuse(object, data, bad); + if (bad && flavour !== 'memory') refuse(object, bad); const id = (data.id as string) ?? `rec_${writes.length}`; const row = { id, ...data }; stored.set(id, row); @@ -105,7 +112,7 @@ function makeDriver(flavour: Flavour) { async update(object: string, id: string, data: Record) { writes.push({ fn: 'update', data: { ...data } }); const bad = Object.keys(data).find((k) => !declaredByThisDouble.has(k)); - if (bad && flavour !== 'memory') refuse(object, data, bad); + if (bad && flavour !== 'memory') refuse(object, bad); const row = { ...(stored.get(id) ?? { id }), ...data, id }; stored.set(id, row); return row; @@ -113,7 +120,7 @@ function makeDriver(flavour: Flavour) { async updateMany(object: string, _ast: any, data: Record) { writes.push({ fn: 'updateMany', data: { ...data } }); const bad = Object.keys(data).find((k) => !declaredByThisDouble.has(k)); - if (bad && flavour !== 'memory') refuse(object, data, bad); + if (bad && flavour !== 'memory') refuse(object, bad); for (const [id, row] of stored) stored.set(id, { ...row, ...data, id }); return stored.size; }, From 66d1cf40b7840d0ce37d362faae6897cc21338b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:05:43 +0000 Subject: [PATCH 09/14] chore(gates): ratchet the query-options-erasure test surface DOWN, 240 -> 236 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ceiling is now above reality, and a ceiling left above reality silently licenses that many new erasures. Written by the gate's own prescribed `pnpm check:query-options-erasure --update`. The four sites left with the rewrite of undeclared-field-write-driver-split.integration.test.ts (its two divergence arms became one it.each over both families) and the three fixture repairs that went with it. The diff is one line — testSurface.sites 240 -> 236; the 67 non-test sites across 17 files are untouched, and nothing anywhere is raised. --- scripts/query-options-erasure-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/query-options-erasure-baseline.json b/scripts/query-options-erasure-baseline.json index c7d801350e..c805529f6d 100644 --- a/scripts/query-options-erasure-baseline.json +++ b/scripts/query-options-erasure-baseline.json @@ -50,6 +50,6 @@ "packages/services/service-settings/src/settings-service.ts": 2 }, "testSurface": { - "sites": 240 + "sites": 236 } } From 901c2fa309a4229b1f915e0a94553517304013e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:41:02 +0000 Subject: [PATCH 10/14] docs(permissions): re-anchor the system-context census after this branch's engine.ts insertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page anchors ~145 citations by file:line; the two pure insertions this branch makes in packages/objectql/src/engine.ts (+67 at old 9491, +28 at old 10469) shifted eight cited lines by +67/+95. Mechanical repair via `node scripts/check-system-context-census.mjs --fix` — line numbers only, same semantic sites (the engine.ts diff is insertion-only, so the old->new line map is exact). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --- content/docs/permissions/system-context.mdx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index b2150be331..c0ffb2e506 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,18 +109,18 @@ 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:10712` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10874` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9605` | +| 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:10807` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10969` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9672` | | 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:1576` | -| 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:9642`, `readonly-strict-errors.ts:66` | +| 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:9709`, `readonly-strict-errors.ts:66` | | 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:5639` | | 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:3574`, `:3584`, `:3611` | | 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:6337` | -| 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:11460` | -| 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:11389` | +| 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:11555` | +| 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:11484` | ### 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:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:13801` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 63 | `objectql/src/engine.ts:13896` | 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 | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "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` | | "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) | -| "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:9588`–`9605` | +| "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:9655`–`9672` | | "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:1514` (#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` | From 3e65b6a19ca159dee0e6e57aa09695cee7287119 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 04:46:19 +0000 Subject: [PATCH 11/14] docs(permissions): re-derive the system-context census anchors after the main merge The merge of origin/main hit `content/docs/permissions/system-context.mdx`, which `.gitattributes` routes `merge=os-regen`. That driver does not text-merge: it defers and leaves git's pre-filled OURS content standing, so the merge commit took the branch blob byte-identically and dropped all 39 anchor values main had re-pointed in the merge window. Re-derived with `node scripts/check-system-context-census.mjs --fix` against the merged tree, which is the only thing that can reach the correct values: they sit on NEITHER side. Every anchor now equals base + main's delta + this branch's delta (e.g. row 18: 10712 -> 10787 on main, -> 10807 on the branch, -> 10882 merged). Prose is untouched; a line-number-normalised compare of both sides is byte-identical, so nothing was chosen between. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --- content/docs/permissions/system-context.mdx | 42 ++++++++++----------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index c0ffb2e506..57f7f920a2 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -47,7 +47,7 @@ nothing to do with elevation. | Declaration | What it is | This page? | |:---|:---|:---:| | `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ | -| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1588` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ | +| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ | | `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ | | `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ | @@ -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:1302`, `:1331`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1389`, `:1418`), 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,24 +103,24 @@ 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:1334` | +| 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:1421` | ### 2. Write pipeline and data integrity | # | 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:10807` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10969` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9672` | -| 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:1576` | -| 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:9709`, `readonly-strict-errors.ts:66` | -| 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:5639` | +| 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:10882` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` | +| 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:1664` | +| 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:9784`, `readonly-strict-errors.ts:66` | +| 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:5705` | | 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:3574`, `:3584`, `:3611` | | 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:6337` | -| 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:11555` | -| 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:11484` | +| 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:6403` | +| 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:11630` | +| 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:11559` | ### 3. Sharing (`plugin-sharing`) @@ -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:413`, `:467`, `:471`, `:544`, `:574` | +| 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:423`, `:477`, `:481`, `:554`, `:584` | | 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` | @@ -143,9 +143,9 @@ The largest single consumer — **20 of the 109 sites**. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 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:333` | -| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:440` | -| 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:850`, `:959`, `:2916`, `:3062`, `:3229`, `:3300`, `:3489`, `:3529` | +| 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` | | 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:4470`, `:5833`, `:6081`, `:6512`, `:6705` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4573`, `:5936`, `:6184`, `:6615`, `:6808` | | 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:246`, `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` | @@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| | 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:13896` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 63 | `objectql/src/engine.ts:13971` | 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 | @@ -195,11 +195,11 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "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` | | "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) | -| "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:9655`–`9672` | -| "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:1514` (#3493 / #6640) | +| "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:9730`–`9747` | +| "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:1302`, `:1331`; `domains/actions.ts:404` | +| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1389`, `:1418`; `domains/actions.ts:404` | --- From 1db3d32197f9915690641b625b9b3d2c07533ec9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 06:52:45 +0000 Subject: [PATCH 12/14] docs(permissions): re-derive the census anchors after the second main merge main landed 18 further commits while this round ran, two of which moved the census page again. The os-regen driver deferred a second time and the merge took OURS byte-identically, dropping main's 5 re-pointed anchor lines; re-derived from the merged tree with `check-system-context-census.mjs --fix` (11 anchors). Prose is again byte-identical between the two sides once line numbers are normalised, so nothing was chosen between. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --- 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 57f7f920a2..04b9e02140 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:1389`, `:1418`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1445`, `:1474`), 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:1421` | +| 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` | ### 2. Write pipeline and data integrity @@ -112,7 +112,7 @@ that silently does not happen. | 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:10882` | | 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` | | 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` | -| 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:1664` | +| 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:1736` | | 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:9784`, `readonly-strict-errors.ts:66` | | 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:5705` | | 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:3574`, `:3584`, `:3611` | @@ -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:4573`, `:5936`, `:6184`, `:6615`, `:6808` | +| 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` | | 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:246`, `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:1389`, `:1418`; `domains/actions.ts:404` | +| "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` | --- From fb8232a2743482fe91e57e741f5f034987fcfbad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 08:00:50 +0000 Subject: [PATCH 13/14] chore(changeset): regrade objectql to minor with the argued BREAKING banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review on PR #13864 ruled the changeset grade dishonest: the file grades '@objectstack/objectql': patch for a change its own text calls a narrowing and a security fix. Regrade to minor, add the BREAKING accept-set banner and the argued bump level in the house form, and answer the ADR-0087 ledger question the banner triggers. '@objectstack/lint': patch is unchanged — that half of the diff is message and comment prose only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01489YWhZEoHT9oXshiyywQy --- .changeset/post-hook-undeclared-field-door.md | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/.changeset/post-hook-undeclared-field-door.md b/.changeset/post-hook-undeclared-field-door.md index 583ccaf6a3..8e7f054e6a 100644 --- a/.changeset/post-hook-undeclared-field-door.md +++ b/.changeset/post-hook-undeclared-field-door.md @@ -1,10 +1,33 @@ --- -'@objectstack/objectql': patch +'@objectstack/objectql': minor '@objectstack/lint': patch --- Refuse an undeclared field a `before*` hook writes, identically on every driver +**BREAKING** accept-set narrowing at the post-hook write door, shipped as `minor` +under the repo's launch-window convention for breaking changes. + +**Bump level, argued**: `@objectstack/objectql` is `minor`, not `patch`. A +`before*` hook or an L2 (`language:'js'`) body writing a key the object never +declares **used to succeed** on the `memory` family — the value reached the +store and persisted as a shadow column — and now **throws**, `INVALID_FIELD` / +**400**, on every driver. That is a narrowing of the accept set on the record +payload, a surface every hook body touches; it is not an instrument or a message +fix, and a hook that relied on either driver-dependent outcome stops working at +run time. The same-package sibling `.changeset/hook-input-symbol-key-refusal.md` +argues exactly this shape — "used to succeed, and now throw. That is a narrowing +of the accept set" — to `minor`, and the launch-window convention is what keeps +it off `major` (pre-1.0 lockstep semantics: a breaking change does not burn a +major version while the stack versions in lockstep — see +`scripts/check-changeset-no-major.mjs`). `patch` would under-declare a change +that turns a passing hook into a throwing one. + +`'@objectstack/lint': patch` is deliberate and stays. That half of the diff is +message and comment prose only: `validateHookBodyWrites` reports the same +findings on the same bodies at the same severity, with wording that now names +the runtime refusal instead of the driver split this change retires. + The declared-field door (#8682 on insert, #8738 on update) runs before the `before*` hooks — deliberately, so a payload about to be refused never consumes an autonumber (#8737). That left the payload the hooks themselves produce @@ -33,3 +56,6 @@ the built-in audit hook writes unconditionally, because SQL drivers create them as built-in columns on every table — are already tolerated by this check alongside `id`; every other stamp (`created_by`, `updated_by`, `tenant_id`) is guarded by an explicit declaration test in the hook that writes it. + + + From 9af92aa34336c3b69fe6a0ef4fed9d612c63a234 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 08:16:59 +0000 Subject: [PATCH 14/14] docs(permissions): re-derive the census anchors from the merged tree The os-regen merge driver deferred on content/docs/permissions/system-context.mdx and git kept OURS byte-identically, dropping main's re-point of row 21 (metadata-protocol/src/protocol.ts:1736 -> :1737). Re-derived with check-system-context-census.mjs --fix against the merged tree; no anchor hand-edited, no row added or removed (405 lines and 65 rows before and after). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01489YWhZEoHT9oXshiyywQy --- 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 04b9e02140..384d048329 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -112,7 +112,7 @@ that silently does not happen. | 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:10882` | | 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` | | 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` | -| 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:1736` | +| 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:1737` | | 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:9784`, `readonly-strict-errors.ts:66` | | 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:5705` | | 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:3574`, `:3584`, `:3611` |