From fa83a42883a83b07ce9f6e1dceab3f020966942b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 16:42:05 +0000 Subject: [PATCH 1/2] fix(plugin-sharing): stop scoping federated objects by the phantom owner_id anchor (#7858) The ObjectQL registry injects `owner_id` into every object that has not opted out, federated (ADR-0015 `external`) ones included, while `Engine.syncObjectSchema` returns early for `external != null` and issues no DDL. So on a federated object that column exists in the registered schema and in no store. `SharingService.buildReadFilter` and `buildWriteFilter` both decided by asking `hasOwnerField`, were answered yes, and AND-composed `owner_id = ` (or the ADR-0057 DEPTH-widened `$in`) onto a query whose backing table has no such column. On SQLite the unresolvable identifier degrades to a string literal, so the predicate is constant-false -- 0 rows, no error, HTTP 200; Postgres and MySQL raise `column "owner_id" does not exist`. Either way a federated object under the secure-default `private` OWD was unreadable below `org` scope, silently. Both filters now apply a provenance test: an `owner_id` byte-identical to the shipped `OWNER_FIELD_DEF` on an `external` object is the platform's injected anchor, not a real owner column, so ownership scoping contributes nothing there. A federated object that DECLARES a real remote owner column keeps its scoping, and every local object is untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .changeset/olive-donkeys-shave.md | 24 ++ packages/plugins/plugin-sharing/package.json | 1 + .../src/federated-phantom-anchors.ts | 151 ++++++++++++ .../federated-phantom-owner-scoping.test.ts | 231 ++++++++++++++++++ .../plugin-sharing/src/sharing-service.ts | 17 ++ .../plugins/plugin-sharing/vitest.config.ts | 12 + pnpm-lock.yaml | 3 + 7 files changed, 439 insertions(+) create mode 100644 .changeset/olive-donkeys-shave.md create mode 100644 packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts create mode 100644 packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts diff --git a/.changeset/olive-donkeys-shave.md b/.changeset/olive-donkeys-shave.md new file mode 100644 index 0000000000..4af03ebb25 --- /dev/null +++ b/.changeset/olive-donkeys-shave.md @@ -0,0 +1,24 @@ +--- +'@objectstack/plugin-sharing': patch +--- + +Stop scoping federated (`external`) objects by the phantom `owner_id` anchor + +The ObjectQL registry injects `owner_id` into every object that has not opted out, +federated ones included, while `Engine.syncObjectSchema` returns early for +`external != null` and issues no DDL — so on a federated object that column exists in +the registered schema and in no store. `SharingService.buildReadFilter` and +`buildWriteFilter` both decided by asking "does this object carry an `owner_id` +field?", were answered yes, and AND-composed `owner_id = ` (or the ADR-0057 +DEPTH-widened `$in`) onto a query whose backing table has no such column. On SQLite the +unresolvable identifier degrades to a string literal, so the predicate is +constant-false — 0 rows, no error, HTTP 200; Postgres and MySQL raise +`column "owner_id" does not exist`. Either way a federated object under the +secure-default `private` OWD was unreadable by any principal whose read scope was +narrower than `org`, and nothing reported why. + +Both filters now apply a provenance test: an `owner_id` that is byte-identical to the +shipped `OWNER_FIELD_DEF` on an `external` object is the platform's injected anchor, +not a real owner column, so ownership scoping contributes nothing there. A federated +object that **declares** a real remote owner column keeps its scoping, and every local +object is untouched. diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 2f877f32d1..a1f97e2fa1 100644 --- a/packages/plugins/plugin-sharing/package.json +++ b/packages/plugins/plugin-sharing/package.json @@ -20,6 +20,7 @@ "dependencies": { "@objectstack/core": "workspace:*", "@objectstack/formula": "workspace:*", + "@objectstack/metadata-core": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/platform-objects": "workspace:*", "@objectstack/spec": "workspace:*", diff --git a/packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts b/packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts new file mode 100644 index 0000000000..1f91d94ef8 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7858] Provenance for the `owner_id` anchor a FEDERATED object carries but + * does not have. + * + * ## The fact this module exists for + * + * `applySystemFields` (the ObjectQL registry) injects `owner_id` into every + * object that has not opted out — **including federated ones** (ADR-0015 + * `external`) — while `Engine.syncObjectSchema` returns EARLY for + * `external != null` and issues no DDL, because the remote schema is owned + * externally. So for a federated object `owner_id` exists in the registered + * schema and **nowhere else**. Measured on a booted showcase stack at + * `origin/main` @ `b54aaab`, with a federated object carrying no ADR-0090 D1 + * grandfather stamp (i.e. taking the secure-default `private` OWD): + * + * ``` + * measure_ext_nostamp (external → remote table `customers`) + * registered fields: organization_id, created_at, created_by, updated_at, + * updated_by, owner_id, owning_business_unit_id, + * name, email, region + * remote columns: name, email, region, lifetime_value (+ the remote pk) + * + * buildReadFilter(…, __readScope='own') = {"owner_id":"usr_member_1"} + * buildReadFilter(…, __readScope='unit') = {"owner_id":"usr_member_1"} + * ``` + * + * `SharingService.buildReadFilter` / `buildWriteFilter` decide by asking "does + * this object carry an `owner_id` field?" (`hasOwnerField`) and are therefore + * answered YES about a column the query will never find. They then AND-compose + * `owner_id = ` (or the ADR-0057 DEPTH-widened `$in`) onto a read whose + * backing table has no `owner_id`. The failure is **dialect-dependent**: SQLite + * reinterprets the unresolvable identifier as a string literal, so the + * comparison is constant-false — 0 rows, no error, HTTP 200 — while + * Postgres/MySQL raise `column "owner_id" does not exist`. Either way a + * federated object under the secure-default OWD is unreadable by any principal + * whose read scope is narrower than `org`, and nothing reports why. + * + * ## Why PROVENANCE and not "is it federated?" + * + * A federated object MAY legitimately expose a real remote `owner_id` column by + * declaring it — and then ownership scoping is meaningful and must keep working. + * Switching ownership scoping off for every `external` object would silently + * widen reads on federated objects that genuinely have an owner column. + * + * So the question is not "is this object federated?" but "is this object's + * `owner_id` the anchor the PLATFORM injected, or a column the AUTHOR + * declared?" — identity against the shipped declaration, never a pattern match + * on a public grammar. Here the shipped declaration is {@link OWNER_FIELD_DEF} + * itself: `applySystemFields` spreads it verbatim + * (`additions.owner_id = { ...OWNER_FIELD_DEF }`) and a declared field of the + * same name suppresses the injection entirely (`if (wantOwner && + * !schema.fields?.owner_id)`), so a registered def that equals the constant can + * only have come from the platform. + * + * Deliberately NOT an authorable "this column is phantom" flag: provenance is a + * fact about who wrote the column, and letting metadata claim it would hand + * authors a switch that turns their own record-level scoping off. + * + * ## Direction of an inexact match + * + * Any mismatch — the registry adds a key, a parse stamps a default, the field + * arrives in the array shape without a recognisable body — answers `false` + * ("not the platform's anchor"), which leaves ownership scoping enforcing + * exactly as it does today. The fail direction is toward scoping, never toward + * exposure. + * + * ## Relationship to the plugin-security sibling + * + * `@objectstack/plugin-security` carries a structurally identical module for the + * TENANT anchor (`federated-phantom-anchors.ts`, #7835), and #7738 / PR #7833 + * withheld `DriverOptions.tenantId` for `external` objects one layer down in + * `@objectstack/objectql`. Three consumers, one producer. The duplication is + * deliberate and temporary: this plugin does not depend on + * `@objectstack/plugin-security` (it declares the narrow slices it probes — + * see `SharingSecurityProbe`), so importing that copy would create a plugin + * dependency edge to save nine lines. The maintainer's 2026-08-12 ruling on + * #7865 chose direction **B** — the registry keeps injecting and grows a + * machine-readable provenance marker, and consumer guards converge on that + * marker **as they are touched**. When that marker lands, this module and its + * plugin-security twin collapse into one read of it; keeping the two shaped + * identically is what makes that collapse mechanical. + */ + +// [#6562] The injected-column DEFINITION table lives in `@objectstack/metadata-core` +// (the registry that provisions the columns reads the same one, and the `/meta` read +// path consumes it too). Importing the constant — rather than restating its shape +// here — is what makes the provenance test track the producer instead of a copy that +// can drift silently. `@objectstack/objectql` re-exports it, but only from +// `registry.js`, not from its package entry point, so `metadata-core` is the +// importable home; its own dependencies are `{ @objectstack/spec, zod }`, so this +// adds no cycle. +import { OWNER_FIELD_DEF } from '@objectstack/metadata-core'; + +/** The one column ownership scoping ever emits a predicate for. */ +const OWNER_COLUMN = 'owner_id'; + +/** Pick a field definition out of either registered `fields` shape. */ +function readFieldDef(schema: unknown, name: string): unknown { + const fields = (schema as { fields?: unknown } | null | undefined)?.fields; + if (Array.isArray(fields)) { + return fields.find((f) => (f as { name?: unknown } | null)?.name === name); + } + if (fields && typeof fields === 'object') { + return (fields as Record)[name]; + } + return undefined; +} + +/** + * Structural identity against the shipped constant. Flat by construction — + * every value in {@link OWNER_FIELD_DEF} is a primitive — so a flat comparison + * is exact rather than a shortcut, and an extra or missing key is a mismatch + * (see "Direction of an inexact match" in the module docs). + * + * The array shape carries an additional `name` key that the object shape + * expresses as the map key; it is excluded so both shapes reach the same + * verdict about the same column. + */ +function equalsShippedDef(def: unknown, shipped: Readonly>): boolean { + if (!def || typeof def !== 'object' || Array.isArray(def)) return false; + const actual = { ...(def as Record) }; + delete actual.name; + const shippedKeys = Object.keys(shipped); + if (Object.keys(actual).length !== shippedKeys.length) return false; + return shippedKeys.every((k) => actual[k] === shipped[k]); +} + +/** + * Is `schema` a federated (ADR-0015 `external`) object binding a remote table? + * The platform provisions no storage for one, so nothing it injects is real. + */ +export function isFederatedObject(schema: unknown): boolean { + return (schema as { external?: unknown } | null | undefined)?.external != null; +} + +/** + * Does this object's `owner_id` exist only in the registry — i.e. is it the + * platform's injected anchor on an object whose storage the platform never + * provisioned? + * + * `true` ⇒ record-level ownership scoping must treat the object as carrying NO + * owner column, because it does not (see the module docs). `false` for every + * local object (the platform DID provision the column there) and for a + * federated object whose author declared a real remote `owner_id`. + */ +export function hasPhantomOwnerAnchor(schema: unknown): boolean { + if (!isFederatedObject(schema)) return false; + return equalsShippedDef(readFieldDef(schema, OWNER_COLUMN), OWNER_FIELD_DEF); +} diff --git a/packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts b/packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts new file mode 100644 index 0000000000..eacde946e4 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7858] Record-level ownership scoping must not filter a FEDERATED object by a + * column it does not have. + * + * ## The defect these pins hold down + * + * The ObjectQL registry injects `owner_id` into every object that has not opted + * out, federated ones included — but issues no DDL for a federated object, + * because its remote schema is owned externally (`Engine.syncObjectSchema` + * returns early for `external != null`). `SharingService.buildReadFilter` / + * `buildWriteFilter` then read the registered field set, answer + * `hasOwnerField: true`, and AND-compose `owner_id = ` onto a query + * whose backing table has no such column. + * + * The symptom is dialect-dependent and the defect is not: SQLite reinterprets + * the unresolvable identifier as a string literal, so the predicate is + * constant-false — **0 rows, no error, HTTP 200**; Postgres/MySQL raise + * `column "owner_id" does not exist`. So "it did not throw" is precisely the + * failure mode, and every case below asserts the **composed filter value**, + * never an absence of error. + * + * ## Why the fixtures below leave `sharingModel` UNSET + * + * This is load-bearing, not an omission. `effectiveSharingModel` returns + * `'public'` for the ADR-0090 D1 grandfather stamp `public_read_write`, and + * both filters return `null` on that at a gate ABOVE the one under test — so a + * fixture carrying the stamp can never reach the phantom-anchor line and would + * stay green against the broken build. Both shipped showcase federated objects + * (`showcase_ext_customer`, `showcase_ext_order`) carry exactly that stamp, + * which is why the card's measurement had to register a fresh unstamped object + * to see the defect at all. The unstamped fixture takes the secure-default + * `private` OWD — the case an app author gets by declaring nothing, i.e. the + * normal one. {@link GRANDFATHERED_SHOWCASE_SHAPE} pins the stamped behaviour + * separately as the no-change regression surface. + * + * ## Why the fixtures are not circular + * + * The injected-anchor fixture spreads {@link OWNER_FIELD_DEF} exactly as + * `applySystemFields` does (`additions.owner_id = { ...OWNER_FIELD_DEF }`), and + * the provenance test compares against the same constant — so this file proves + * the DECISION, not that the registry still spreads it verbatim. That second + * fact has an independent witness in the dogfood layer, which reads what the + * real registry produced on a real boot. If the registry ever stops spreading + * the constant, that pin goes red while these stay green, which is the correct + * division of labour rather than a gap. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { OWNER_FIELD_DEF } from '@objectstack/metadata-core'; +import { SharingService } from './sharing-service.js'; + +/** The caller: an ordinary member whose read/write DEPTH is narrower than `org`. */ +const MEMBER = 'usr_member_1'; + +/** The remote table's real columns — the only ones a federated query can name. */ +const REMOTE_COLUMNS = { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + region: { type: 'text', label: 'Region' }, +}; + +/** + * What the registry hands this plugin for a federated object today: the remote + * columns PLUS the platform anchor it provisions no storage for. No + * `sharingModel` — see the module docs for why that is the whole point. + */ +const federatedSchema = (extra: Record = {}) => ({ + name: 'measure_ext_nostamp', + external: { remoteName: 'customers' }, + fields: { + // `applySystemFields`: `additions.owner_id = { ...OWNER_FIELD_DEF }` + owner_id: { ...OWNER_FIELD_DEF }, + ...REMOTE_COLUMNS, + }, + ...extra, +}); + +/** + * A federated object whose author DECLARED a real remote owner column. The + * registry suppresses its injection entirely (`if (wantOwner && + * !schema.fields?.owner_id)`), so this def is the author's — the column is real + * in the remote table and scoping by it is meaningful. + */ +const DECLARED_REAL_OWNER = { + name: 'ext_with_real_owner', + external: { remoteName: 'accounts' }, + fields: { + owner_id: { type: 'lookup', reference: 'sys_user', label: 'Account Rep' }, + ...REMOTE_COLUMNS, + }, +}; + +/** A LOCAL private object — the control: its `owner_id` IS provisioned. */ +const LOCAL_PRIVATE = { + name: 'local_task', + fields: { owner_id: { ...OWNER_FIELD_DEF }, ...REMOTE_COLUMNS }, +}; + +/** The shipped showcase federated shape: grandfathered under ADR-0090 D1. */ +const GRANDFATHERED_SHOWCASE_SHAPE = federatedSchema({ + name: 'showcase_ext_customer', + sharingModel: 'public_read_write', +}); + +/** + * The narrow slice of the engine both filters touch. `find` answers the + * `sys_record_share` grant lookup with no rows, so the composed filter is the + * owner branch alone — which is exactly the value under test. + */ +function makeEngine(schemas: Record) { + return { + getSchema: (name: string) => schemas[name], + find: async () => [], + insert: async (_o: string, d: unknown) => d, + update: async (_o: string, d: unknown) => d, + delete: async () => ({ deleted: 0 }), + }; +} + +describe('[#7858] ownership scoping vs federated (external) objects', () => { + let svc: SharingService; + + beforeEach(() => { + svc = new SharingService({ + engine: makeEngine({ + measure_ext_nostamp: federatedSchema(), + ext_with_real_owner: DECLARED_REAL_OWNER, + local_task: LOCAL_PRIVATE, + showcase_ext_customer: GRANDFATHERED_SHOWCASE_SHAPE, + sys_record_share: { name: 'sys_record_share' }, + }), + }); + }); + + describe('the INJECTED anchor contributes nothing', () => { + // The card's measured values, pre-fix: + // buildReadFilter(measure_ext_nostamp, __readScope='own') = {"owner_id":"usr_member_1"} + // buildReadFilter(measure_ext_nostamp, __readScope='unit') = {"owner_id":"usr_member_1"} + it.each(['own', 'own_and_reports', 'unit', 'unit_and_below'] as const)( + 'read: no owner predicate at __readScope=%s', + async (scope) => { + const filter = await svc.buildReadFilter('measure_ext_nostamp', { + userId: MEMBER, + __readScope: scope, + } as never); + expect(filter).toBeNull(); + }, + ); + + it('read: `org` scope stays null, exactly as it already did', async () => { + const filter = await svc.buildReadFilter('measure_ext_nostamp', { + userId: MEMBER, + __readScope: 'org', + } as never); + expect(filter).toBeNull(); + }); + + it.each(['update', 'delete'] as const)( + 'write: no owner predicate on a bulk %s', + async (verb) => { + const filter = await svc.buildWriteFilter( + 'measure_ext_nostamp', + { userId: MEMBER, __writeScope: 'own' } as never, + verb, + ); + expect(filter).toBeNull(); + }, + ); + + it('read: the principal-less degenerate case is not reached either', async () => { + // Ownership contributes nothing BEFORE the deny-all fallback, so a + // federated object does not become unreadable to an anonymous API key + // over a column it has not got. + const filter = await svc.buildReadFilter('measure_ext_nostamp', {} as never); + expect(filter).toBeNull(); + }); + }); + + describe('what must NOT change', () => { + it('federated object with a DECLARED real remote owner column keeps its scoping', async () => { + const read = await svc.buildReadFilter('ext_with_real_owner', { + userId: MEMBER, + __readScope: 'own', + } as never); + expect(read).toEqual({ owner_id: MEMBER }); + + const write = await svc.buildWriteFilter( + 'ext_with_real_owner', + { userId: MEMBER, __writeScope: 'own' } as never, + 'delete', + ); + expect(write).toEqual({ owner_id: MEMBER }); + }); + + it('LOCAL private object with the injected anchor is untouched', async () => { + const read = await svc.buildReadFilter('local_task', { + userId: MEMBER, + __readScope: 'own', + } as never); + expect(read).toEqual({ owner_id: MEMBER }); + + const write = await svc.buildWriteFilter( + 'local_task', + { userId: MEMBER, __writeScope: 'own' } as never, + 'delete', + ); + expect(write).toEqual({ owner_id: MEMBER }); + }); + + it('the grandfathered showcase federated object behaves exactly as today', async () => { + // `public_read_write` → `effectiveSharingModel` is `public`, so BOTH + // filters return null at a gate above the phantom-anchor test. Pinned + // rather than assumed: this is the shipped regression surface. + expect( + await svc.buildReadFilter('showcase_ext_customer', { + userId: MEMBER, + __readScope: 'own', + } as never), + ).toBeNull(); + expect( + await svc.buildWriteFilter( + 'showcase_ext_customer', + { userId: MEMBER, __writeScope: 'own' } as never, + 'update', + ), + ).toBeNull(); + }); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index 1b4b7874c9..4c773916ce 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -22,6 +22,7 @@ import { // way out of its own contract to read fields the caller had already supplied. import type { ExecutionContext } from '@objectstack/spec/kernel'; import { WRITE_ACCESS_LEVELS, normalizeAccessLevel } from './access-level.js'; +import { hasPhantomOwnerAnchor } from './federated-phantom-anchors.js'; import { deleteRowsForDeletedRecords, sweepOrphanedRowsByRecordExistence, @@ -289,6 +290,16 @@ export class SharingService implements ISharingService { if (!schema) return null; if (effectiveSharingModel(schema) !== 'private') return null; if (!hasOwnerField(schema)) return null; + // [#7858] …and an `owner_id` the REGISTRY injected into a FEDERATED object + // is not an owner column at all: the platform provisions no storage for one, + // so the column exists in the schema and in no store. Scoping by it composes + // a predicate the remote table cannot resolve — constant-false on SQLite + // (0 rows, HTTP 200), a hard error on Postgres/MySQL — which is why this + // reads as "ownership contributes nothing", exactly like the owner-less + // object one line up, rather than as a narrower filter. A federated object + // that DECLARES a real remote owner column keeps its scoping, and every + // local object is untouched; see `federated-phantom-anchors.ts`. + if (hasPhantomOwnerAnchor(schema)) return null; if (!context.userId) { // Authenticated context with no user id is a degenerate case // (e.g. anonymous API key). Restrict to nothing rather than @@ -366,6 +377,12 @@ export class SharingService implements ISharingService { if (!schema) return null; if (effectiveSharingModel(schema) === 'public') return null; if (!hasOwnerField(schema)) return null; + // [#7858] The write half of the same phantom-anchor test the read filter + // applies — the card measured BOTH gates. Fixing only the read half would + // leave a bulk `update`/`delete` AND-composing `owner_id = ` onto a + // federated table that has no such column: the same constant-false / + // hard-error split, here silently touching zero rows instead of refusing. + if (hasPhantomOwnerAnchor(schema)) return null; if (!context.userId) { // Authenticated but principal-less → edit nothing (fail closed), // mirroring buildReadFilter's degenerate-context handling. diff --git a/packages/plugins/plugin-sharing/vitest.config.ts b/packages/plugins/plugin-sharing/vitest.config.ts index db150b06bb..90cf8bb9fe 100644 --- a/packages/plugins/plugin-sharing/vitest.config.ts +++ b/packages/plugins/plugin-sharing/vitest.config.ts @@ -33,8 +33,20 @@ export default defineConfig({ // swallow any subpath and resolve it to `…/src/index.ts/` // (ENOTDIR) — a config that looks right and fails at run time. Same shape // as `service-storage`'s and `service-knowledge`'s. + // + // `@objectstack/metadata-core` (#7858) is on the same footing, and reaches + // the tests two ways: `federated-phantom-owner-scoping.test.ts` imports + // `OWNER_FIELD_DEF` to build its fixture, and `federated-phantom-anchors.ts` + // — pulled in transitively by `sharing-service.ts` — imports the same + // constant to compare against. That constant IS the provenance test's + // subject: it decides whether a federated object's `owner_id` is the + // platform's injected anchor or a column its author declared. Resolved from + // `dist/`, a stale copy would move the verdict without moving the assertion + // — the guard would answer about a definition that is no longer shipped, + // and stay green while doing it. alias: [ { find: /^@objectstack\/driver-sql$/, replacement: path.resolve(__dirname, '../../drivers/driver-sql/src/index.ts') }, + { find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../../metadata-core/src/index.ts') }, ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9564f56613..2de8198b4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1749,6 +1749,9 @@ importers: '@objectstack/formula': specifier: workspace:* version: link:../../formula + '@objectstack/metadata-core': + specifier: workspace:* + version: link:../../metadata-core '@objectstack/objectql': specifier: workspace:* version: link:../../objectql From 7b5d6727b3088dced421cc0ac58d7a6c5c5100b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 17:02:39 +0000 Subject: [PATCH 2/2] test(plugin-sharing): pin the #7858 fake engine to ObjectQL's update/delete dispatch predicates `check:engine-double-contract` flagged both verbs on the new test file: the double's `update()` and `delete()` accepted call shapes the real engine rejects. A fake looser than `ObjectQL.delete` is how #4434 shipped a dead REST route with its suite green -- the same class as an assertion that passes because the harness is more permissive than the producer. Both now open with `assertEngineUpdateDispatch(data, options)` / `assertEngineDeleteDispatch(options)`, imported from `@objectstack/metadata-core` (where the predicates have lived since #5619, and already a dependency of this package for `OWNER_FIELD_DEF`). Parameters are typed with the predicates' own input types rather than `any`, so the query-options erasure ratchet's test aggregate does not move. Gate re-run: both verbs report `pinned` for this file; the shrink-only baseline is untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --- .../federated-phantom-owner-scoping.test.ts | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts b/packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts index eacde946e4..ce744d70d8 100644 --- a/packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts +++ b/packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts @@ -48,7 +48,14 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { OWNER_FIELD_DEF } from '@objectstack/metadata-core'; +import { + OWNER_FIELD_DEF, + assertEngineDeleteDispatch, + assertEngineUpdateDispatch, + type EngineDeleteDispatchInput, + type EngineUpdateDispatchData, + type EngineUpdateDispatchInput, +} from '@objectstack/metadata-core'; import { SharingService } from './sharing-service.js'; /** The caller: an ordinary member whose read/write DEPTH is narrower than `org`. */ @@ -108,14 +115,35 @@ const GRANDFATHERED_SHOWCASE_SHAPE = federatedSchema({ * The narrow slice of the engine both filters touch. `find` answers the * `sys_record_share` grant lookup with no rows, so the composed filter is the * owner branch alone — which is exactly the value under test. + * + * [#4550] `update` and `delete` open with the REAL engine's own dispatch + * predicates. Neither verb is exercised by the cases below — the `SharingEngine` + * contract requires both members, so the double has to declare them — but a + * double that would ACCEPT a call shape `ObjectQL` rejects is precisely how + * #4434 shipped a dead REST route with its suite green. That is the same + * failure this file's own fixture choice guards against one level up: an + * assertion that passes because the harness is more permissive than the thing + * it stands for. Imported from `@objectstack/metadata-core`, where the + * predicates have lived since #5619 and which this package already depends on + * for {@link OWNER_FIELD_DEF}. */ function makeEngine(schemas: Record) { return { getSchema: (name: string) => schemas[name], find: async () => [], - insert: async (_o: string, d: unknown) => d, - update: async (_o: string, d: unknown) => d, - delete: async () => ({ deleted: 0 }), + insert: async (_object: string, data: unknown) => data, + update: async ( + _object: string, + data: EngineUpdateDispatchData, + options?: EngineUpdateDispatchInput, + ) => { + assertEngineUpdateDispatch(data, options); + return data; + }, + delete: async (_object: string, options?: EngineDeleteDispatchInput) => { + assertEngineDeleteDispatch(options); + return { deleted: 0 }; + }, }; }