From 0c5b42afce88cffc9ea863248cea07adf6b16622 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 04:01:43 +0000 Subject: [PATCH 1/3] fix(plugin-sharing): run the ADR-0111 D7 inert-grant guard for SYSTEM callers (#8207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SharingService.grant` skipped both of its pre-flights for a system context in one block. The two halves ask different questions and only one may vary by caller: D1 (`assertCanManageShares`) is an AUTHORIZATION check, which a rule rightly skips because a rule is not a principal; D7 is an INERTNESS check — "would any gate ever read a row on this object?" — whose answer does not depend on who asks, because the gates that would consult the row never see the granter. Measured on origin/main @ a7e94e990: the rule evaluator does NOT independently reject the inert object classes. One rule per class, defineRule then evaluateRule under a system context, materialised a real sys_record_share row for all five — public model, owner-less, controlled_by_parent, federated phantom anchor (#8119), bypass object. The inertness verdict is now computed caller-free (`inertGrantReason`) and refused for every caller (`assertNotInertGrant`). The EXISTENCE check stays non-system-only: an unresolvable object name is a caller's mistake (NOT_FOUND), but for the evaluator it is a stored object_name against an engine that may not have that schema registered yet, and absence of a schema is absence of evidence of inertness rather than evidence of it. `POST /sharing/rules/:idOrName/evaluate` maps the refusal to 422 SHARING_NOT_ENABLED — the same code/status pair the per-record shares routes already publish — instead of burying the diagnosis in a 500. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .changeset/system-caller-inert-grant-guard.md | 56 +++ .../plugin-sharing/src/sharing-service.ts | 125 ++++-- .../src/system-caller-inert-grant.test.ts | 384 ++++++++++++++++++ packages/rest/src/rest-server.ts | 11 + ...sharing-rule-evaluate-inert-object.test.ts | 167 ++++++++ 5 files changed, 715 insertions(+), 28 deletions(-) create mode 100644 .changeset/system-caller-inert-grant-guard.md create mode 100644 packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts create mode 100644 packages/rest/src/sharing-rule-evaluate-inert-object.test.ts diff --git a/.changeset/system-caller-inert-grant-guard.md b/.changeset/system-caller-inert-grant-guard.md new file mode 100644 index 0000000000..233b3245fc --- /dev/null +++ b/.changeset/system-caller-inert-grant-guard.md @@ -0,0 +1,56 @@ +--- +"@objectstack/plugin-sharing": patch +"@objectstack/rest": patch +--- + +fix(plugin-sharing): the ADR-0111 D7 inert-grant guard runs for SYSTEM callers too, so the sharing-rule evaluator can no longer materialise rows no gate consults (#8207) + +`SharingService.grant` skipped **both** of its pre-flights for a system context, +in one block, under one justification: *"System callers bypass: the rule +evaluator materialises through here under its own validation."* The two halves +ask different questions, and only one of them may vary by caller. + +- **D1, `assertCanManageShares` — an AUTHORIZATION check.** "May this principal + manage shares on this record?" A sharing rule is not a principal and has no + ownership to prove, so the system skip is correct and is unchanged. +- **D7, the inertness guard — not an authorization check.** "Would any gate ever + read a row on this object?" The gates that would consult the row + (`buildReadFilter`, `checkEdit`, `checkDelete`) never see the granter, so the + answer cannot depend on who asks. Exempting the system context made the guard + answer a question it was not asked. + +**The "own validation" the old comment credited the evaluator with is not +there.** Measured, one rule per class, `defineRule` then `evaluateRule`, both +under a system context: a rule against a `public_read_write` object, an +owner-less object, a `controlled_by_parent` detail, a federated phantom-anchor +object, or a bypass object was accepted and materialised a real +`sys_record_share` row — five for five. `defineRule` never reads the object's +schema, and reconcile hands `grant` whatever `object_name` the rule row carries. +So an authored sharing rule pointed at any of those objects minted rows that +looked granted and enforced nothing, which is the ADR-0078 silently-inert trap +arriving through the one door the guard did not watch. + +The inertness verdict is now computed caller-free and refused for **every** +caller. Refusing costs no live access on either path: the row it declines to +write could never have granted any. + +**What deliberately did NOT move.** The existence check stays non-system-only. An +unresolvable object name is a NOT_FOUND (REST: 404) for a caller who typed it, +but for the evaluator it is a stored `object_name` meeting an engine that may not +have that schema registered at this instant — and absence of a schema is absence +of *evidence* of inertness, not evidence of it. Hard-failing a reconcile pass on +that would refuse a write nobody showed to be inert. An engine with no +`getSchema` at all keeps its existing "it cannot know" skip. + +**Operator-visible effects.** A sharing rule pointed at an object no gate +consults now fails loudly instead of quietly writing nothing usable. Every system +caller of `grant` is the rule evaluator's reconcile, and each of its entry points +already treats a per-rule failure as best-effort: the boot rule backfill, the +object-wide re-grant and the business-unit re-grant queue log the refusal (naming +the rule and the reason) and carry on, and the write hooks catch it, so a user's +insert or update is never failed by it. `POST /api/v1/sharing/rules/:idOrName/evaluate` +now answers **422 `SHARING_NOT_ENABLED`**, naming the object and the reason, +instead of burying the diagnosis in a 500 — the same code-to-status pair the +per-record shares routes already publish. Withdrawal is untouched, so rows minted +by an earlier build stay purgeable through `deleteRule` and through evaluating a +deactivated rule. diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index 69857381d1..1edff3789f 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -862,35 +862,41 @@ export class SharingService implements ISharingService { } /** - * [ADR-0111 D7] The object must be one the sharing gates actually consult — - * otherwise the grant would persist a row no read/write decision ever reads - * (the ADR-0078 silently-inert trap, inverted: "share" succeeds and nothing - * is shared). Bypass objects, `controlled_by_parent` (a detail record's - * access follows its master, ADR-0055), public models, and owner-less - * objects all refuse with SHARING_NOT_ENABLED (REST: 422). An engine - * without schema access skips the check — it cannot know, and this guard is - * an inertness guard, not the authority gate. + * [ADR-0111 D7] Would a `sys_record_share` row on `object` ever be consulted + * by a read/write decision? Returns the REASON it could not be, or `null` + * when it could (which includes "this engine cannot tell"). + * + * ## Why this is a verdict and not an assertion (#8207) + * + * This is an **inertness** check, not an authorization check — and the two + * differ in whether the answer may depend on WHO ASKS. An authorization + * check legitimately varies by caller; inertness does not. Whether a share + * row can ever be read is a property of the OBJECT: the gates that would + * consult it (`buildReadFilter`, `checkEdit`, `checkDelete`) never see the + * granter at all. So the verdict is computed here, caller-free, and the two + * pre-flights below decide only what to do with it. + * + * `null` for an engine that cannot answer — no `getSchema`, or a name it + * does not resolve. Absence of a schema is absence of EVIDENCE of inertness, + * not evidence of liveness, and this function's job is to name a certainty. + * The non-system pre-flight still turns an unresolvable name into NOT_FOUND + * ({@link assertSharingEnforced}); that is an EXISTENCE verdict, which is a + * different act and stays where it was. */ - private assertSharingEnforced(object: string): void { + private inertGrantReason(object: string): string | null { if (this.bypassObjects.has(object)) { - throw new Error( - `SHARING_NOT_ENABLED: '${object}' bypasses record sharing; a share row on it would never be consulted`, - ); + return `'${object}' bypasses record sharing; a share row on it would never be consulted`; } - if (typeof this.engine.getSchema !== 'function') return; + if (typeof this.engine.getSchema !== 'function') return null; const schema = this.engine.getSchema(object); - if (!schema) throw new Error(`NOT_FOUND: unknown object '${object}'`); + if (!schema) return null; const declared = schema?.sharingModel ?? schema?.security?.sharingModel; if (declared === 'controlled_by_parent') { - throw new Error( - `SHARING_NOT_ENABLED: '${object}' is controlled by its parent (master-detail); share the master record instead`, - ); + return `'${object}' is controlled by its parent (master-detail); share the master record instead`; } if (effectiveSharingModel(schema) === 'public' || !hasOwnerField(schema)) { - throw new Error( - `SHARING_NOT_ENABLED: '${object}' is not under record-sharing enforcement ` - + `(public sharing model or no '${OWNER_FIELD}' field); a share row on it would never be consulted`, - ); + return `'${object}' is not under record-sharing enforcement ` + + `(public sharing model or no '${OWNER_FIELD}' field); a share row on it would never be consulted`; } // [#8119] …and an `owner_id` the REGISTRY injected into a FEDERATED object is // not an owner column either — it is the one case `hasOwnerField` answers YES @@ -925,12 +931,44 @@ export class SharingService implements ISharingService { // shares — see `federated-phantom-anchors.ts` for why this is a provenance // test and not an `external` test. if (hasPhantomOwnerAnchor(schema)) { - throw new Error( - `SHARING_NOT_ENABLED: '${object}' is federated (ADR-0015) and its '${OWNER_FIELD}' is the ` + return `'${object}' is federated (ADR-0015) and its '${OWNER_FIELD}' is the ` + `platform's injected anchor, not a remote column — the record-level gates read it off a ` - + `table that does not have it, so a share row on it would never be consulted`, - ); + + `table that does not have it, so a share row on it would never be consulted`; + } + return null; + } + + /** + * [ADR-0111 D7 / #8207] Refuse a grant whose row could never be consulted. + * The caller-independent half of the pre-flight — run for EVERY caller, + * system included (see {@link inertGrantReason} for why "who asks" is not an + * input to this question). + */ + private assertNotInertGrant(object: string): void { + const reason = this.inertGrantReason(object); + if (reason) throw new Error(`SHARING_NOT_ENABLED: ${reason}`); + } + + /** + * The NON-system pre-flight: the object must EXIST, and a share row on it + * must be one the gates would consult. + * + * The existence half is deliberately not part of {@link assertNotInertGrant}. + * A caller who names an object that does not resolve has made a mistake and + * deserves NOT_FOUND (REST: 404); the rule evaluator reconciling under a + * system context has a stored `object_name` and an engine that may not have + * that schema registered at this instant, and hard-failing its pass on that + * would break a legitimate write to answer a question nobody asked. + */ + private assertSharingEnforced(object: string): void { + if ( + !this.bypassObjects.has(object) + && typeof this.engine.getSchema === 'function' + && !this.engine.getSchema(object) + ) { + throw new Error(`NOT_FOUND: unknown object '${object}'`); } + this.assertNotInertGrant(object); } /** @@ -973,9 +1011,40 @@ export class SharingService implements ISharingService { // [ADR-0111 D1/D7] Authorization + posture, service-side so every caller // is covered (#3902 ③ — the REST route used to hand any signed-in user - // straight to this SYSTEM_CTX write path). System callers bypass: the - // rule evaluator materialises through here under its own validation. - if (!context?.isSystem) { + // straight to this SYSTEM_CTX write path). + // + // [#8207] The two halves are split by WHAT THEY ASK, not by convenience. + // + // D1 (`assertCanManageShares`) is an AUTHORIZATION check: "may this + // principal manage shares on this record?". A rule is not a principal + // and has no ownership to prove, so the system context rightly skips it. + // + // D7 (`assertNotInertGrant`) is an INERTNESS check: "would any gate ever + // read this row?". Its answer does not depend on who asks — the gates + // that would consult the row never see the granter — so exempting the + // system context made the guard answer a question it was not asked. + // + // The comment this replaced justified the whole-block skip as "the rule + // evaluator materialises through here under its own validation". MEASURED + // on `origin/main` @ a7e94e990: it does not. A rule defined against a + // `public_read_write` object, an owner-less object, a + // `controlled_by_parent` detail, a federated phantom-anchor object + // (#8119), or a bypass object is accepted by `defineRule` and materialises + // real `sys_record_share` rows through `SharingRuleService.reconcile` — + // five for five, `grantsCreated: 1` each. The "own validation" the old + // comment relied on is not there, so the guard was the only thing standing + // between an authored rule and rows no verdict can ever consult. + // + // Refusing costs no live access, on either path: the row it declines to + // write could never have granted any. Every system caller of this method + // is the rule evaluator's reconcile (`reconcile` / `reconcileForRecord`), + // and each of its entry points already treats a per-rule throw as + // best-effort — the boot backfill, the object-wide re-grant and the + // bu-tree re-grant queue log and continue, and the write hooks catch so a + // user's insert/update is never failed by it. + if (context?.isSystem) { + this.assertNotInertGrant(input.object); + } else { this.assertSharingEnforced(input.object); await this.assertCanManageShares(input.object, input.recordId, context); } diff --git a/packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts b/packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts new file mode 100644 index 0000000000..b12bf85613 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts @@ -0,0 +1,384 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8207] The ADR-0111 D7 inert-grant guard runs for SYSTEM callers too. + * + * ## The distinction these pins hold down + * + * `SharingService.grant` used to skip BOTH of its pre-flights for a system + * context, in one block, with one justification. The two halves ask different + * questions and only one of them may vary by caller: + * + * - **D1, `assertCanManageShares` — AUTHORIZATION.** "May this principal + * manage shares on this record?" A sharing rule is not a principal and has + * no ownership to prove, so skipping it for the evaluator is correct and + * stays. + * - **D7, the inertness guard — NOT authorization.** "Would any gate ever + * read a row on this object?" The gates that would consult the row + * (`buildReadFilter`, `checkEdit`, `checkDelete`) never see the granter, so + * the answer cannot depend on who asks. Exempting the system context made + * the guard answer a question it was not asked. + * + * ## The measurement that made this a defect rather than a tidy-up + * + * The card was filed observation-class, on the explicit condition that someone + * check whether the rule evaluator independently refuses these object classes + * before materialising — because if it did, the guard would have no live + * consumer. It does not. Measured against `origin/main` @ `a7e94e990`, one rule + * per class, `defineRule` then `evaluateRule`, both under a system context: + * + * ``` + * object class defineRule evaluateRule rows minted + * account (private, control) accepted created=1 1 + * whiteboard (public_read_write) accepted created=1 1 + * note (no owner_id) accepted created=1 1 + * detail_item (controlled_by_parent) accepted created=1 1 + * ext_nostamp (phantom anchor) accepted created=1 1 + * sys_user (bypass object) accepted created=1 1 + * ``` + * + * Five inert classes, five real `sys_record_share` rows with `source: 'rule'`, + * no refusal anywhere. `defineRule` never reads the object's schema at all, and + * `reconcile` hands `grant` whatever `object_name` the rule row carries. So the + * "own validation" the removed comment credited the evaluator with is not there, + * and the guard was the only thing between an authored rule and rows no verdict + * can ever consult. {@link describe} block 2 is that measurement, re-run as a + * pin against the fixed build. + * + * ## Why the fixtures assert BOTH directions + * + * Every refusal case here is paired with an ordinary-object case that must + * still SUCCEED — and, where the row is the point, must still be LIVE. A + * one-sided suite would read identically against a build that refused every + * system grant, which is the failure this change's own risk profile points at + * (the boot backfill, the write hooks and the re-grant queues are all system + * callers of `grant`). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { OWNER_FIELD_DEF, assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { ERROR_CODE_LEDGER } from '@objectstack/spec/api'; +import { SharingService } from './sharing-service.js'; +import { SharingRuleService } from './sharing-rule-service.js'; +import { backfillRuleGrants } from './sharing-plugin.js'; + +interface Row { [k: string]: any } + +/** The system context every one of `grant`'s system callers passes. */ +const SYS = { isSystem: true, positions: [], permissions: [] } as any; + +function makeEngine(schemas: Record) { + const tables: Record = {}; + const ensure = (n: string) => (tables[n] ??= []); + function matches(row: Row, f: any): boolean { + if (!f || typeof f !== 'object') return true; + if (Array.isArray(f.$or) && !f.$or.some((x: any) => matches(row, x))) return false; + if (Array.isArray(f.$and) && !f.$and.every((x: any) => matches(row, x))) return false; + for (const [k, v] of Object.entries(f)) { + if (k === '$or' || k === '$and') continue; + const rv = row[k]; + if (v != null && typeof v === 'object' && '$in' in (v as any)) { + if (!(v as any).$in.includes(rv)) return false; + continue; + } + if (rv !== v) return false; + } + return true; + } + return { + _tables: tables, + getSchema(n: string) { return schemas[n]; }, + async find(o: string, opts?: any) { + const f = opts?.filter ?? opts?.where; + return ensure(o).filter(r => matches(r, f)).slice(0, opts?.limit ?? 10000); + }, + async insert(o: string, data: any) { const row = { ...data }; ensure(o).push(row); return row; }, + async update(o: string, idOrData: any, dataOrOpts?: any) { + const data = typeof idOrData === 'object' ? idOrData : dataOrOpts; + const id = typeof idOrData === 'object' ? idOrData.id : idOrData; + const t = ensure(o); const i = t.findIndex(r => r.id === id); + if (i >= 0) t[i] = { ...t[i], ...data }; + return t[i]; + }, + async delete(o: string, opts?: any) { + // [#4550] Pinned to `ObjectQL.delete`'s OWN dispatch predicate — a fake + // looser than the contract it stands in for is how a green suite ships a + // dead route (#4434). + assertEngineDeleteDispatch(opts); + const t = ensure(o); const where = opts?.where ?? {}; + for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1); + return { ok: true }; + }, + }; +} + +/** + * The control: an ordinary LOCAL object under the secure-default private OWD + * with a real, provisioned `owner_id`. Every gate consults share rows on it, so + * a system grant here must keep working — and the row it writes must be live. + */ +const ACCOUNT = { + name: 'account', + sharingModel: 'private', + fields: { id: {}, tier: {}, owner_id: { ...OWNER_FIELD_DEF } }, +}; + +/** The five classes on which a `sys_record_share` row can never be consulted. */ +const INERT_SCHEMAS: Record = { + // 1 — public model: every principal already reads and writes it. + whiteboard: { + name: 'whiteboard', + sharingModel: 'public_read_write', + fields: { id: {}, tier: {}, owner_id: { ...OWNER_FIELD_DEF } }, + }, + // 2 — owner-less: ownership contributes nothing, so nothing composes a + // record filter that a share row could widen. + note: { name: 'note', sharingModel: 'private', fields: { id: {}, tier: {} } }, + // 3 — controlled_by_parent (ADR-0055): access follows the master record. + detail_item: { + name: 'detail_item', + sharingModel: 'controlled_by_parent', + fields: { id: {}, tier: {}, owner_id: { ...OWNER_FIELD_DEF } }, + }, + // 4 — federated phantom anchor (#8119 / #8209): `owner_id` is the platform's + // injected anchor on an object whose storage the platform never + // provisioned, so the gates read it off a table that does not have it. + // Spreads OWNER_FIELD_DEF exactly as `applySystemFields` does. + ext_nostamp: { + name: 'ext_nostamp', + external: { remoteName: 'customers' }, + fields: { id: {}, tier: {}, owner_id: { ...OWNER_FIELD_DEF } }, + }, + // 5 — a bypass object: sharing is not consulted on it at all. + sys_user: { + name: 'sys_user', + isSystem: true, + fields: { id: {}, tier: {}, owner_id: { ...OWNER_FIELD_DEF } }, + }, +}; + +const ALL_SCHEMAS: Record = { + account: ACCOUNT, + ...INERT_SCHEMAS, + sys_record_share: { name: 'sys_record_share' }, + sys_sharing_rule: { name: 'sys_sharing_rule' }, +}; + +const INERT_OBJECTS = Object.keys(INERT_SCHEMAS); + +function seedRows(engine: ReturnType) { + for (const object of ['account', ...INERT_OBJECTS]) { + engine._tables[object] = [ + { id: 'r1', tier: 'gold', owner_id: 'usr_owner' }, + { id: 'r2', tier: 'silver', owner_id: 'usr_owner' }, + ]; + } +} + +// ───────────────────────────────────────────────────────────────────── +// 1 — the guard itself, at the `grant()` seam +// ───────────────────────────────────────────────────────────────────── + +describe('[ADR-0111 D7 / #8207] the inert-grant guard does not ask who is calling', () => { + let engine: ReturnType; + let svc: SharingService; + beforeEach(() => { + engine = makeEngine(ALL_SCHEMAS); + svc = new SharingService({ engine: engine as any }); + seedRows(engine); + }); + + it.each(INERT_OBJECTS)('refuses a SYSTEM grant on %s — the row could never be consulted', async (object) => { + // Pre-fix every one of these RESOLVED and minted a real row (see the + // measurement in this file's header). + await expect( + svc.grant({ object, recordId: 'r1', recipientId: 'usr_grantee' }, SYS), + ).rejects.toThrow(/SHARING_NOT_ENABLED/); + expect(engine._tables.sys_record_share ?? []).toHaveLength(0); + }); + + it.each(INERT_OBJECTS)('the SYSTEM refusal on %s is the SAME verdict the user path gives', async (object) => { + // The point of the card: an inertness check has one answer per object, not + // one answer per caller. Comparing the two messages is what pins that — + // a future guard that refused system callers for some *other* reason would + // still be a guard whose answer depends on who asks. + const asSystem = await svc + .grant({ object, recordId: 'r1', recipientId: 'usr_grantee' }, SYS) + .then(() => null, (e: Error) => e.message); + const asUser = await svc + .grant({ object, recordId: 'r1', recipientId: 'usr_grantee' }, { userId: 'usr_owner' } as any) + .then(() => null, (e: Error) => e.message); + expect(asSystem).toBe(asUser); + expect(asSystem!.startsWith('SHARING_NOT_ENABLED:')).toBe(true); + }); + + it('the code is one the package DECLARES in the ADR-0112 ledger', () => { + // The prefix is what `rest-server.ts` reads to pick 422; a code invented at + // the throw site would fall through to a 500. + expect(ERROR_CODE_LEDGER['@objectstack/plugin-sharing']).toContain('SHARING_NOT_ENABLED'); + }); + + it('ANTI-VACUITY: a SYSTEM grant on an ordinary private object still succeeds', async () => { + // Without this the block above would read identically against a build that + // refused every system grant — which would break the boot backfill, the + // write hooks and both re-grant queues. + await expect( + svc.grant( + { object: 'account', recordId: 'r1', recipientId: 'usr_grantee', accessLevel: 'edit', source: 'rule', sourceId: 'srule_1' }, + SYS, + ), + ).resolves.toMatchObject({ object_name: 'account', recipient_id: 'usr_grantee', source: 'rule' }); + expect(engine._tables.sys_record_share).toHaveLength(1); + }); + + it('ANTI-VACUITY: the row that system grant wrote is LIVE, not merely persisted', async () => { + await svc.grant( + { object: 'account', recordId: 'r1', recipientId: 'usr_grantee', accessLevel: 'edit', source: 'rule', sourceId: 'srule_1' }, + SYS, + ); + // `usr_grantee` owns nothing and holds no bypass, so the only thing that can + // lift this verdict is the share row just minted — the contrast that gives + // the refusals above their meaning. + expect(await svc.canEdit('account', 'r1', { userId: 'usr_grantee' } as any)).toBe(true); + // …and it does not leak to a sibling record of the same object. + expect(await svc.canEdit('account', 'r2', { userId: 'usr_grantee' } as any)).toBe(false); + }); + + it('the D1 MANAGEMENT gate stays system-skipped — a rule has no ownership to prove', async () => { + // The half that legitimately varies by caller. `usr_nobody` owns no record + // and could not manage shares here, but the evaluator is not a principal: + // the same grant must go through under a system context… + await expect( + svc.grant({ object: 'account', recordId: 'r2', recipientId: 'usr_grantee' }, SYS), + ).resolves.toMatchObject({ object_name: 'account' }); + // …and must NOT go through for the user who cannot manage that record. + await expect( + svc.grant({ object: 'account', recordId: 'r2', recipientId: 'usr_grantee' }, { userId: 'usr_nobody' } as any), + ).rejects.toThrow(/PERMISSION_DENIED|NOT_FOUND/); + }); + + it('an UNRESOLVABLE object stays a user-only NOT_FOUND — existence is not inertness', async () => { + // The carve-out that keeps a legitimate system write working: absence of a + // schema is absence of EVIDENCE of inertness, not evidence of it. A user + // who names an object that does not resolve has made a mistake (404); the + // evaluator holds a stored `object_name` against an engine that may not + // have that schema registered at this instant, and hard-failing its pass on + // that would refuse a grant nobody showed to be inert. + await expect( + svc.grant({ object: 'ghost_object', recordId: 'r1', recipientId: 'usr_grantee' }, { userId: 'usr_owner' } as any), + ).rejects.toThrow(/NOT_FOUND/); + await expect( + svc.grant({ object: 'ghost_object', recordId: 'r1', recipientId: 'usr_grantee' }, SYS), + ).resolves.toMatchObject({ object_name: 'ghost_object' }); + }); + + it('an engine with NO schema access still cannot answer, for either caller', async () => { + // The pre-existing "it cannot know" carve-out, unchanged: a `SharingEngine` + // without `getSchema` skips the schema-derived verdicts (the bypass list is + // still consulted — it needs no schema). + const blind: any = { ...makeEngine({}) }; + delete blind.getSchema; + const s = new SharingService({ engine: blind }); + await expect( + s.grant({ object: 'anything', recordId: 'r1', recipientId: 'usr_grantee' }, SYS), + ).resolves.toMatchObject({ object_name: 'anything' }); + await expect( + s.grant({ object: 'sys_user', recordId: 'r1', recipientId: 'usr_grantee' }, SYS), + ).rejects.toThrow(/SHARING_NOT_ENABLED/); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// 2 — the consumer the card is actually about: the rule evaluator +// ───────────────────────────────────────────────────────────────────── + +describe('[#8207] the sharing-rule evaluator can no longer materialise inert rows', () => { + let engine: ReturnType; + let sharing: SharingService; + let rules: SharingRuleService; + const warns: Array<{ msg: string; meta: any }> = []; + + beforeEach(() => { + warns.length = 0; + engine = makeEngine(ALL_SCHEMAS); + sharing = new SharingService({ engine: engine as any }); + rules = new SharingRuleService({ + engine: engine as any, + sharing, + logger: { warn: (msg: string, meta?: any) => { warns.push({ msg, meta }); }, info: () => {} }, + }); + seedRows(engine); + }); + + const define = (object: string) => rules.defineRule({ + name: `share_${object}_gold`, + label: `Share ${object}`, + object, + criteria: { tier: 'gold' }, + recipientType: 'user', + recipientId: 'usr_grantee', + accessLevel: 'read', + } as any, SYS); + + it.each(INERT_OBJECTS)('evaluating a rule on %s mints NOTHING (it minted a row pre-fix)', async (object) => { + await define(object); + // `evaluateRule` does not swallow the refusal — its callers do (next block). + await expect(rules.evaluateRule(`share_${object}_gold`, SYS)).rejects.toThrow(/SHARING_NOT_ENABLED/); + expect(engine._tables.sys_record_share ?? []).toHaveLength(0); + }); + + it('ANTI-VACUITY: the same rule shape on an ordinary object still materialises', async () => { + await define('account'); + const result = await rules.evaluateRule('share_account_gold', SYS); + expect(result).toMatchObject({ matchedRecords: 1, expandedUsers: 1, grantsCreated: 1 }); + expect(engine._tables.sys_record_share).toHaveLength(1); + expect(engine._tables.sys_record_share[0]).toMatchObject({ + object_name: 'account', record_id: 'r1', recipient_id: 'usr_grantee', source: 'rule', + }); + // LIVE: the criteria-matched record is reachable, the unmatched one is not. + expect(await sharing.canEdit('account', 'r1', { userId: 'usr_grantee' } as any)).toBe(false); // read-level grant + const filter: any = await sharing.buildReadFilter('account', { userId: 'usr_grantee' } as any); + expect(filter.$or[1].id.$in).toEqual(['r1']); + }); + + it('the boot backfill still COMPLETES — one bad rule does not stop its siblings', async () => { + // The path the card names as the one most likely to trip. `evaluateRule` + // throws for the inert rule; `backfillRuleGrants` catches per rule, warns, + // and carries on, so the ordinary rule is still reconciled. + for (const object of ['account', ...INERT_OBJECTS]) await define(object); + const logged: Array<{ msg: string; meta: any }> = []; + const reconciled = await backfillRuleGrants( + rules, + [{ name: 'share_account_gold' }, ...INERT_OBJECTS.map(o => ({ name: `share_${o}_gold` }))], + { + info: (msg: string, meta?: any) => logged.push({ msg, meta }), + warn: (msg: string, meta?: any) => logged.push({ msg, meta }), + }, + ); + expect(reconciled).toBe(1); // the ordinary rule, and only it + expect(logged.some(l => l.msg.includes('boot rule backfill done'))).toBe(true); + // Each refusal reaches the operator naming the rule AND the reason. + for (const object of INERT_OBJECTS) { + const warn = logged.find(l => l.meta?.rule === `share_${object}_gold`); + expect(warn?.msg).toMatch(/boot rule backfill failed for rule/); + expect(warn?.meta?.error).toMatch(/SHARING_NOT_ENABLED/); + } + // …and the ordinary rule's grant is there. + expect(engine._tables.sys_record_share).toHaveLength(1); + expect(engine._tables.sys_record_share[0]).toMatchObject({ object_name: 'account' }); + }); + + it('WITHDRAWAL still works on an inert object — revoke is untouched', async () => { + // Rows minted by an older build stay purgeable: only `grant` gained the + // guard, so `deleteRule` / `evaluateRule`-on-inactive can still clean up. + await define('whiteboard'); + const rule = await rules.getRule('share_whiteboard_gold', SYS); + engine._tables.sys_record_share = [{ + id: 'shr_legacy', object_name: 'whiteboard', record_id: 'r1', + recipient_type: 'user', recipient_id: 'usr_grantee', access_level: 'read', + source: 'rule', source_id: rule!.id, + }]; + expect(await rules.revokeRuleGrants(rule!.id)).toBe(1); + expect(engine._tables.sys_record_share).toHaveLength(0); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 6c39932666..106046986e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -9660,6 +9660,17 @@ export class RestServer { if (msg.startsWith('RULE_NOT_FOUND')) { return res.status(404).json({ code: 'RULE_NOT_FOUND', error: msg.replace(/^RULE_NOT_FOUND:?\s*/, '') }); } + // [ADR-0111 D7 / #8207] `POST .../evaluate` reconciles through + // `SharingService.grant`, whose inertness guard now runs for the + // evaluator's system context too. A rule pointed at an object no + // sharing gate consults therefore refuses here instead of silently + // materialising rows nothing reads — and the admin who asked for + // the evaluation needs to be told WHICH object and WHY, not handed + // an opaque 500. Same code→status pair the per-record shares routes + // already publish (`respondSharingError`), so no new contract. + if (msg.startsWith('SHARING_NOT_ENABLED')) { + return res.status(422).json({ code: 'SHARING_NOT_ENABLED', error: msg.replace(/^SHARING_NOT_ENABLED:\s*/, '') }); + } logError(`[REST] sharing-rule ${defaultCode}:`, err); return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) }); }; diff --git a/packages/rest/src/sharing-rule-evaluate-inert-object.test.ts b/packages/rest/src/sharing-rule-evaluate-inert-object.test.ts new file mode 100644 index 0000000000..de1a671941 --- /dev/null +++ b/packages/rest/src/sharing-rule-evaluate-inert-object.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8207] `POST {basePath}/sharing/rules/:idOrName/evaluate` maps the ADR-0111 + * D7 inertness refusal onto 422, not a 500. + * + * ## Why this arm appeared + * + * `evaluateRule` reconciles through `SharingService.grant` under a SYSTEM + * context, and #8207 made the D7 inert-grant guard run for system callers too + * (it is an inertness check — its answer does not depend on who asks). So a + * rule pointed at an object no sharing gate consults — a public model, an + * owner-less object, a `controlled_by_parent` detail, a federated + * phantom-anchor object, a bypass object — now REFUSES here instead of + * silently materialising `sys_record_share` rows nothing reads. + * + * That refusal reaches this route's `handleError`, whose arms are matched on + * the plugin's `CODE:` message prefix. Without a `SHARING_NOT_ENABLED` arm the + * admin who asked for the evaluation gets `500 RULE_EVALUATE_FAILED` — an + * unhandled-fault status for a condition the platform diagnosed precisely, and + * one that hides which object is at fault behind a generic code. The pair + * asserted here is the SAME one the per-record shares routes already publish + * (`respondSharingError`'s `['SHARING_NOT_ENABLED', 422]`), so no new contract + * is introduced — only a second route family reaching an existing one. + * + * ## The envelope SHAPE here is deliberately not the subject + * + * `registerSharingRuleEndpoints` still answers the pre-#8111 flat dialect + * (`{ code, error: '' }`) on every one of its arms; #8111 converted the + * per-record shares family only. These cases therefore assert the STATUS and + * the code VALUE — the substance of the mapping — and read the code through a + * helper that accepts either position, so the eventual ADR-0112 D5 conversion + * of this route family changes one helper rather than going red on a card that + * was never about the shape. What they do NOT do is pin the retired dialect as + * if it were the contract. + */ + +import { describe, it, expect, vi } from 'vitest'; +// `.js` on purpose — NodeNext resolution requires the extension (#7248). +import { RestServer } from './rest-server.js'; +import { ERROR_CODE_LEDGER } from '@objectstack/spec/api'; + +const EVALUATE = '/api/v1/sharing/rules/:idOrName/evaluate'; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(function (this: any) { return this; }), + end: vi.fn(function (this: any) { return this; }), + setHeader: vi.fn(function (this: any) { return this; }), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(function (this: any) { return this; }), + }; + return res; +} + +type Answer = { status: number; body: any }; + +/** The code, whichever position this route family currently answers in. */ +const codeOf = (body: any): unknown => body?.error?.code ?? body?.code; +/** The message, likewise. */ +const messageOf = (body: any): unknown => + typeof body?.error === 'string' ? body.error : (body?.error?.message ?? body?.message); + +function boot(ruleService: any) { + const rest = new RestServer( + mockServer() as any, + { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: {} }) } as any, + { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, + (async () => ruleService) as any, // sharingRulesServiceProvider + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u_admin' }); + rest.registerRoutes(); + + const found = (rest as any).getRoutes().find( + (r: any) => r.method === 'POST' && r.path === EVALUATE, + ); + if (!found) throw new Error(`route not registered: POST ${EVALUATE}`); + + return async (): Promise => { + const res = mockRes(); + await found.handler( + { + method: 'POST', path: EVALUATE, headers: {}, query: {}, body: {}, + params: { idOrName: 'share_whiteboard_gold' }, + } as any, + res, + ); + return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; + }; +} + +/** The verbatim idiom `SharingService.assertNotInertGrant` throws. */ +const INERT = new Error( + "SHARING_NOT_ENABLED: 'whiteboard' is not under record-sharing enforcement " + + "(public sharing model or no 'owner_id' field); a share row on it would never be consulted", +); + +describe('[#8207] POST /sharing/rules/:idOrName/evaluate — the D7 inertness refusal', () => { + it('answers 422 SHARING_NOT_ENABLED when reconcile refuses an inert object', async () => { + const evaluate = boot({ evaluateRule: vi.fn().mockRejectedValue(INERT) }); + const answer = await evaluate(); + expect( + answer.status, + `expected 422, got ${answer.status} with body ${JSON.stringify(answer.body)}`, + ).toBe(422); + expect(codeOf(answer.body)).toBe('SHARING_NOT_ENABLED'); + }); + + it('the message names the object and survives, minus the internal prefix', async () => { + // The operator half: a bare code cannot tell them WHICH object of the + // rule's is inert. The `CODE:` prefix is a server-internal + // service→REST derivation (#8111) and is stripped like every sibling + // arm's, so it must not reach the wire. + const evaluate = boot({ evaluateRule: vi.fn().mockRejectedValue(INERT) }); + const { body } = await evaluate(); + expect(messageOf(body)).toMatch(/'whiteboard' is not under record-sharing enforcement/); + expect(String(messageOf(body)).startsWith('SHARING_NOT_ENABLED')).toBe(false); + }); + + it('the code is one the platform DECLARES — the ADR-0112 ledger, not a route literal', () => { + expect(ERROR_CODE_LEDGER['@objectstack/plugin-sharing']).toContain('SHARING_NOT_ENABLED'); + }); + + it('ANTI-VACUITY: a successful evaluation still answers 200 with the reconcile result', async () => { + // Without this the case above would read identically against a route + // that answered 422 to everything. + const result = { ruleId: 'srule_1', matchedRecords: 2, expandedUsers: 1, grantsCreated: 2, grantsUpdated: 0, grantsRevoked: 0 }; + const evaluate = boot({ evaluateRule: vi.fn().mockResolvedValue(result) }); + const answer = await evaluate(); + expect(answer.status).toBe(200); + expect(answer.body).toEqual(result); + }); + + it('ANTI-VACUITY: the sibling arms still map to their own statuses', async () => { + // The new arm must not have shadowed the ones around it — it is matched + // by message prefix, so an over-broad predicate would swallow these. + for (const [message, status, code] of [ + ['VALIDATION_FAILED: name is required', 400, 'VALIDATION_FAILED'], + ['PERMISSION_DENIED: requires manage_sharing', 403, 'PERMISSION_DENIED'], + ['RULE_NOT_FOUND', 404, 'RULE_NOT_FOUND'], + ] as const) { + const evaluate = boot({ evaluateRule: vi.fn().mockRejectedValue(new Error(message)) }); + const answer = await evaluate(); + expect(answer.status, `for ${message}`).toBe(status); + expect(codeOf(answer.body)).toBe(code); + } + // …and an genuinely unexpected fault is still a 500 with the route's + // own default code, not a mis-attributed 422. + const evaluate = boot({ evaluateRule: vi.fn().mockRejectedValue(new Error('kaboom')) }); + const answer = await evaluate(); + expect(answer.status).toBe(500); + expect(codeOf(answer.body)).toBe('RULE_EVALUATE_FAILED'); + }); +}); From 1018d75fbc674c12435470d953119062ad973c44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 04:23:06 +0000 Subject: [PATCH 2/3] fix(rest): build the new SHARING_NOT_ENABLED arm through the shared envelope (#8207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 422 arm added by the previous commit copied its three neighbours in `registerSharingRuleEndpoints` — the flat `{ code, error: '' }` dialect, `code` beside `error` instead of inside it, so `body.error.code` reads `undefined`. Those three are #7035's declared debt, held down by the `check:route-envelope` ratchet's `siblingCode` count, which only ticks DOWN. Measured on this branch: with the flat arm the gate reports `siblingCode: found 70, declared 69 — a NEW non-conforming body`; through the shared `sendError` (`sendEnvelopeError`, aliased at rest-server.ts:89) it is green at 69. A new arm copying its neighbours' shape is exactly what that ratchet exists to stop, and raising the declared number is a maintainer action, not this card's. The test file now pins the NESTED pair for the new arm (`body.error.code` + `body.error.message`, `success: false`, no top-level `code`), and keeps reading the sibling arms through the position-tolerant helper — they are asserted for their STATUS only, which is all this card claims about them, so converting them later under #8111's unfinished half does not go red here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- packages/rest/src/rest-server.ts | 15 ++++++++- ...sharing-rule-evaluate-inert-object.test.ts | 31 +++++++++++++------ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 106046986e..3983704303 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -9668,8 +9668,21 @@ export class RestServer { // the evaluation needs to be told WHICH object and WHY, not handed // an opaque 500. Same code→status pair the per-record shares routes // already publish (`respondSharingError`), so no new contract. + // + // ⚠️ Built through the SHARED `sendError` envelope, unlike the three + // arms above it. Those are #7035's declared debt — `code` beside + // `error` instead of inside it, so `body.error.code` reads + // `undefined` — held down by the `check:route-envelope` ratchet, + // which only ticks DOWN. A new arm copying its neighbours' shape is + // exactly what that ratchet exists to stop, so this one answers the + // envelope `BaseResponseSchema` declares. The asymmetry is the + // ratchet working; converting the other three is #8111's unfinished + // half for this route family, not a rider on this card. if (msg.startsWith('SHARING_NOT_ENABLED')) { - return res.status(422).json({ code: 'SHARING_NOT_ENABLED', error: msg.replace(/^SHARING_NOT_ENABLED:\s*/, '') }); + return sendEnvelopeError( + res, 422, 'SHARING_NOT_ENABLED', + msg.replace(/^SHARING_NOT_ENABLED:\s*/, ''), + ); } logError(`[REST] sharing-rule ${defaultCode}:`, err); return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) }); diff --git a/packages/rest/src/sharing-rule-evaluate-inert-object.test.ts b/packages/rest/src/sharing-rule-evaluate-inert-object.test.ts index de1a671941..495acab71c 100644 --- a/packages/rest/src/sharing-rule-evaluate-inert-object.test.ts +++ b/packages/rest/src/sharing-rule-evaluate-inert-object.test.ts @@ -23,16 +23,21 @@ * (`respondSharingError`'s `['SHARING_NOT_ENABLED', 422]`), so no new contract * is introduced — only a second route family reaching an existing one. * - * ## The envelope SHAPE here is deliberately not the subject + * ## Why this arm's envelope differs from its neighbours * - * `registerSharingRuleEndpoints` still answers the pre-#8111 flat dialect - * (`{ code, error: '' }`) on every one of its arms; #8111 converted the - * per-record shares family only. These cases therefore assert the STATUS and - * the code VALUE — the substance of the mapping — and read the code through a - * helper that accepts either position, so the eventual ADR-0112 D5 conversion - * of this route family changes one helper rather than going red on a card that - * was never about the shape. What they do NOT do is pin the retired dialect as - * if it were the contract. + * `registerSharingRuleEndpoints`'s other refusal arms answer the pre-#8111 flat + * dialect (`{ code, error: '' }`) — `code` beside `error` instead of + * inside it, so `body.error.code` reads `undefined`. #8111 converted the + * per-record shares family only; these three are declared debt, held down by + * the `check:route-envelope` ratchet, which only ticks DOWN. A new arm copying + * its neighbours' shape is precisely what that ratchet exists to stop (it + * caught this one), so the arm added here is built through the shared + * `sendError` and answers the envelope `BaseResponseSchema` declares. + * + * The cases below therefore assert the NESTED pair for the new arm, and read + * the siblings through a position-tolerant helper — they are pinned for their + * STATUS, which is all this card claims about them, so converting them later + * does not go red on a card that was never about their shape. */ import { describe, it, expect, vi } from 'vitest'; @@ -116,7 +121,13 @@ describe('[#8207] POST /sharing/rules/:idOrName/evaluate — the D7 inertness re answer.status, `expected 422, got ${answer.status} with body ${JSON.stringify(answer.body)}`, ).toBe(422); - expect(codeOf(answer.body)).toBe('SHARING_NOT_ENABLED'); + // The ADR-0112 D5 pair at the position the schema declares — NESTED, + // because this arm is built through the shared `sendError` rather than + // copying its neighbours' retired flat dialect (see the file header). + expect(answer.body?.error?.code).toBe('SHARING_NOT_ENABLED'); + expect(typeof answer.body?.error?.message).toBe('string'); + expect(answer.body).not.toHaveProperty('code'); + expect(answer.body?.success).toBe(false); }); it('the message names the object and survives, minus the internal prefix', async () => { From 483e4c3b8e74a342de109ebaf8fbcbaaa95197fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 04:38:27 +0000 Subject: [PATCH 3/3] fix(plugin-sharing): pin the #8207 fake engine's update() to the real dispatch contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:engine-double-contract` reported the new test file's engine double as `PINNED [update]: … declares 1 engine double(s) whose update() does not route through assertEngineUpdateDispatch (line 88)`. The double was loose in two ways, not one. It mirrored the DRIVER arity (`update(object, id, data)`) alongside the engine one — a shape `IDataEngine` does not have at all — and, on the engine arm, it dispatched on a hand-derived `data.id` with no rejection surface, so it accepted the predicate updates `ObjectQL.update` refuses. Both are the #4434 vacuity class the gate exists for: a double that accepts what the producer rejects is a test that cannot fail. `update` now opens with `assertEngineUpdateDispatch(data, options)` from `@objectstack/metadata-core` — already a dependency of this package and already the source of the `assertEngineDeleteDispatch` on the `delete` member two lines below — and implements the returned `by-id` / `multi` verdicts separately. `metadata-core` deliberately, not `@objectstack/objectql`: objectql depends on this package's siblings and the reverse edge is a cycle turbo refuses. No baseline entry added — `scripts/engine-double-contract.baseline.json` is shrink-only and raising it is a maintainer action. Gate now reads `OK — 186 pinned, 133 in the DEBT ledger, 2 exempt`; suite still 24/24. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .../src/system-caller-inert-grant.test.ts | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts b/packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts index b12bf85613..ce4569badb 100644 --- a/packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts +++ b/packages/plugins/plugin-sharing/src/system-caller-inert-grant.test.ts @@ -56,7 +56,11 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { OWNER_FIELD_DEF, assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { + OWNER_FIELD_DEF, + assertEngineDeleteDispatch, + assertEngineUpdateDispatch, +} from '@objectstack/metadata-core'; import { ERROR_CODE_LEDGER } from '@objectstack/spec/api'; import { SharingService } from './sharing-service.js'; import { SharingRuleService } from './sharing-rule-service.js'; @@ -93,12 +97,30 @@ function makeEngine(schemas: Record) { return ensure(o).filter(r => matches(r, f)).slice(0, opts?.limit ?? 10000); }, async insert(o: string, data: any) { const row = { ...data }; ensure(o).push(row); return row; }, - async update(o: string, idOrData: any, dataOrOpts?: any) { - const data = typeof idOrData === 'object' ? idOrData : dataOrOpts; - const id = typeof idOrData === 'object' ? idOrData.id : idOrData; - const t = ensure(o); const i = t.findIndex(r => r.id === id); - if (i >= 0) t[i] = { ...t[i], ...data }; - return t[i]; + async update(o: string, data: any, options?: any) { + // [#4550/#5480] Pinned to `ObjectQL.update`'s OWN dispatch predicate, for + // the same reason `delete` is below: a fake looser than the contract it + // stands in for is how a green suite ships a dead route (#4434). + // + // The shape this replaced also mirrored the DRIVER arity + // (`update(object, id, data)`) alongside the engine one, which + // `IDataEngine` does not have at all — so the double accepted a call no + // real engine would dispatch, on top of accepting the predicate updates + // the producer rejects. + const dispatch = assertEngineUpdateDispatch(data, options); + const t = ensure(o); + if (dispatch.kind === 'by-id') { + const i = t.findIndex(r => r.id === dispatch.id); + if (i >= 0) t[i] = { ...t[i], ...data }; + return t[i]; + } + // `multi`: the producer rewrites every matching row's fields. + let updated = 0; + const where = options?.where ?? {}; + for (let i = 0; i < t.length; i++) { + if (matches(t[i], where)) { t[i] = { ...t[i], ...data }; updated += 1; } + } + return { ok: true, updated }; }, async delete(o: string, opts?: any) { // [#4550] Pinned to `ObjectQL.delete`'s OWN dispatch predicate — a fake