Skip to content

Commit e67ba80

Browse files
fix(plugin-security): the seed ownership claim writes with skipAutomations — no app hooks, flows, approvals or notifications on the first sign-up (#22069)
Fixes #22067 Clause-②: no ## What changed `claimSeedOwnership` hands every seeded row to the first platform admin. Its one write, `reown` (a predicate write per unowned shape, paged only when the engine refuses it for the per-row hook ceiling), now runs with `{ isSystem: true, skipAutomations: true }` instead of a bare `{ isSystem: true }`. This is triage's ruling on the card (comment 6035454999), verbatim: "Run `reown` with `{ isSystem: true, skipAutomations: true }`. This is the seed's own principle (`engine.ts:5413`: seed loads end-state data, not user events), carried to the attribution write that completes the seed." The page read (`readPage`) is unchanged. | file | change | | --- | --- | | `packages/plugins/plugin-security/src/claim-seed-ownership.ts` | `CLAIM_WRITE_CTX`, used by `reown` only; the header says why, and which hooks still run | | `packages/plugins/plugin-security/src/claim-seed-ownership.test.ts` | every `update` call (refused whole-set attempts, fallback pages, the closing whole-set write) carries exactly `{ isSystem: true, skipAutomations: true }` | | `packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts` (new) | real `ObjectQL` over `SqlDriver`: what the flag reaches, and the per-row hook ceiling | | `packages/qa/dogfood/test/seed-ownership-claim-dispatch.dogfood.test.ts` (new) | booted app, first sign-up over REST: no automation in the claim window; audit rows and sharing grants still land | | `.changeset/22067-seed-claim-skip-automations.md` | `patch` for `@objectstack/plugin-security`, with the line that app hooks no longer fire for the seed ownership claim | No `packages/objectql` change, the claim stays inside the request, and no narrower flag was added. ## Latency A/B, built from source Example: `examples/app-showcase`, the in-repo example that carries metadata hooks and record-change flows on seeded objects. Each run: `OS_SEED_ADMIN=0 objectstack dev --fresh --no-seed-admin --log-level debug` on an empty database. The seed inserts 132 rows; 20 s after `[Seeder] Seed loading complete`, the first user signs up with `POST /api/v1/auth/sign-up/email` (curl `time_total`). The claim window runs from `first user promoted to platform admin` to `handed 127 seeded record(s) to platform admin` in the same request's log. - **before**: this branch with `claim-seed-ownership.ts` restored to `56bf27affb` (blob `78f3850d4a`), `@objectstack/plugin-security` rebuilt; `dist/` verified to carry the base write (`multi: true, context: SYSTEM_CTX9 }` x1, `CLAIM_WRITE_CTX` x0), then restored by blob and rebuilt. - **after**: this branch, rebuilt. - One shared container for every run: absolute seconds are shared-box readings, and the ratios are what carries. | leg | sign-up wall time (3 runs) | claim window (3 runs) | | --- | --- | --- | | before | 1.540 / 1.670 / 1.675 s | 1.113 / 1.200 / 1.145 s | | after | 0.947 / 0.954 / 1.016 s | 0.499 / 0.466 / 0.557 s | Claim-window counts, identical in all three runs of each leg: | in the claim window | before | after | | --- | --- | --- | | rows claimed | 127 (18 of 22 eligible objects) | 127 (18 of 22) | | predicate writes (`Update operation starting`) | 44 | 44 | | app hook bodies run (`[BodyRunner] hook fired`) | 10 | 0 | | record-change flow dispatches (start condition evaluated) | 117 | 0 | | flows run / approvals opened (`approval node suspended run`) | 2 / 2 | 0 / 0 | | emails handed to the transport (`[LogTransport] would send email`) | 0 | 0 | | metadata-bound dispatches skipped (`Skipping metadata-bound hook (skipAutomations)`) | 0 | 416 | - The showcase's flows gate on transitions (`status == "done" && previous.status != "done"` and the like), so an owner-only change evaluates 117 start conditions and runs none of them, with one exception. The draft `showcase_approver_bindings` flow has no condition, so the claim ran it on both seeded `showcase_field_zoo` rows and opened 2 approval requests, each "resolved to no concrete approver". The showcase wires no email transport, so 0 emails before and after. - The 416 skipped dispatches: 35 app-hook dispatches (`showcase_normalize_task_title` 10, `showcase_guard_task_reopen` 10, `showcase_audit_task_completion` 10, `showcase_warn_over_budget` 5), 127 `sys_stamp_audit_update`, and 254 `sys_stamp_audit_insert` on plugin-audit's 127 `sys_audit_log` and 127 `sys_activity` inserts. - The card's hotcrm reading (17.7.0, a 354-row seed, about 45 s, 1,254 app hooks, 8 flow runs, 2 approvals, 8 emails) is far larger than anything in this repo's examples. The dogfood fixture below carries the hotcrm shape: a condition-free flow that notifies and opens an approval, on seeded rows. ## The pins - **The claim dispatches no automation**, measured on a booted app (`seed-ownership-claim-dispatch.dogfood.test.ts`). Four seeded deals carry a condition-free metadata hook and a condition-free record-change flow (notify the owner, then an approval). The real chain is mounted: automation, `RecordChangeTriggerPlugin`, approvals, messaging, `AuditPlugin` and sharing. The first user signs up over `POST /api/v1/auth/sign-up/email` (dev admin seed off). The claim window shows 0 metadata-hook dispatches, 0 flow runs, 0 approvals and 0 notifications, and every deal's `owner_id` is the admin. A positive control (the admin's own `PATCH` of one deal) fires the hook twice and runs the flow once, with its approval. - **Audit and sharing still run (#2922).** The same window has one `sys_audit_log` update row per claimed deal, each recording the new owner and carrying `created_at`. It also has one `sys_record_share` rule grant per deal, from a criteria rule (`record.owner_id != null`, shared with the `owner_id` field) whose grants only the claim's owner change can earn. Before the sign-up there are 0 grants. - **What the flag reaches**, measured on a real engine (`claim-seed-ownership-dispatch.pin.test.ts`). A hook bound through `bindHooksToEngine` (so it carries `meta`, like an app hook) gets 0 dispatches. A code-registered hook gets one per-row dispatch per claimed row and phase, each with `isSystem`, `skipAutomations` and `skipTriggers` true. `skipTriggers` is the one field `RecordChangeTrigger`'s handler reads before dispatching a flow. Control: a plain system predicate write over the same rows fires the metadata hook 2 x N. - **The per-row hook ceiling (mechanism hypothesis 2), measured.** With `MAX_BULK_PER_ROW_HOOK_ROWS + 500` unowned rows, a `{ isSystem: true, skipAutomations: true }` predicate write is still refused whole (`code` and `limit` asserted, nothing written, no hook run). The engine counts matched rows against the ceiling whenever any hook covers the object and does not consult the flag. The claim then pages to every row (10 500 of 10 500), with 0 metadata-hook dispatches. `CLAIM_PAGE_ROWS` is unchanged, because its derivation is still right. - **Every caller (mechanism hypothesis 3).** Both callers write through `claimSeedOwnership`: the promotion pass (`bootstrap-platform-admin.ts`, inside the first sign-up) and the `app:seeded` settle pass (`security-plugin.ts` `claimSeedOwnershipOnSettle`, on every boot including warm boots). The changeset names both. **Ablation** (`node scripts/ablation-replace.mjs`, anchor `CLAIM_WRITE_CTX = { isSystem: true, skipAutomations: true };` replaced with `CLAIM_WRITE_CTX = { isSystem: true };`, landed x1 to x0, blob `fa63cffb37` to `7b581f3b26`): - `ablation-dist-preflight` found the marker in `dist/index.js` and `dist/index.mjs`. - The claim unit test went red: `expected { isSystem: true } to deeply equal { isSystem: true, …(1) }`. - The real-engine pin went red twice: `a metadata-bound hook fired for the claim: expected [ …(80) ] to deeply equal []`, and on the fallback `expected [ …(21000) ] to deeply equal []`. - The dogfood pin went red: `appHooks: 8, flowRuns: 4, approvals: 4, notifications: 4` against all-zero. - Restore: blob == HEAD and `git diff HEAD` empty, `@objectstack/plugin-security` rebuilt, and `--absent` preflight shows the marker gone from all 6 built files with a clean tree. ## Owner-change readers (first-party, in tree) I enumerated every metadata-bound hook and record-triggered flow in `examples/**` and `packages/**`. Outside test fixtures, `packages/**` ships none. **None reads an `owner_id` change**, so this needs no decision: - **app-crm.** Hook `opportunity_stage_probability` (`crm_opportunity`, before insert and update) reads `stage`. `convert-lead` is a screen flow, not record-triggered. - **app-showcase hooks** (`src/data/hooks/index.ts`). `showcase_normalize_task_title` reads `title`, `showcase_audit_task_completion` reads `done`, `showcase_warn_over_budget` reads `spent`/`budget`, `showcase_stamp_inquiry_defaults` reads `status`/`source` on insert, and `showcase_guard_task_reopen` reads `done`. - **app-showcase record-triggered flows** (`src/automation/flows/index.ts` unless noted). Their start conditions read `status` (`showcase_task_completed`, `_task_completed_slack`, `_task_completed_rest_ping`, `_task_done_notify_owner`, `_fan_out_notify`, `_resilient_sync`, `_project_closure`, `_invoice_signoff`, `_expense_signoff`, `_committee_quorum`), `assignee` (`showcase_task_assigned_notify`), `budget` (`showcase_budget_approval`), `health` (`showcase_project_escalation`), `priority` (`showcase_urgent_task_alert`) and `title` (`showcase_dynamic_approval`, `dynamic-approval.flow.ts`). Three are insert-only (`showcase_declarative_connector_ping`, `_mcp_connector_echo`, `_task_follow_up`). The draft `showcase_approver_bindings` (`approver-bindings.flow.ts`) has no condition. Several notify `{record.owner}`, a business field, not `owner_id`. - **app-todo.** Hook `task_logic` and flow `task_completion` read `status`. - **Fixture only.** `packages/verify/src/handle.fixture.ts` hooks on `hnd_deal` read no owner field. ## Gates and tests (head `aa21b6c3f9`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived on this change with no paths, gives 69 families: the 57 at dispatch plus 12 for the changeset and the new files. All 69 were run on `aa21b6c3f9`, exit code captured before any pipe, every one `exit 0`. `--ran` with the exit codes: "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero …)". - `check-tenant-audit-census`: "OK -- 233 write call sites certified". The write context is a plain literal on purpose. Spelled `{ … } as const satisfies ExecutionContext`, it moved this write from "decidably elevated" to "elevation undecidable" in the census (122/103 against the page's 123/102), because the census does not unwrap `satisfies`. - `check:engine-double-contract`: "OK — 980 pinned". `check:nul-bytes`: "OK (scanned 10064 text file(s) …)". `check:dual-build-cjs-loads`: "106 published require entry point(s) across 66 package(s) load", after building the 8 packages its prerequisite named. - `@objectstack/plugin-security`, after `pnpm --filter '@objectstack/plugin-security^...' build`: - `typecheck` passes: `tsc` plus `check:test-typecheck: OK … 0 error(s)`; - `vitest run` gives 169 files, 3640 passed, 45 skipped. - Dogfood tests that drive the seed claim. Every `bootStack` boot runs it through the dev admin seed; the new one runs it through REST. - `--project isolated`: `seed-ownership-claim-dispatch`, `owner-anchor-and-bulk-writes`, `authored-row-write-scope`, `predicate-write-unreadable-not-matched`, `bulk-widener-probe` and `admin-platform-admin-standing`, 6 files, 46 passed. - `--project shared-showcase`: `showcase-private-owd` and `showcase-public-read-owd`, 2 files, 8 passed. - `@objectstack/dogfood` `typecheck` passes. - The rest of the dogfood suite is left to CI's Dogfood Regression Gate. - `eslint --no-inline-config --format json` on the 4 changed `.ts` files gives 4 files, 0 errors, 0 warnings. Each file matches the config (`--print-config` exits 0). This repo's config enables no type-aware linting (`eslint.config.mjs` near line 327: no `parserOptions.project`), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **ObjectQL's `sys_stamp_audit_insert` / `sys_stamp_audit_update` are skipped under `skipAutomations`.** They are bound through `ql.bindHooks` (`packages/objectql/src/plugin.ts`, packageId `sys:audit`), so they carry `meta`. The A/B log shows them among the 416 skipped dispatches. - For this write that changes nothing. The claim has no user to stamp into `updated_by`, and every driver stamps `created_at`/`updated_at` itself. Measured on the dogfood fixture: the claimed rows' `updated_at` advances (`…:55.456Z` to `…:57.042Z`), `updated_by` stays null as before, and the claim's audit rows carry `created_at`. - It does contradict the `ExecutionContext.skipAutomations` description, which lists audit among the hooks that still run. For a data import with "run automations" unchecked, `created_by`/`updated_by` stamping would be skipped. That is NOT MEASURED. Noted, not filed. Carrier: none. - **The multi-tenant twin, `claimOrgSeedOwnership`** (`packages/plugins/organizations/src/claim-org-seed-ownership.ts`), re-owns seed rows with single-id writes under a bare `{ isSystem: true }`, so it has the same dispatch shape. It is not touched here. Its reach is NOT MEASURED (it needs a multi-tenant boot). Carrier: none. - **The draft `showcase_approver_bindings`** says "Draft on purpose", but draft flows fire their triggers (service-automation logs that at boot). At `56bf27affb` the first sign-up's claim ran it and opened 2 approval requests with no concrete approver. On this branch the claim no longer reaches it. A user edit of a `showcase_field_zoo` row still would; that is NOT MEASURED. Carrier: none. References only: #14530, #14719, #21486, objectstack-ai/hotcrm#2008. --- _Generated by [Claude Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 879bd38 commit e67ba80

5 files changed

Lines changed: 702 additions & 5 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
The seed ownership claim no longer fires app hooks or record-change flows
6+
7+
Clause-②: no
8+
9+
On a freshly seeded database, the first sign-up promotes the first user to platform admin, and `claimSeedOwnership` hands every seeded row to that admin inside the same request. 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, approvals opened on seeded records and notifications went to the new admin. Measured on hotcrm `56d98f7e` (17.7.0, a 354-row seed), the first sign-up took about 45 s, and in that time the claim fired 1,254 app hooks, ran 8 flows, opened 2 approvals and handed 8 emails to the transport.
10+
11+
The claim's write now runs with `{ isSystem: true, skipAutomations: true }`. The seed itself is end-state data written without automation, and the claim keeps that rule for the write that completes it.
12+
13+
- **App hooks no longer fire for the seed ownership claim.** No hook bound from metadata (an app's `hooks`, sandboxed bodies included) 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+
- **Which claims.** Every pass of the claim: the promotion pass inside the first sign-up, and the pass that runs on `app:seeded` on every boot, the first and every later one.
15+
- **Still runs.** Hooks that plugins register in code still run, so the claim still writes one audit row per claimed record (plugin-audit) and plugin-sharing still recomputes the grants the owner change earns. Every claimed row's `owner_id` is the admin and its `updated_at` still advances, exactly as before. The per-row hook ceiling and the paged fallback for very large objects are unchanged.
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. None of the hooks or flows in ObjectStack's own example apps reads it.
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22067] What the seed ownership claim's write DISPATCHES — measured on a REAL
5+
* `ObjectQL` over a real `SqlDriver` (better-sqlite3 `:memory:`).
6+
*
7+
* The claim re-owns seed rows to the platform admin. That is attribution, the
8+
* step that completes the seed, not a user event — so its write runs under
9+
* `{ isSystem: true, skipAutomations: true }`. This file pins what that context
10+
* reaches on the engine, because a flag that is silently dropped on the
11+
* predicate-write path would read as green in every double:
12+
*
13+
* 1. **the metadata-hook filter** — a hook bound through the real binder
14+
* (`bindHooksToEngine`, which is what stamps `entry.meta`) does not fire,
15+
* before or after, on any claimed row;
16+
* 2. **the per-row hook path** — code-registered hooks DO fire, once per
17+
* matched row, and every per-row context carries the flag;
18+
* 3. **the record-change trigger's reading point** — every one of those
19+
* contexts has `session.skipTriggers === true`, the one field
20+
* `RecordChangeTrigger`'s handler reads before it dispatches a flow
21+
* (`skipAutomations` implies it, `engine.ts` `buildSession`). The booted-app
22+
* half — the real trigger, approvals, notifications, audit rows and the
23+
* sharing projection — is `packages/qa/dogfood/test/
24+
* seed-ownership-claim-dispatch.dogfood.test.ts`.
25+
*
26+
* And it measures the claim's one structural dependency on hook dispatch, the
27+
* per-row hook ceiling: the claim's fallback page size is derived from it, so
28+
* if the flag ever exempted a write from the ceiling the fallback would be dead
29+
* code and the page size wrong. It does not — the engine asks whether ANY hook
30+
* covers the object before it counts the matched rows, and code-registered
31+
* hooks still do — so an over-ceiling claim is still refused whole and still
32+
* pages to every row.
33+
*
34+
* Each case carries its positive control on the SAME engine: a plain
35+
* `{ isSystem: true }` predicate write over the same rows fires the metadata
36+
* hook per row. Without it, "the hook fired 0 times" could be a hook that was
37+
* never bound.
38+
*/
39+
40+
import { describe, it, expect, afterEach } from 'vitest';
41+
import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql';
42+
import { SqlDriver } from '@objectstack/driver-sql';
43+
import {
44+
BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE,
45+
MAX_BULK_PER_ROW_HOOK_ROWS,
46+
} from '@objectstack/spec/data';
47+
48+
import { claimSeedOwnership } from './claim-seed-ownership.js';
49+
50+
const SYS = { context: { isSystem: true } } as const;
51+
const ADMIN = 'usr_admin_human';
52+
const SEED_IDENTITY = 'usr_system';
53+
const OBJECT = 'probe_deal';
54+
55+
const PROBE_OBJECT: any = {
56+
name: OBJECT,
57+
label: 'Probe Deal',
58+
fields: {
59+
id: { type: 'text', label: 'Id', primary: true },
60+
name: { type: 'text', label: 'Name' },
61+
stage: { type: 'text', label: 'Stage' },
62+
owner_id: { type: 'text', label: 'Owner' },
63+
},
64+
};
65+
66+
/** One dispatch a hook saw: what the engine handed it, reduced to the flags. */
67+
interface Seen {
68+
event: string;
69+
mode: unknown;
70+
isSystem: unknown;
71+
skipAutomations: unknown;
72+
skipTriggers: unknown;
73+
}
74+
75+
const engines: ObjectQL[] = [];
76+
afterEach(async () => {
77+
while (engines.length) {
78+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
79+
}
80+
});
81+
82+
/**
83+
* A booted engine with ONE metadata-bound hook and ONE code-registered hook on
84+
* the probe object, both on `beforeUpdate` and `afterUpdate`.
85+
*
86+
* The metadata hook is bound through `bindHooksToEngine` — the binder an app's
87+
* `defineStack({ hooks })` goes through — so its entry carries `meta` exactly as
88+
* a shipped app hook's does. The code hook is `registerHook` with no metadata,
89+
* the shape of plugin-audit's writer, plugin-sharing's rule projection and
90+
* `RecordChangeTrigger`'s per-flow handler.
91+
*/
92+
async function boot() {
93+
const engine = new ObjectQL();
94+
engine.registerDriver(
95+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
96+
true,
97+
);
98+
await engine.init();
99+
engine.registerApp({
100+
id: 'com.objectstack.claim-dispatch-22067',
101+
name: 'Claim dispatch',
102+
version: '1.0.0',
103+
type: 'plugin',
104+
scope: 'system',
105+
objects: [PROBE_OBJECT],
106+
} as any);
107+
await engine.syncSchemas();
108+
engines.push(engine);
109+
110+
const metadataHook: Seen[] = [];
111+
const codeHook: Seen[] = [];
112+
const record = (into: Seen[]) => async (ctx: any) => {
113+
into.push({
114+
event: ctx.event,
115+
mode: ctx.dispatch?.mode,
116+
isSystem: ctx.session?.isSystem,
117+
skipAutomations: ctx.session?.skipAutomations,
118+
skipTriggers: ctx.session?.skipTriggers,
119+
});
120+
};
121+
122+
const bound = bindHooksToEngine(
123+
engine,
124+
[
125+
{
126+
name: 'probe_app_hook',
127+
object: OBJECT,
128+
events: ['beforeUpdate', 'afterUpdate'],
129+
handler: record(metadataHook),
130+
} as any,
131+
],
132+
{ packageId: 'com.objectstack.claim-dispatch-22067' },
133+
);
134+
expect(bound.registered, 'the metadata hook must be bound for its absence to mean anything').toBe(2);
135+
136+
for (const event of ['beforeUpdate', 'afterUpdate'] as const) {
137+
engine.registerHook(event, record(codeHook), { object: OBJECT, packageId: 'probe.code-hook' });
138+
}
139+
140+
return { engine, metadataHook, codeHook };
141+
}
142+
143+
/**
144+
* Seed rows straight through the driver — the seed path is not the subject
145+
* here, and the claim reads only what is stored. Chunked, so no single
146+
* statement nears SQLite's bound-parameter limit.
147+
*/
148+
async function seed(engine: ObjectQL, rows: Record<string, unknown>[]): Promise<void> {
149+
const driver = (engine as any).getDriverForObject(OBJECT);
150+
for (let i = 0; i < rows.length; i += 500) {
151+
await driver.bulkCreate(OBJECT, rows.slice(i, i + 500));
152+
}
153+
}
154+
155+
async function ownersOf(engine: ObjectQL): Promise<Map<string, unknown>> {
156+
const rows = await (engine as any).find(OBJECT, { fields: ['id', 'owner_id'] }, SYS);
157+
return new Map(rows.map((r: any) => [r.id, r.owner_id ?? null]));
158+
}
159+
160+
describe('[#22067] the seed ownership claim dispatches no metadata-bound automation (real engine)', () => {
161+
it('the whole-set write: no metadata hook fires, code hooks fire per row with the flag and skipTriggers', async () => {
162+
const { engine, metadataHook, codeHook } = await boot();
163+
const N = 40;
164+
await seed(engine, Array.from({ length: N }, (_, i) => ({
165+
id: `d${i}`,
166+
name: `Deal ${i}`,
167+
stage: 'open',
168+
owner_id: i % 2 === 0 ? null : SEED_IDENTITY,
169+
})));
170+
171+
const result = await claimSeedOwnership(engine, ADMIN);
172+
173+
expect(result).toEqual([{ object: OBJECT, count: N }]);
174+
const owners = await ownersOf(engine);
175+
expect(owners.size).toBe(N);
176+
for (const owner of owners.values()) expect(owner).toBe(ADMIN);
177+
178+
// ⭐ 1 — the metadata-hook filter: not one dispatch, before or after.
179+
expect(metadataHook, 'a metadata-bound hook fired for the claim').toEqual([]);
180+
181+
// ⭐ 2 — the per-row path still runs code-registered hooks, one context per
182+
// matched row and phase, so audit and sharing keep their per-row input.
183+
expect(codeHook.filter((s) => s.event === 'beforeUpdate')).toHaveLength(N);
184+
expect(codeHook.filter((s) => s.event === 'afterUpdate')).toHaveLength(N);
185+
// ⭐ 3 — every one of those contexts carries the flag AND `skipTriggers`,
186+
// the field the record-change trigger reads before dispatching a flow.
187+
for (const s of codeHook) {
188+
expect(s).toEqual({
189+
event: s.event,
190+
mode: 'per-row',
191+
isSystem: true,
192+
skipAutomations: true,
193+
skipTriggers: true,
194+
});
195+
}
196+
197+
// Positive control, same engine, same rows: a plain system predicate write
198+
// fires the metadata hook per row and phase, and carries no skip flag.
199+
metadataHook.length = 0;
200+
codeHook.length = 0;
201+
await (engine as any).update(OBJECT, { stage: 'touched' }, {
202+
where: { owner_id: ADMIN },
203+
multi: true,
204+
context: { isSystem: true },
205+
});
206+
expect(metadataHook).toHaveLength(2 * N);
207+
expect(codeHook).toHaveLength(2 * N);
208+
for (const s of codeHook) {
209+
expect(s.skipAutomations).toBeUndefined();
210+
expect(s.skipTriggers).toBeUndefined();
211+
}
212+
}, 120_000);
213+
214+
it('over the per-row hook ceiling: still refused whole under the flag, and the fallback pages to every row', async () => {
215+
const { engine, metadataHook, codeHook } = await boot();
216+
const N = MAX_BULK_PER_ROW_HOOK_ROWS + 500;
217+
await seed(engine, Array.from({ length: N }, (_, i) => ({
218+
id: `d${String(i).padStart(6, '0')}`,
219+
name: `Deal ${i}`,
220+
stage: 'open',
221+
owner_id: null,
222+
})));
223+
224+
// The measurement the fallback's page size rests on: with metadata hooks
225+
// switched off, code-registered hooks still cover the object, so the
226+
// ceiling still applies and refuses the whole write — nothing written,
227+
// no hook run.
228+
await expect(
229+
(engine as any).update(OBJECT, { owner_id: ADMIN }, {
230+
where: { owner_id: null },
231+
multi: true,
232+
context: { isSystem: true, skipAutomations: true },
233+
}),
234+
).rejects.toMatchObject({ code: BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE, limit: MAX_BULK_PER_ROW_HOOK_ROWS });
235+
expect([...(await ownersOf(engine)).values()].every((o) => o === null)).toBe(true);
236+
expect(codeHook).toEqual([]);
237+
238+
const result = await claimSeedOwnership(engine, ADMIN);
239+
240+
// Every row claimed, through the fallback: more than one landed write,
241+
// none of them over the ceiling.
242+
expect(result).toEqual([{ object: OBJECT, count: N }]);
243+
const owners = await ownersOf(engine);
244+
expect(owners.size).toBe(N);
245+
for (const owner of owners.values()) expect(owner).toBe(ADMIN);
246+
const afterDispatches = codeHook.filter((s) => s.event === 'afterUpdate');
247+
expect(afterDispatches).toHaveLength(N);
248+
expect(metadataHook, 'a metadata-bound hook fired on a fallback page').toEqual([]);
249+
for (const s of codeHook) {
250+
expect(s.skipAutomations).toBe(true);
251+
expect(s.skipTriggers).toBe(true);
252+
}
253+
254+
// Positive control on this engine too, kept under the ceiling.
255+
metadataHook.length = 0;
256+
await (engine as any).update(OBJECT, { stage: 'touched' }, {
257+
where: { id: { $in: ['d000000', 'd000001', 'd000002'] } },
258+
multi: true,
259+
context: { isSystem: true },
260+
});
261+
expect(metadataHook).toHaveLength(6);
262+
}, 180_000);
263+
});

‎packages/plugins/plugin-security/src/claim-seed-ownership.test.ts‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ interface RecordedWrite {
1919
multi: boolean;
2020
/** Ids this write actually re-owned, in fixture order. */
2121
matched: string[];
22+
/** The execution context the write carried — what the engine's hook dispatch reads. */
23+
context: any;
2224
}
2325

2426
/** One recorded call to `ql.find` — the page request the writer issued. */
@@ -27,6 +29,8 @@ interface RecordedRead {
2729
where: any;
2830
limit: number;
2931
returned: number;
32+
/** The execution context the read carried. */
33+
context: any;
3034
}
3135

3236
/**
@@ -90,11 +94,17 @@ function makeQL(
9094
const ceiling = opts.ceiling ?? MAX_BULK_PER_ROW_HOOK_ROWS;
9195
const ql: any = {
9296
registry: { getAllObjects: () => schemas },
93-
find: vi.fn(async (object: string, query: any) => {
97+
find: vi.fn(async (object: string, query: any, options?: any) => {
9498
const all = rowsByObject[object] ?? [];
9599
const hits = all.filter((r) => rowMatches(r, query?.where ?? {}));
96100
const page = typeof query?.limit === 'number' ? hits.slice(0, query.limit) : hits;
97-
reads.push({ object, where: query?.where, limit: query?.limit, returned: page.length });
101+
reads.push({
102+
object,
103+
where: query?.where,
104+
limit: query?.limit,
105+
returned: page.length,
106+
context: options?.context,
107+
});
98108
return page.map((r) => ({ id: r.id }));
99109
}),
100110
update: vi.fn(async (object: string, data: any, options: any) => {
@@ -127,6 +137,7 @@ function makeQL(
127137
where,
128138
multi: options?.multi === true,
129139
matched: matched.map((r) => r.id),
140+
context: options?.context,
130141
});
131142
return matched.length;
132143
}),
@@ -512,4 +523,41 @@ describe('claimSeedOwnership', () => {
512523
expect(warn).toHaveBeenCalledTimes(2);
513524
expect(warn.mock.calls[0][0]).toContain('those rows stay unowned');
514525
});
526+
527+
// ── [#22067] the claim's write dispatches no metadata-bound automation ────
528+
529+
it('every reown write — whole-set attempt and fallback page alike — runs as isSystem with skipAutomations', async () => {
530+
// Re-owning a seed row is attribution, not a user event, so the write runs
531+
// with metadata-bound automation off: no app hook, no record-change flow,
532+
// hence no approval and no notification. Code-registered hooks (audit,
533+
// sharing) still run — the real-engine half of this pin is
534+
// `claim-seed-ownership-dispatch.pin.test.ts`; this half pins that EVERY
535+
// write this function issues carries the flag, including the page writes
536+
// a large object takes and the whole-set attempts the engine refuses.
537+
//
538+
// 12 000 unowned rows: over the per-row hook ceiling, so one fixture drives
539+
// a refused whole-set attempt, the page writes, and the closing whole-set
540+
// write that lands.
541+
const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }] }];
542+
const rows = Array.from({ length: 12_000 }, (_, i) => ({ id: `l${i}`, owner_id: null }));
543+
const { ql, writes, reads } = makeQL(schemas, { crm_lead: rows });
544+
545+
expect(await claimSeedOwnership(ql, ADMIN)).toEqual([{ object: 'crm_lead', count: 12_000 }]);
546+
547+
// Both write shapes were exercised, so the assertion below covers both.
548+
expect(writes.some((w) => (w.where as any)?.id)).toBe(true);
549+
expect(writes.some((w) => !(w.where as any)?.id)).toBe(true);
550+
expect(reads.length).toBeGreaterThan(0);
551+
552+
// Every CALL, not only the writes that landed: a refused attempt is
553+
// refused for its row count, and the context it asked with is the same.
554+
const contexts = ql.update.mock.calls.map((call: any[]) => call[2]?.context);
555+
expect(contexts.length).toBeGreaterThan(writes.length);
556+
for (const context of contexts) {
557+
expect(context).toEqual({ isSystem: true, skipAutomations: true });
558+
}
559+
// The page read stays a plain system read — the flag is about dispatch,
560+
// and a read dispatches no write automation.
561+
for (const r of reads) expect(r.context?.isSystem).toBe(true);
562+
});
515563
});

0 commit comments

Comments
 (0)