Skip to content

Commit e49aaec

Browse files
fix(organizations): the organization seed ownership claim writes with skipAutomations — no app hooks or record-change flows for a bookkeeping write (#23053)
Fixes #23008 Clause-②: no ## What changed `claimOrgSeedOwnership` (`@objectstack/organizations`) hands an organization's owner-less seeded rows to its owner. Its per-row write now runs under `{ isSystem: true, skipAutomations: true }` (`CLAIM_WRITE_CTX`) instead of a bare `{ isSystem: true }`, the context its single-tenant twin `claimSeedOwnership` already uses. The card carries no maintainer ruling; the binding direction is triage's grade on the card (comment 6123012416), verbatim: > `claimOrgSeedOwnership` writes with `{ isSystem: true, skipAutomations: true }`, with #22069's pin: a metadata-bound hook does not run, and a code-registered hook (audit, sharing projection) still does. No metadata-bound hook runs on the claim's owner change and no record-change flow is dispatched for it, so no approval opens and no notification goes out for the bookkeeping write. Code-registered hooks (the audit writer, capability gates, the sharing projection) still run on every claimed row. The per-object read stays a plain `{ isSystem: true }` read; see "The read" below. | file | change | | --- | --- | | `packages/plugins/organizations/src/claim-org-seed-ownership.ts` | `CLAIM_WRITE_CTX` beside `SYSTEM_CTX`, used by the update only; the header says why, and which hooks still run | | `packages/plugins/organizations/src/claim-org-seed-ownership.test.ts` | new case: every `update` call carries exactly `{ isSystem: true, skipAutomations: true }`, every `find` exactly `{ isSystem: true }` | | `packages/plugins/organizations/src/claim-org-seed-ownership-dispatch.pin.test.ts` (new) | real `ObjectQL` over `SqliteWasmDriver` (the driver `objectstack dev` uses): what the flag reaches | | `.changeset/23008-org-seed-claim-skip-automations.md` | `patch` for `@objectstack/organizations` | No `package.json` or lockfile change: `@objectstack/objectql` and `@objectstack/driver-sqlite-wasm` were already devDependencies of the package. ## What the pin asserts, on the real engine 1. **The metadata-hook filter.** A hook bound through the real binder (`bindHooksToEngine`, which stamps `entry.meta`) on `beforeUpdate`/`afterUpdate` sees 0 write dispatches during the claim. 2. **The per-row hook path.** A code-registered hook (`registerHook`, no metadata) fires once per claimed row and phase. Ids are compared, not only counted: 12 rows, 2 phases. 3. **The record-change trigger's reading point.** Every one of those contexts carries `isSystem: true`, `skipAutomations: true` and `session.skipTriggers: true`, the field `RecordChangeTrigger` reads before it dispatches a flow. There are two positive controls on the same engine. During the claim, the same metadata hook, also bound on `beforeFind`/`afterFind`, sees the claim's read (exactly one `beforeFind` and one `afterFind`), so it is live while the write events stay silent. After the claim, the claim's own by-id write shape under a bare `{ isSystem: true }` fires the metadata hook 2N times, and the code hook sees no skip flag. An owned row and another organization's owner-less row stay untouched. A second case pins that a re-run claims nothing and dispatches no write hook of either kind. ## Ablation: drop `skipAutomations`, the pin goes red The change was committed first (`04d20c038`), and the mutation was made from that state with `node scripts/ablation-replace.mjs` in wrap mode under the verify lock, with an outer `trap` restore on the absolute path. Both test files import their subject as `./claim-org-seed-ownership.js`, which is `src/`, so no `dist/` sits in the resolution path. - Mutation landed: anchor `const CLAIM_WRITE_CTX = { isSystem: true, skipAutomations: true };` went from 1 to 0, the replacement `const CLAIM_WRITE_CTX = { isSystem: true };` from 0 to 1, and the blob from `8474e3b66319` to `814e30575673`. - Mutated run: `Tests 2 failed | 6 passed (8)`. The pin failed on half 1: ```text AssertionError: a metadata-bound hook fired for the claim write: expected [ …(24) ] to deeply equal [] + { "event": "beforeUpdate", "id": "d0", "isSystem": true, "skipAutomations": undefined, "skipTriggers": undefined }, ``` The unit test's context assertion failed as well. - Restore proven: the blob after restore is `8474e3b66319`, equal to HEAD, and `git diff HEAD` is empty. The restored run: `Test Files 2 passed (2)`, `Tests 8 passed (8)`. ## The read: measured, then chosen - **Measured.** `find` dispatches `beforeFind`/`afterFind` through `triggerHooks`, so a metadata-bound find hook does fire on an `isSystem`-only read. The pin observes it on the real engine: one of each per claimed object. - **Chosen.** Keep the read plain, as the twin does. A read starts no record-change flow; the approvals and emails this card is about come from write hooks and flows. A metadata-bound `beforeFind` hook is an app's read-filter seam, and switching it off would change which rows the claim selects relative to every other system read of that object. No consumer was measured either way: `examples/` and `packages/qa` at `9f4b652e3` declare 0 metadata-bound find hooks (grep for `beforeFind` and `afterFind`: 0 hits). ## Verification `origin/main` was merged once (`a1a4f4025`). The merge brought 4 paths: two ADRs and two `scripts/adr-anchors/*.json`, with no package source. - Tests: `pnpm --filter @objectstack/organizations test` gave `Test Files 13 passed (13)`, `Tests 155 passed (155)`. `pnpm --filter @objectstack/organizations typecheck` passed: `tsc --noEmit`, then `check:test-typecheck: OK`, 0 file(s), 0 error(s). Both ran at `04d20c038`; the merge touched no package file. - Gates at `a1a4f4025`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 64 commands over the 4 changed paths. That is the dispatch's 51 plus the changeset families: `check-adr-0087-registration`, `check-changeset-no-major` and `check-empty-changeset` (each with its self-test), `check:changeset-gate-self-tests`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, and the `release-rehearsal-clone` and `release-pending-publish` self-tests. - All 64 ran and every one exited 0. - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3: the tree had no full build). `check:type-check-debt --re-measure` then built the closure, the gate was re-run, and it exited 0. - `check:dts-closure` and `check:sourcemap-no-sources-content` were re-run after that build so they cover this package: 72 and 69 built packages swept, against 48 the first time. - `--ran` with exit codes: `Run reconciliation — 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN`, and the zero is derived from the recorded codes. - Lint, a declared narrowing: `eslint --no-inline-config --format json` over the 3 changed TypeScript files at `a1a4f4025` gave 3 files, 0 errors, 0 warnings. - Population: all three match `eslint.config.mjs`'s `packages/**/*.{ts,tsx,mts,cts}` object, and none was ignored (an ignored file passed explicitly reports a warning). - Invariance: the config enables no type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move an untouched file's verdict. - The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **The 10,000-row read limit: measured, reproduces, out of this PR** (triage's direction). An untracked scratch test, since deleted, ran the real engine with 10,500 owner-less rows of one object in one organization. The claim returned `count: 10000`: 10,000 owned and 500 unowned, in one 5.0 s pass. The production caller reaches the claim through `createEnsureDefaultOrganizationOnce`, the ADR-0093 D7 once-gate. Once the owner bind is decided, the bootstrap "binds nobody and hands over no seed ownership", so the remainder stays unowned through that caller. It goes to the seat as its own card, naming the paged predicate write the twin adopted (#14530). - The changeset does not say that no example app is affected. `examples/app-showcase` declares `record-after-update` flows on `showcase_task` and `showcase_project`; whether any of them matched the claim's owner change was not measured. - No booted-app (dogfood) counterpart is added. The twin has `packages/qa/dogfood/test/seed-ownership-claim-dispatch.dogfood.test.ts` for the single-tenant claim. This PR's dispatch evidence is the real-engine pin, within the file surface of the dispatch. - Cloud's adapter in objectstack-ai/cloud#2794, which adds the flag before calling this function, can retire with cloud's next framework pin. --- _Generated by [Claude Code](https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent df7a137 commit e49aaec

4 files changed

Lines changed: 326 additions & 2 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/organizations': patch
3+
---
4+
5+
The organization seed ownership claim no longer fires app hooks or record-change flows
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
The claim's write now runs with `{ isSystem: true, skipAutomations: true }`, the same context the single-tenant claim uses.
12+
13+
- **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.
14+
- **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.
15+
- **The read is unchanged.** The claim's per-object read is still a plain system read.
16+
- **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.
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23008] What the organization seed-ownership claim's write DISPATCHES —
5+
* measured on a REAL `ObjectQL` over a real `SqliteWasmDriver` (the driver
6+
* `objectstack dev` uses). The multi-tenant twin of plugin-security's
7+
* `claim-seed-ownership-dispatch.pin.test.ts`.
8+
*
9+
* `claimOrgSeedOwnership` re-owns an organization's seeded rows to its owner.
10+
* That is attribution, the step that completes the seed, not a user event, so
11+
* its write runs under `{ isSystem: true, skipAutomations: true }`. This file
12+
* pins what that context reaches on the engine, because a flag that is
13+
* silently dropped on the way would read as green in every double:
14+
*
15+
* 1. **the metadata-hook filter** — a hook bound through the real binder
16+
* (`bindHooksToEngine`, which is what stamps `entry.meta`) does not fire,
17+
* before or after, on any claimed row;
18+
* 2. **the per-row hook path** — code-registered hooks (the shape of the
19+
* audit writer, capability gates and the sharing projection) DO fire, once
20+
* per claimed row and phase, and every one of those contexts carries the
21+
* flag;
22+
* 3. **the record-change trigger's reading point** — every one of those
23+
* contexts has `session.skipTriggers === true`, the one field
24+
* `RecordChangeTrigger`'s handler reads before it dispatches a flow
25+
* (`skipAutomations` implies it, `engine.ts` `buildSession`).
26+
*
27+
* And it measures the read the flag deliberately stays off: the claim's
28+
* per-object read is a plain system read, so the same metadata hook, bound on
29+
* `beforeFind`/`afterFind` too, sees it. That is also the in-run positive
30+
* control: the metadata hook is live on this engine DURING the claim, so its
31+
* silence on the write events is the filter, not a hook that was never bound.
32+
*
33+
* Each case carries a second positive control on the SAME engine: the claim's
34+
* own write shape with a plain `{ isSystem: true }` context fires the metadata
35+
* hook per row and phase.
36+
*/
37+
38+
import { describe, it, expect, afterEach } from 'vitest';
39+
import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql';
40+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
41+
42+
import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js';
43+
44+
const SYS = { context: { isSystem: true } } as const;
45+
const ORG = 'org_alpha';
46+
const OTHER_ORG = 'org_north';
47+
const OWNER = 'usr_org_owner';
48+
const SOMEONE = 'usr_someone';
49+
const OBJECT = 'probe_deal';
50+
const PACKAGE_ID = 'com.objectstack.org-claim-dispatch-23008';
51+
52+
const PROBE_OBJECT = {
53+
name: OBJECT,
54+
label: 'Probe Deal',
55+
fields: {
56+
id: { name: 'id', type: 'text', primaryKey: true },
57+
name: { name: 'name', type: 'text' },
58+
stage: { name: 'stage', type: 'text' },
59+
owner_id: { name: 'owner_id', type: 'text' },
60+
organization_id: { name: 'organization_id', type: 'text' },
61+
},
62+
};
63+
64+
/** One dispatch a hook saw: what the engine handed it, reduced to the flags. */
65+
interface Seen {
66+
event: string;
67+
id: unknown;
68+
isSystem: unknown;
69+
skipAutomations: unknown;
70+
skipTriggers: unknown;
71+
}
72+
73+
const engines: ObjectQL[] = [];
74+
afterEach(async () => {
75+
while (engines.length) {
76+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
77+
}
78+
});
79+
80+
/**
81+
* A booted engine with ONE metadata-bound hook and ONE code-registered hook on
82+
* the probe object.
83+
*
84+
* The metadata hook is bound through `bindHooksToEngine` — the binder an app's
85+
* `defineStack({ hooks })` goes through — so its entry carries `meta` exactly as
86+
* a shipped app hook's does. It listens on the write events AND the read
87+
* events. The code hook is `registerHook` with no metadata, on the write events,
88+
* the shape of the audit writer, the sharing projection and
89+
* `RecordChangeTrigger`'s per-flow handler.
90+
*/
91+
async function boot() {
92+
const engine = new ObjectQL();
93+
engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' } as never) as never, true);
94+
await engine.init();
95+
engine.registerApp({
96+
id: PACKAGE_ID,
97+
name: 'Organization claim dispatch',
98+
version: '1.0.0',
99+
type: 'plugin',
100+
scope: 'system',
101+
objects: [PROBE_OBJECT],
102+
} as never);
103+
await engine.syncSchemas();
104+
engines.push(engine);
105+
106+
const metadataHook: Seen[] = [];
107+
const codeHook: Seen[] = [];
108+
const record = (into: Seen[]) => async (ctx: any) => {
109+
into.push({
110+
event: ctx.event,
111+
id: ctx.input?.id,
112+
isSystem: ctx.session?.isSystem,
113+
skipAutomations: ctx.session?.skipAutomations,
114+
skipTriggers: ctx.session?.skipTriggers,
115+
});
116+
};
117+
118+
const bound = bindHooksToEngine(
119+
engine,
120+
[
121+
{
122+
name: 'probe_app_hook',
123+
object: OBJECT,
124+
events: ['beforeFind', 'afterFind', 'beforeUpdate', 'afterUpdate'],
125+
handler: record(metadataHook),
126+
} as never,
127+
],
128+
{ packageId: PACKAGE_ID },
129+
);
130+
expect(bound.registered, 'the metadata hook must be bound for its absence to mean anything').toBe(4);
131+
132+
for (const event of ['beforeUpdate', 'afterUpdate'] as const) {
133+
engine.registerHook(event, record(codeHook) as never, { object: OBJECT, packageId: 'probe.code-hook' });
134+
}
135+
136+
return { engine, metadataHook, codeHook };
137+
}
138+
139+
/** Seed rows straight through the driver: the seed path is not the subject here. */
140+
async function seed(engine: ObjectQL, rows: Record<string, unknown>[]): Promise<void> {
141+
const driver = (engine as any).getDriverForObject(OBJECT);
142+
for (let i = 0; i < rows.length; i += 500) {
143+
await driver.bulkCreate(OBJECT, rows.slice(i, i + 500));
144+
}
145+
}
146+
147+
async function ownersOf(engine: ObjectQL): Promise<Map<string, unknown>> {
148+
const rows = await (engine as any).find(OBJECT, { fields: ['id', 'owner_id'] }, SYS);
149+
return new Map(rows.map((r: any) => [r.id, r.owner_id ?? null]));
150+
}
151+
152+
const writeEvents = (seen: Seen[]) => seen.filter((s) => s.event === 'beforeUpdate' || s.event === 'afterUpdate');
153+
154+
describe('[#23008] the organization seed-ownership claim dispatches no metadata-bound automation (real engine)', () => {
155+
it('no metadata hook fires on the write; code hooks fire once per claimed row and phase with the flag and skipTriggers', async () => {
156+
const { engine, metadataHook, codeHook } = await boot();
157+
const N = 12;
158+
const claimed = Array.from({ length: N }, (_, i) => `d${i}`);
159+
await seed(engine, [
160+
...claimed.map((id, i) => ({ id, name: `Deal ${i}`, stage: 'open', organization_id: ORG, owner_id: null })),
161+
// Out of the claim's scope: owned already, and another organization's row.
162+
{ id: 'owned', name: 'Owned', stage: 'open', organization_id: ORG, owner_id: SOMEONE },
163+
{ id: 'foreign', name: 'Foreign', stage: 'open', organization_id: OTHER_ORG, owner_id: null },
164+
]);
165+
166+
const result = await claimOrgSeedOwnership(engine, ORG, OWNER);
167+
// Taken before any read of the test's own: `ownersOf` is a `find` too.
168+
const metadataSeen = [...metadataHook];
169+
const codeSeen = [...codeHook];
170+
171+
expect(result).toEqual([{ object: OBJECT, count: N }]);
172+
const owners = await ownersOf(engine);
173+
for (const id of claimed) expect(owners.get(id)).toBe(OWNER);
174+
expect(owners.get('owned')).toBe(SOMEONE);
175+
expect(owners.get('foreign')).toBeNull();
176+
177+
// ⭐ 1 — the metadata-hook filter: not one write dispatch, before or after.
178+
expect(writeEvents(metadataSeen), 'a metadata-bound hook fired for the claim write').toEqual([]);
179+
// In-run positive control, and the read's measurement: the claim's one read
180+
// of this object is a plain system read, so the same metadata hook saw it.
181+
expect(metadataSeen.map((s) => s.event)).toEqual(['beforeFind', 'afterFind']);
182+
for (const s of metadataSeen) {
183+
expect(s.isSystem).toBe(true);
184+
expect(s.skipAutomations).toBeUndefined();
185+
}
186+
187+
// ⭐ 2 — the per-row path still runs code-registered hooks: one dispatch per
188+
// claimed row and phase, so audit and sharing keep their per-row input.
189+
for (const event of ['beforeUpdate', 'afterUpdate']) {
190+
const ids = codeSeen.filter((s) => s.event === event).map((s) => s.id);
191+
expect([...ids].sort(), `${event} per claimed row`).toEqual([...claimed].sort());
192+
}
193+
expect(codeSeen).toHaveLength(2 * N);
194+
// ⭐ 3 — every one of those contexts carries the flag AND `skipTriggers`,
195+
// the field the record-change trigger reads before dispatching a flow.
196+
for (const s of codeSeen) {
197+
expect(s).toEqual({ event: s.event, id: s.id, isSystem: true, skipAutomations: true, skipTriggers: true });
198+
}
199+
200+
// Positive control, same engine, same rows: the claim's own write shape
201+
// (a by-id `update`) under a plain system context fires the metadata hook
202+
// per row and phase, and carries no skip flag.
203+
metadataHook.length = 0;
204+
codeHook.length = 0;
205+
for (const id of claimed) {
206+
await (engine as any).update(OBJECT, { id, stage: 'touched' }, SYS);
207+
}
208+
expect(writeEvents(metadataHook)).toHaveLength(2 * N);
209+
expect(codeHook).toHaveLength(2 * N);
210+
for (const s of codeHook) {
211+
expect(s.skipAutomations).toBeUndefined();
212+
expect(s.skipTriggers).toBeUndefined();
213+
}
214+
}, 120_000);
215+
216+
it('a second run claims nothing and dispatches no write hook of either kind', async () => {
217+
const { engine, metadataHook, codeHook } = await boot();
218+
await seed(engine, [{ id: 'd0', name: 'Deal 0', stage: 'open', organization_id: ORG, owner_id: null }]);
219+
220+
expect(await claimOrgSeedOwnership(engine, ORG, OWNER)).toEqual([{ object: OBJECT, count: 1 }]);
221+
metadataHook.length = 0;
222+
codeHook.length = 0;
223+
224+
expect(await claimOrgSeedOwnership(engine, ORG, OWNER)).toEqual([]);
225+
expect(writeEvents(metadataHook)).toEqual([]);
226+
expect(codeHook).toEqual([]);
227+
}, 120_000);
228+
});

‎packages/plugins/organizations/src/claim-org-seed-ownership.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,45 @@ describe('claimOrgSeedOwnership', () => {
9090
await claimOrgSeedOwnership(ql, ORG, OWNER);
9191
expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]);
9292
});
93+
94+
// ── [#23008] the claim's write dispatches no metadata-bound automation ────
95+
96+
it('every claim write runs as isSystem with skipAutomations; the read stays a plain system read', async () => {
97+
// Re-owning a seed row is attribution, not a user event, so the write runs
98+
// with metadata-bound automation off: no app hook, no record-change flow,
99+
// hence no approval and no notification. Code-registered hooks (audit,
100+
// sharing) still run — the real-engine half of this pin is
101+
// `claim-org-seed-ownership-dispatch.pin.test.ts`; this half pins that
102+
// EVERY write this function issues carries the flag, across objects.
103+
const schemas = [
104+
{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] },
105+
{ name: 'crm_deal', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] },
106+
];
107+
const { ql, updates } = makeQL(schemas, {
108+
crm_lead: [
109+
{ id: 'l1', organization_id: ORG, owner_id: null },
110+
{ id: 'l2', organization_id: ORG, owner_id: null },
111+
],
112+
crm_deal: [{ id: 'd1', organization_id: ORG, owner_id: null }],
113+
});
114+
115+
expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([
116+
{ object: 'crm_lead', count: 2 },
117+
{ object: 'crm_deal', count: 1 },
118+
]);
119+
expect(updates).toHaveLength(3);
120+
121+
const writeContexts = ql.update.mock.calls.map((call: any[]) => call[2]?.context);
122+
expect(writeContexts).toHaveLength(3);
123+
for (const context of writeContexts) {
124+
expect(context).toEqual({ isSystem: true, skipAutomations: true });
125+
}
126+
// The read stays a plain system read: a read starts no record-change flow,
127+
// and the flag would also switch off an app's read-filter hooks.
128+
const readContexts = ql.find.mock.calls.map((call: any[]) => call[2]?.context);
129+
expect(readContexts).toHaveLength(2);
130+
for (const context of readContexts) {
131+
expect(context).toEqual({ isSystem: true });
132+
}
133+
});
93134
});

‎packages/plugins/organizations/src/claim-org-seed-ownership.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,21 @@
1919
* Scoped to a single org (`organization_id = organizationId`) so it never
2020
* touches another tenant's rows. Idempotent: only NULL-owned rows are updated.
2121
* `managedBy` and `sys_*` tables are skipped.
22+
*
23+
* ## [#23008] The claim dispatches no automation
24+
*
25+
* Re-owning a seed row is attribution, the step that completes the seed, not a
26+
* user event, so every write runs under {@link CLAIM_WRITE_CTX} — the same
27+
* context its single-tenant twin `claimSeedOwnership` adopted (#22067). The
28+
* engine reads `skipAutomations` in two places: the hook dispatch skips every
29+
* hook bound FROM METADATA (an app's lifecycle hooks), and it implies
30+
* `skipTriggers`, so the record-change trigger dispatches no flow — no approval
31+
* opens and no notification goes out for the claim. Hooks plugins register in
32+
* code carry no metadata binding and still run on every claimed row: the audit
33+
* writer, capability gates and the sharing projection. The opt-out never
34+
* bypasses audit or sharing (#2922).
35+
*
36+
* The real-engine half of this is `claim-org-seed-ownership-dispatch.pin.test.ts`.
2237
*/
2338

2439
import type { ServiceObject } from '@objectstack/spec/data';
@@ -32,8 +47,31 @@ interface ClaimOwnershipOptions {
3247
};
3348
}
3449

50+
/**
51+
* The context of the claim's per-object READ: system-elevated, nothing more.
52+
*
53+
* The flag stays off the read on purpose. A read starts no record-change flow,
54+
* and a metadata-bound `beforeFind` hook is an app's read-filter seam: keeping
55+
* it in force keeps the row set the claim selects equal to what every other
56+
* system read of that object sees.
57+
*/
3558
const SYSTEM_CTX = { isSystem: true };
3659

60+
/**
61+
* The context of the claim's WRITE: system-elevated, with metadata-bound
62+
* automation off. See "The claim dispatches no automation" above.
63+
*
64+
* `skipAutomations` alone, never a narrower flag. It is the spec's one spelling
65+
* of "skip metadata hooks AND record-change flows, keep code-registered hooks"
66+
* (`ExecutionContext`, `@objectstack/spec/kernel`), and `skipTriggers` alone
67+
* would leave the app's metadata hooks firing on every claimed row.
68+
*
69+
* A plain literal, like {@link SYSTEM_CTX}: `pnpm check:tenant-audit-census`
70+
* reads `isSystem` off it statically and does not see through a `satisfies`
71+
* wrapper.
72+
*/
73+
const CLAIM_WRITE_CTX = { isSystem: true, skipAutomations: true };
74+
3775
function hasField(schema: ServiceObject, field: string): boolean {
3876
const fields: any = (schema as any)?.fields;
3977
if (!fields) return false;
@@ -48,7 +86,8 @@ function hasField(schema: ServiceObject, field: string): boolean {
4886
* (a) are not `managedBy` (skip sys_/auth/platform tables),
4987
* (b) are not `sys_*`-namespaced,
5088
* (c) declare BOTH `owner_id` and `organization_id`,
51-
* and updates the org's unowned rows as `isSystem`. Returns a per-object summary.
89+
* and updates the org's unowned rows as `isSystem` with `skipAutomations`.
90+
* Returns a per-object summary.
5291
*/
5392
export async function claimOrgSeedOwnership(
5493
ql: OrgScopingEngine,
@@ -92,7 +131,7 @@ export async function claimOrgSeedOwnership(
92131
for (const row of list) {
93132
if (!row?.id) continue;
94133
try {
95-
await ql.update(schema.name, { id: row.id, owner_id: ownerUserId }, { context: SYSTEM_CTX });
134+
await ql.update(schema.name, { id: row.id, owner_id: ownerUserId }, { context: CLAIM_WRITE_CTX });
96135
updated += 1;
97136
} catch (e) {
98137
logger?.warn?.(`[org-scoping] claimOrgSeedOwnership failed for ${schema.name}:${row.id}`, {

0 commit comments

Comments
 (0)