diff --git a/.changeset/23008-org-seed-claim-skip-automations.md b/.changeset/23008-org-seed-claim-skip-automations.md new file mode 100644 index 00000000000..4e0821fa2f9 --- /dev/null +++ b/.changeset/23008-org-seed-claim-skip-automations.md @@ -0,0 +1,16 @@ +--- +'@objectstack/organizations': patch +--- + +The organization seed ownership claim no longer fires app hooks or record-change flows + +Clause-②: no + +When an organization's owner is established (for example, `ensureDefaultOrganization` binding the platform admin as the default organization's owner), `claimOrgSeedOwnership` hands that organization's owner-less seeded rows to the owner. That write ran under a bare system context, so every claimed row went through the full write pipeline: hooks bound from app metadata fired, record-change flows ran, and the approvals and notifications those flows start went out for a bookkeeping write. The single-tenant claim, `claimSeedOwnership` in `@objectstack/plugin-security`, already wrote without automation; the organization claim did not. + +The claim's write now runs with `{ isSystem: true, skipAutomations: true }`, the same context the single-tenant claim uses. + +- **App hooks no longer fire for the organization seed ownership claim.** No hook bound from metadata runs on the claim's owner change, and no record-change flow is dispatched for it, so the claim opens no approval and sends no notification. +- **Still runs.** Hooks that plugins register in code still run on every claimed row: the audit writer, capability gates and the sharing projection. Every claimed row's `owner_id` is the organization owner, as before. +- **The read is unchanged.** The claim's per-object read is still a plain system read. +- **If an app relied on it.** An app hook or flow that reacted to the claim's owner change no longer sees it, just as it never saw the seed's own writes. diff --git a/packages/plugins/organizations/src/claim-org-seed-ownership-dispatch.pin.test.ts b/packages/plugins/organizations/src/claim-org-seed-ownership-dispatch.pin.test.ts new file mode 100644 index 00000000000..4a14bc6310f --- /dev/null +++ b/packages/plugins/organizations/src/claim-org-seed-ownership-dispatch.pin.test.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23008] What the organization seed-ownership claim's write DISPATCHES — + * measured on a REAL `ObjectQL` over a real `SqliteWasmDriver` (the driver + * `objectstack dev` uses). The multi-tenant twin of plugin-security's + * `claim-seed-ownership-dispatch.pin.test.ts`. + * + * `claimOrgSeedOwnership` re-owns an organization's seeded rows to its owner. + * That is attribution, the step that completes the seed, not a user event, so + * its write runs under `{ isSystem: true, skipAutomations: true }`. This file + * pins what that context reaches on the engine, because a flag that is + * silently dropped on the way would read as green in every double: + * + * 1. **the metadata-hook filter** — a hook bound through the real binder + * (`bindHooksToEngine`, which is what stamps `entry.meta`) does not fire, + * before or after, on any claimed row; + * 2. **the per-row hook path** — code-registered hooks (the shape of the + * audit writer, capability gates and the sharing projection) DO fire, once + * per claimed row and phase, and every one of those contexts carries the + * flag; + * 3. **the record-change trigger's reading point** — every one of those + * contexts has `session.skipTriggers === true`, the one field + * `RecordChangeTrigger`'s handler reads before it dispatches a flow + * (`skipAutomations` implies it, `engine.ts` `buildSession`). + * + * And it measures the read the flag deliberately stays off: the claim's + * per-object read is a plain system read, so the same metadata hook, bound on + * `beforeFind`/`afterFind` too, sees it. That is also the in-run positive + * control: the metadata hook is live on this engine DURING the claim, so its + * silence on the write events is the filter, not a hook that was never bound. + * + * Each case carries a second positive control on the SAME engine: the claim's + * own write shape with a plain `{ isSystem: true }` context fires the metadata + * hook per row and phase. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; + +import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; + +const SYS = { context: { isSystem: true } } as const; +const ORG = 'org_alpha'; +const OTHER_ORG = 'org_north'; +const OWNER = 'usr_org_owner'; +const SOMEONE = 'usr_someone'; +const OBJECT = 'probe_deal'; +const PACKAGE_ID = 'com.objectstack.org-claim-dispatch-23008'; + +const PROBE_OBJECT = { + name: OBJECT, + label: 'Probe Deal', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + stage: { name: 'stage', type: 'text' }, + owner_id: { name: 'owner_id', type: 'text' }, + organization_id: { name: 'organization_id', type: 'text' }, + }, +}; + +/** One dispatch a hook saw: what the engine handed it, reduced to the flags. */ +interface Seen { + event: string; + id: unknown; + isSystem: unknown; + skipAutomations: unknown; + skipTriggers: unknown; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** + * A booted engine with ONE metadata-bound hook and ONE code-registered hook on + * the probe object. + * + * The metadata hook is bound through `bindHooksToEngine` — the binder an app's + * `defineStack({ hooks })` goes through — so its entry carries `meta` exactly as + * a shipped app hook's does. It listens on the write events AND the read + * events. The code hook is `registerHook` with no metadata, on the write events, + * the shape of the audit writer, the sharing projection and + * `RecordChangeTrigger`'s per-flow handler. + */ +async function boot() { + const engine = new ObjectQL(); + engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' } as never) as never, true); + await engine.init(); + engine.registerApp({ + id: PACKAGE_ID, + name: 'Organization claim dispatch', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [PROBE_OBJECT], + } as never); + await engine.syncSchemas(); + engines.push(engine); + + const metadataHook: Seen[] = []; + const codeHook: Seen[] = []; + const record = (into: Seen[]) => async (ctx: any) => { + into.push({ + event: ctx.event, + id: ctx.input?.id, + isSystem: ctx.session?.isSystem, + skipAutomations: ctx.session?.skipAutomations, + skipTriggers: ctx.session?.skipTriggers, + }); + }; + + const bound = bindHooksToEngine( + engine, + [ + { + name: 'probe_app_hook', + object: OBJECT, + events: ['beforeFind', 'afterFind', 'beforeUpdate', 'afterUpdate'], + handler: record(metadataHook), + } as never, + ], + { packageId: PACKAGE_ID }, + ); + expect(bound.registered, 'the metadata hook must be bound for its absence to mean anything').toBe(4); + + for (const event of ['beforeUpdate', 'afterUpdate'] as const) { + engine.registerHook(event, record(codeHook) as never, { object: OBJECT, packageId: 'probe.code-hook' }); + } + + return { engine, metadataHook, codeHook }; +} + +/** Seed rows straight through the driver: the seed path is not the subject here. */ +async function seed(engine: ObjectQL, rows: Record[]): Promise { + const driver = (engine as any).getDriverForObject(OBJECT); + for (let i = 0; i < rows.length; i += 500) { + await driver.bulkCreate(OBJECT, rows.slice(i, i + 500)); + } +} + +async function ownersOf(engine: ObjectQL): Promise> { + const rows = await (engine as any).find(OBJECT, { fields: ['id', 'owner_id'] }, SYS); + return new Map(rows.map((r: any) => [r.id, r.owner_id ?? null])); +} + +const writeEvents = (seen: Seen[]) => seen.filter((s) => s.event === 'beforeUpdate' || s.event === 'afterUpdate'); + +describe('[#23008] the organization seed-ownership claim dispatches no metadata-bound automation (real engine)', () => { + it('no metadata hook fires on the write; code hooks fire once per claimed row and phase with the flag and skipTriggers', async () => { + const { engine, metadataHook, codeHook } = await boot(); + const N = 12; + const claimed = Array.from({ length: N }, (_, i) => `d${i}`); + await seed(engine, [ + ...claimed.map((id, i) => ({ id, name: `Deal ${i}`, stage: 'open', organization_id: ORG, owner_id: null })), + // Out of the claim's scope: owned already, and another organization's row. + { id: 'owned', name: 'Owned', stage: 'open', organization_id: ORG, owner_id: SOMEONE }, + { id: 'foreign', name: 'Foreign', stage: 'open', organization_id: OTHER_ORG, owner_id: null }, + ]); + + const result = await claimOrgSeedOwnership(engine, ORG, OWNER); + // Taken before any read of the test's own: `ownersOf` is a `find` too. + const metadataSeen = [...metadataHook]; + const codeSeen = [...codeHook]; + + expect(result).toEqual([{ object: OBJECT, count: N }]); + const owners = await ownersOf(engine); + for (const id of claimed) expect(owners.get(id)).toBe(OWNER); + expect(owners.get('owned')).toBe(SOMEONE); + expect(owners.get('foreign')).toBeNull(); + + // ⭐ 1 — the metadata-hook filter: not one write dispatch, before or after. + expect(writeEvents(metadataSeen), 'a metadata-bound hook fired for the claim write').toEqual([]); + // In-run positive control, and the read's measurement: the claim's one read + // of this object is a plain system read, so the same metadata hook saw it. + expect(metadataSeen.map((s) => s.event)).toEqual(['beforeFind', 'afterFind']); + for (const s of metadataSeen) { + expect(s.isSystem).toBe(true); + expect(s.skipAutomations).toBeUndefined(); + } + + // ⭐ 2 — the per-row path still runs code-registered hooks: one dispatch per + // claimed row and phase, so audit and sharing keep their per-row input. + for (const event of ['beforeUpdate', 'afterUpdate']) { + const ids = codeSeen.filter((s) => s.event === event).map((s) => s.id); + expect([...ids].sort(), `${event} per claimed row`).toEqual([...claimed].sort()); + } + expect(codeSeen).toHaveLength(2 * N); + // ⭐ 3 — every one of those contexts carries the flag AND `skipTriggers`, + // the field the record-change trigger reads before dispatching a flow. + for (const s of codeSeen) { + expect(s).toEqual({ event: s.event, id: s.id, isSystem: true, skipAutomations: true, skipTriggers: true }); + } + + // Positive control, same engine, same rows: the claim's own write shape + // (a by-id `update`) under a plain system context fires the metadata hook + // per row and phase, and carries no skip flag. + metadataHook.length = 0; + codeHook.length = 0; + for (const id of claimed) { + await (engine as any).update(OBJECT, { id, stage: 'touched' }, SYS); + } + expect(writeEvents(metadataHook)).toHaveLength(2 * N); + expect(codeHook).toHaveLength(2 * N); + for (const s of codeHook) { + expect(s.skipAutomations).toBeUndefined(); + expect(s.skipTriggers).toBeUndefined(); + } + }, 120_000); + + it('a second run claims nothing and dispatches no write hook of either kind', async () => { + const { engine, metadataHook, codeHook } = await boot(); + await seed(engine, [{ id: 'd0', name: 'Deal 0', stage: 'open', organization_id: ORG, owner_id: null }]); + + expect(await claimOrgSeedOwnership(engine, ORG, OWNER)).toEqual([{ object: OBJECT, count: 1 }]); + metadataHook.length = 0; + codeHook.length = 0; + + expect(await claimOrgSeedOwnership(engine, ORG, OWNER)).toEqual([]); + expect(writeEvents(metadataHook)).toEqual([]); + expect(codeHook).toEqual([]); + }, 120_000); +}); diff --git a/packages/plugins/organizations/src/claim-org-seed-ownership.test.ts b/packages/plugins/organizations/src/claim-org-seed-ownership.test.ts index dfa8c5bde03..4f8aaed1da4 100644 --- a/packages/plugins/organizations/src/claim-org-seed-ownership.test.ts +++ b/packages/plugins/organizations/src/claim-org-seed-ownership.test.ts @@ -90,4 +90,45 @@ describe('claimOrgSeedOwnership', () => { await claimOrgSeedOwnership(ql, ORG, OWNER); expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]); }); + + // ── [#23008] the claim's write dispatches no metadata-bound automation ──── + + it('every claim write runs as isSystem with skipAutomations; the read stays a plain system read', async () => { + // Re-owning a seed row is attribution, not a user event, so the write runs + // with metadata-bound automation off: no app hook, no record-change flow, + // hence no approval and no notification. Code-registered hooks (audit, + // sharing) still run — the real-engine half of this pin is + // `claim-org-seed-ownership-dispatch.pin.test.ts`; this half pins that + // EVERY write this function issues carries the flag, across objects. + const schemas = [ + { name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }, + { name: 'crm_deal', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }, + ]; + const { ql, updates } = makeQL(schemas, { + crm_lead: [ + { id: 'l1', organization_id: ORG, owner_id: null }, + { id: 'l2', organization_id: ORG, owner_id: null }, + ], + crm_deal: [{ id: 'd1', organization_id: ORG, owner_id: null }], + }); + + expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([ + { object: 'crm_lead', count: 2 }, + { object: 'crm_deal', count: 1 }, + ]); + expect(updates).toHaveLength(3); + + const writeContexts = ql.update.mock.calls.map((call: any[]) => call[2]?.context); + expect(writeContexts).toHaveLength(3); + for (const context of writeContexts) { + expect(context).toEqual({ isSystem: true, skipAutomations: true }); + } + // The read stays a plain system read: a read starts no record-change flow, + // and the flag would also switch off an app's read-filter hooks. + const readContexts = ql.find.mock.calls.map((call: any[]) => call[2]?.context); + expect(readContexts).toHaveLength(2); + for (const context of readContexts) { + expect(context).toEqual({ isSystem: true }); + } + }); }); diff --git a/packages/plugins/organizations/src/claim-org-seed-ownership.ts b/packages/plugins/organizations/src/claim-org-seed-ownership.ts index 323ec61dd54..8474e3b6631 100644 --- a/packages/plugins/organizations/src/claim-org-seed-ownership.ts +++ b/packages/plugins/organizations/src/claim-org-seed-ownership.ts @@ -19,6 +19,21 @@ * Scoped to a single org (`organization_id = organizationId`) so it never * touches another tenant's rows. Idempotent: only NULL-owned rows are updated. * `managedBy` and `sys_*` tables are skipped. + * + * ## [#23008] The claim dispatches no automation + * + * Re-owning a seed row is attribution, the step that completes the seed, not a + * user event, so every write runs under {@link CLAIM_WRITE_CTX} — the same + * context its single-tenant twin `claimSeedOwnership` adopted (#22067). The + * engine reads `skipAutomations` in two places: the hook dispatch skips every + * hook bound FROM METADATA (an app's lifecycle hooks), and it implies + * `skipTriggers`, so the record-change trigger dispatches no flow — no approval + * opens and no notification goes out for the claim. Hooks plugins register in + * code carry no metadata binding and still run on every claimed row: the audit + * writer, capability gates and the sharing projection. The opt-out never + * bypasses audit or sharing (#2922). + * + * The real-engine half of this is `claim-org-seed-ownership-dispatch.pin.test.ts`. */ import type { ServiceObject } from '@objectstack/spec/data'; @@ -32,8 +47,31 @@ interface ClaimOwnershipOptions { }; } +/** + * The context of the claim's per-object READ: system-elevated, nothing more. + * + * The flag stays off the read on purpose. A read starts no record-change flow, + * and a metadata-bound `beforeFind` hook is an app's read-filter seam: keeping + * it in force keeps the row set the claim selects equal to what every other + * system read of that object sees. + */ const SYSTEM_CTX = { isSystem: true }; +/** + * The context of the claim's WRITE: system-elevated, with metadata-bound + * automation off. See "The claim dispatches no automation" above. + * + * `skipAutomations` alone, never a narrower flag. It is the spec's one spelling + * of "skip metadata hooks AND record-change flows, keep code-registered hooks" + * (`ExecutionContext`, `@objectstack/spec/kernel`), and `skipTriggers` alone + * would leave the app's metadata hooks firing on every claimed row. + * + * A plain literal, like {@link SYSTEM_CTX}: `pnpm check:tenant-audit-census` + * reads `isSystem` off it statically and does not see through a `satisfies` + * wrapper. + */ +const CLAIM_WRITE_CTX = { isSystem: true, skipAutomations: true }; + function hasField(schema: ServiceObject, field: string): boolean { const fields: any = (schema as any)?.fields; if (!fields) return false; @@ -48,7 +86,8 @@ function hasField(schema: ServiceObject, field: string): boolean { * (a) are not `managedBy` (skip sys_/auth/platform tables), * (b) are not `sys_*`-namespaced, * (c) declare BOTH `owner_id` and `organization_id`, - * and updates the org's unowned rows as `isSystem`. Returns a per-object summary. + * and updates the org's unowned rows as `isSystem` with `skipAutomations`. + * Returns a per-object summary. */ export async function claimOrgSeedOwnership( ql: OrgScopingEngine, @@ -92,7 +131,7 @@ export async function claimOrgSeedOwnership( for (const row of list) { if (!row?.id) continue; try { - await ql.update(schema.name, { id: row.id, owner_id: ownerUserId }, { context: SYSTEM_CTX }); + await ql.update(schema.name, { id: row.id, owner_id: ownerUserId }, { context: CLAIM_WRITE_CTX }); updated += 1; } catch (e) { logger?.warn?.(`[org-scoping] claimOrgSeedOwnership failed for ${schema.name}:${row.id}`, {