Skip to content

Commit c8d06a9

Browse files
fix(objectql): skipAutomations never skips the builtin audit stamps (#22106)
Fixes #22070 Clause-②: no ## What changed `ExecutionContext.skipAutomations` skips the lifecycle hooks bound from metadata. Its own description says it "must never bypass security or audit", and so does the engine's comment at the skip. ObjectQL's builtin audit stamps (`sys_stamp_audit_insert` / `sys_stamp_audit_update`) were registered through the hook binder, which puts `meta` on every registration. `meta` is exactly what the engine's skip keys on, so the opt-out skipped the audit stamps along with the app's hooks. - `packages/objectql/src/plugin.ts`, `registerAuditHooks`: every `sys:audit` builtin is now registered in code with `ql.registerHook(event, wrapDeclarativeHook(h, h.handler, { logger, metrics: ql.getHookMetricsRecorder() }), { object, priority, packageId: 'sys:audit' })` and no `meta`, after `unregisterHooksByPackage('sys:audit')`. - It uses the same wrapper and the same metrics recorder that `bindHooks` reads at bind time. AppPlugin installs the Server-Timing `hooks` feed in its `init`, ahead of this `start`, so that span keeps counting the builtins. - The unregister-by-package keeps a second ObjectQLPlugin on a shared engine idempotent. The constructor accepts an existing `ql`. - So the opt-out is the only thing that changes for a builtin. Priority and package id are unchanged. - `packages/objectql/src/hook-binder.ts`: the comment that called `meta` inert ("The engine ignores unknown options today; this is forward-only") now says what `meta` is: the binding the opt-out keys on. - `engine.ts` is unchanged. Its one rule stays "`meta` means bound from metadata", and no dispatch site gains a second criterion. The rejected alternative was exempting `sys:audit` at the engine's two dispatch sites. It would add a magic package id to every dispatch site, and it contradicts the spec text "bound from metadata (`bindHooksToEngine`) are skipped". - `.changeset/22070-audit-stamp-under-skip-automations.md`: `@objectstack/objectql` patch. ## Measurement: which stamped column is lost under the flag The door is the import runner: `runImport`, then `ObjectStackProtocolImplementation`, then the engine, then driver-sql on SQLite, with a session user and an organization. Base is `3d9188502e`. | column | automations on (base) | automations off (base) | automations off (head) | |---|---|---|---| | `created_by` | written | **lost** (NULL) | written | | `updated_by`, insert and update | written | **lost** (NULL) | written | | a declared plain `tenant_id` field | written | **lost** (NULL) | written | | `created_at`, `updated_at` | written | written (driver-sql stamps them) | written | | `organization_id`, or a declared `tenancy.tenantField` (the wall column) | written | written, by the driver from `DriverOptions.tenantId` | written | - The tenancy wall held throughout. Triage kept p2 on this measurement (6040619736). - The head column was read with the same probe after this change (the same probe at head `5da266a319`, after a workspace rebuild: `Tests 4 passed (4)`; with automations off the app hook still does not run). - On a driver that stamps nothing (the in-package pin's stub), `created_at` / `updated_at` are lost too at base, and written at head. ## Pins `packages/objectql/src/audit-stamp-skip-automations.test.ts`. They were committed red at base in `8e0bfab19d`. They boot a real ObjectKernel with ObjectQLPlugin over a store that stamps nothing, so every audit value on a stored row is the hook's. - **(a)** Under `skipAutomations` with a session user, an insert stamps `created_by`, `updated_by`, `created_at`, `updated_at` and the declared `tenant_id`. An update stamps `updated_by` and `updated_at`. The answers are the same as without the flag. - **(b)** Control: a metadata-bound app hook (`bindHooks`, `packageId: 'test-app'`) is still skipped under the flag, and runs without it. - **(c)** The import door: `runImport` with `runAutomations: false`, through the real `ObjectStackProtocolImplementation`, stamps the session user on the row it creates and on the row it updates. - **(d)** Every `sys:audit` entry on the engine carries no `meta`, so a builtin added to the list later cannot reach the opt-out. ## Reverse verification (committed head `b97585c76f`) - **Mutation:** `node scripts/ablation-replace.mjs` (WRAP mode, plus a shell trap on EXIT/INT/TERM) replaced the anchor `this.ql.unregisterHooksByPackage('sys:audit');` with the old binder call `(this.ql as any).bindHooks(builtinHooks, { packageId: 'sys:audit' }); return;`. - The tool reported: anchor 1 to 0, replacement 0 to 1, blob `9a41af3ad873` to `4d1c8b04da7c`. - **Mutated run:** `Tests 3 failed | 1 passed (4)`. (a), (c) and (d) are red, and (b) is green. - **Restore:** blob after restore `9a41af3ad873` equals the HEAD blob `9a41af3ad873`, `git diff HEAD` is empty, and `git status --porcelain` is empty. - **Restored run:** `Tests 4 passed (4)`. - The subject is imported from `src` by relative path, so no `dist/` leg was needed for the pins. ## Clause-② measured on the built declarations - `@objectstack/objectql` was built from the pre-fix source (`HEAD~1` blobs restored in place, trap-guarded) and from head. - `core.d.ts` is byte-identical. - `index.d.ts` differs only in the JSDoc of the private member `registerAuditHooks` (6 comment lines). Pre-fix sha256 is `b0e8cffd9ce1`, the same as base `3d9188502e`. Head is `c0cfc96e72f3`. - No export, key, option or type moves. - The head `dist/` was rebuilt afterwards. `ablation-dist-preflight --absent` for the old JSDoc text exited 0, and the tree is clean. ## Local verification (head `5da266a319`, `origin/main` `bafb58bb08` merged) - `node scripts/pm/dispatch-gates.mjs --commands` (no paths; merge base `bafb58bb0`, 4 paths): all 65 derived commands exit 0. - `--ran` reconcile: `✓ dispatch-gates --ran: 65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero — all 65 recorded an exit code and none of them is 3)`. - Artifact-roster block: all 54 run. 51 exit 0. - 3 exit 2, `NOT WIRED` / `NOT MEASURED` with no PR context: `check-closing-target-claim.mjs`, `check-partof-closing-keyword.mjs`, `check-single-claim-paths.mjs`. They are left to CI on this PR. - `check-partof-closing-keyword.mjs` was also run with `PR_BODY` set to this body (exit 0: `this PR carries no Part-of/closing-keyword contradiction`). - Symbol-anchor sweeps: `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors` all exit 0. - `@objectstack/objectql` full suite, both vitest projects (`vitest run --maxWorkers=2`): `Test Files 380 passed (380)`, `Tests 7517 passed (7517)`, lock VERDICT command-exit 0. - `@objectstack/objectql` typecheck: `tsc --noEmit` is clean, and `check:test-typecheck` reports `OK — 40 file(s) / 234 error(s) / 65 pinned signature(s) held`. - `check:objectql-double-limit` was red on the first pass: the new pin's stub `find` ignored the caller's `limit`. It was fixed in `86ee151e64`, and the gate is green at head. - The workspace was rebuilt at head before the dist-reading gates ran (`turbo run build --filter=!@objectstack/docs`: 72/72). - `plugin-security`'s suite was not run, because its file is not edited here. ## Acceptance notes - **`plugin.ts` `tenant_id` reading (not changed here; a read-only inference):** `record.tenant_id = record.tenant_id ?? session.organizationId` keeps a caller-supplied `tenant_id` with no flag. - That is the client-preferred shape that #15964 and #16311 removed for `created_at` / `created_by`. - Not measured. No shipped object declares a plain `tenant_id` the hook stamps: the one shipped `tenant_id` field, `sys_audit_log`'s, is stamped by plugin-audit's writer. - **For the services lane: `packages/plugins/plugin-security/src/claim-seed-ownership.ts` lines 69 to 72 become stale with this change.** - It says: "ObjectQL's own `sys_stamp_audit_*` builtins are bound through the hook binder, so they carry metadata and are skipped as well; for this write that changes nothing — the claim has no user to stamp into `updated_by`, and the drivers stamp `updated_at` themselves." - Proposed replacement: "ObjectQL's own `sys_stamp_audit_*` builtins are registered in code too (#22070), so they also run on every claimed row; for this write they stamp `updated_at` only, because the claim has no user to stamp into `updated_by`." - Left out of this PR on the seat's instruction. No change to #22067's code. - **#22067's `reown`:** on driver-sql, its `updated_at` was already written under the flag (the driver stamps it). It carries no `session.userId`, so it writes no `updated_by` before or after this change. - **H2:** the two stamps are the only platform builtins that carried `meta`. The binder's other callers are metadata sources: `plugin.ts` metadata-service binding and hook reload, and `runtime` app artifacts. No `registerHook` call under `packages/` passes `meta`. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 51290bc commit c8d06a9

4 files changed

Lines changed: 356 additions & 22 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
`skipAutomations` no longer skips ObjectQL's own audit stamps: a data import with "run automations & triggers" unchecked stamps `created_by` / `updated_by` from the session user again
6+
7+
Clause-②: no
8+
9+
`ExecutionContext.skipAutomations` suppresses the lifecycle hooks bound from metadata and, by its own
10+
description, never bypasses audit. ObjectQL's builtin audit stamps (`sys_stamp_audit_insert` on
11+
`beforeInsert`, `sys_stamp_audit_update` on `beforeUpdate`) were registered through the hook binder,
12+
so they carried the metadata binding the opt-out keys on and were skipped together with the app's
13+
hooks. Rows that a data import wrote with automations off landed with no `created_by` / `updated_by`
14+
and no value in a declared plain `tenant_id` field. On a driver that does not stamp its own
15+
timestamps, they also landed with no `created_at` / `updated_at`. The organization column was not
16+
affected: the driver stamps it from the engine's driver options either way.
17+
18+
The builtins are now registered in code (`registerHook`, no metadata binding), which is how the
19+
contract describes audit. They run under `skipAutomations` exactly as they run without it, and they
20+
keep the same wrapper, priority and `sys:audit` package id. Every hook bound from metadata is still
21+
skipped under the flag. ⛔ Nothing you author changes: no key, option, export or type is added or
22+
removed.
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #22070 — `skipAutomations` never bypasses ObjectQL's own audit stamps.
4+
//
5+
// ## The contract
6+
//
7+
// `ExecutionContext.skipAutomations` (`@objectstack/spec` kernel
8+
// `execution-context.zod.ts`) suppresses the lifecycle hooks bound FROM
9+
// METADATA, and states the other half in the same breath: "Hooks registered in
10+
// code by plugins — audit, capability gates, sharing projection — carry no
11+
// metadata binding and STILL run: this flag must never bypass security or
12+
// audit." The engine's skip says the same at `triggerHooks`.
13+
//
14+
// ## The defect
15+
//
16+
// The engine keys the skip on `entry.meta`, the metadata binding the hook
17+
// binder stamps on every registration it makes. ObjectQL's builtin audit stamps
18+
// (`sys_stamp_audit_insert` / `sys_stamp_audit_update`) were registered THROUGH
19+
// the binder, so they carried `meta` and the opt-out skipped them: a data
20+
// import with "run automations & triggers" unchecked — a real user in the
21+
// session — stored rows with no `created_by` / `updated_by` and no declared
22+
// `tenant_id`, and, on a driver that does not stamp its own timestamps, no
23+
// `created_at` / `updated_at` either. The organization column was never the
24+
// hook's: the driver stamps it from the engine's `DriverOptions.tenantId`,
25+
// flag or no flag, so the tenancy wall held throughout.
26+
//
27+
// ## The fix
28+
//
29+
// `ObjectQLPlugin.registerAuditHooks` registers the builtins IN CODE
30+
// (`engine.registerHook`, no `meta`), which is what the contract already says
31+
// audit is. The engine's rule stays one bit — `meta` means "bound from
32+
// metadata" — so no dispatch site gains a second criterion. The builtins keep
33+
// the binder's wrapper and the unregister-by-package, so the opt-out is the
34+
// only thing that changes for them. (a), (c) and (d) were red before it and (b)
35+
// was green.
36+
//
37+
// ## Pins
38+
//
39+
// (a) under `skipAutomations`, with a session user, an insert stamps
40+
// `created_by`, `updated_by`, `created_at`, `updated_at` and the declared
41+
// `tenant_id`, and an update stamps `updated_by` and `updated_at` — the
42+
// same answers as without the flag, through the real engine;
43+
// (b) control: a metadata-bound app hook is still skipped under the flag;
44+
// (c) the door: a `runImport` with automations off, through the real
45+
// DataProtocol, stamps the session user on the rows it creates and updates;
46+
// (d) the registration: every `sys:audit` entry carries no metadata binding,
47+
// so a builtin added to the list later cannot reach the opt-out either.
48+
//
49+
// The driver below stamps NOTHING itself, so every audit value on a stored row
50+
// is the hook's.
51+
52+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
53+
import { ObjectKernel, runImport } from '@objectstack/core';
54+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
55+
import { ObjectQLPlugin } from './plugin.js';
56+
import { ObjectQL } from './engine.js';
57+
58+
const OBJECT = 'audit_ticket';
59+
const T_INSERT = '2026-10-07T10:00:00.000Z';
60+
const T_UPDATE = '2026-10-07T11:00:00.000Z';
61+
const IMPORTER = 'usr_importer';
62+
const EDITOR = 'usr_editor';
63+
const ORG = 'org_acme';
64+
65+
/** A store-backed stub driver that stamps nothing: the stored row IS the verdict. */
66+
function makeStubDriver() {
67+
const rows = new Map<string, Record<string, any>>();
68+
const createOptions: any[] = [];
69+
const matches = (row: any, where: any): boolean => {
70+
if (!where || typeof where !== 'object') return true;
71+
return Object.entries(where).every(([k, v]: [string, any]) => {
72+
if (k === '$and') return (v as any[]).every((w) => matches(row, w));
73+
if (k.startsWith('$')) throw new Error(`stub driver: unsupported combinator ${k}`);
74+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
75+
return Object.entries(v).every(([op, target]) => {
76+
if (op === '$eq') return row?.[k] === target;
77+
if (op === '$in') return Array.isArray(target) && target.includes(row?.[k]);
78+
// REFUSE, never silently match.
79+
throw new Error(`stub driver: unsupported operator ${op}`);
80+
});
81+
}
82+
return row?.[k] === v;
83+
});
84+
};
85+
let n = 0;
86+
const driver: any = {
87+
name: 'audit-store', version: '0.0.0', supports: {},
88+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
89+
async syncSchema() {},
90+
async find(_o: string, ast: any) {
91+
const hits = Array.from(rows.values()).filter((r) => matches(r, ast?.where));
92+
// The caller's bound, applied AFTER the filter (the import runner's
93+
// match probe asks for `limit: 2`).
94+
const page = typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits;
95+
return page.map((r) => ({ ...r }));
96+
},
97+
async findOne(_o: string, ast: any) {
98+
for (const r of rows.values()) if (matches(r, ast?.where)) return { ...r };
99+
return null;
100+
},
101+
async create(_o: string, data: Record<string, unknown>, options?: any) {
102+
createOptions.push(options);
103+
n += 1;
104+
const id = (data.id as string) ?? `rec_${n}`;
105+
const row = { ...data, id };
106+
rows.set(id, row);
107+
return { ...row };
108+
},
109+
async update(_o: string, id: string, data: Record<string, unknown>) {
110+
const row = { ...rows.get(id), ...data, id };
111+
rows.set(id, row);
112+
return { ...row };
113+
},
114+
async delete(_o: string, id: string) { return rows.delete(id); },
115+
async count(_o: string, ast: any) {
116+
return Array.from(rows.values()).filter((r) => matches(r, ast?.where)).length;
117+
},
118+
};
119+
return { driver, rows, createOptions };
120+
}
121+
122+
/** The audit columns a stored row carries — `undefined` where nothing stamped one. */
123+
const auditOf = (row: Record<string, any> | undefined) => ({
124+
created_by: row?.created_by,
125+
updated_by: row?.updated_by,
126+
created_at: row?.created_at,
127+
updated_at: row?.updated_at,
128+
tenant_id: row?.tenant_id,
129+
});
130+
131+
describe('#22070 — skipAutomations never bypasses the builtin audit stamps', () => {
132+
let kernel: ObjectKernel;
133+
let objectql: ObjectQL;
134+
let stub: ReturnType<typeof makeStubDriver>;
135+
let appHookCalls: string[];
136+
137+
beforeEach(async () => {
138+
vi.useFakeTimers({ toFake: ['Date'] });
139+
vi.setSystemTime(new Date(T_INSERT));
140+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
141+
stub = makeStubDriver();
142+
await kernel.use({
143+
name: 'audit-store-plugin', type: 'driver', version: '1.0.0',
144+
init: async (ctx: any) => { ctx.registerService('driver.audit-store', stub.driver); },
145+
} as any);
146+
await kernel.use(new ObjectQLPlugin());
147+
await kernel.bootstrap();
148+
objectql = kernel.getService<ObjectQL>('objectql');
149+
// The audit family is NOT declared: the registry injects it, as on every
150+
// real object. `tenant_id` is declared, because the insert stamp writes it
151+
// only where the object carries the column.
152+
objectql.registry.registerObject({
153+
name: OBJECT,
154+
label: 'Audit Ticket',
155+
datasource: 'audit-store',
156+
fields: {
157+
code: { name: 'code', label: 'Code', type: 'text' },
158+
title: { name: 'title', label: 'Title', type: 'text' },
159+
tenant_id: { name: 'tenant_id', label: 'Tenant', type: 'text' },
160+
},
161+
} as any, 'test', 'test');
162+
// (b)'s subject: an app's lifecycle hook, bound from metadata exactly as a
163+
// `defineStack({ hooks })` app binds it.
164+
appHookCalls = [];
165+
objectql.bindHooks([
166+
{
167+
name: 'app_touch',
168+
object: OBJECT,
169+
events: ['beforeInsert', 'beforeUpdate'],
170+
handler: async (ctx: any) => { appHookCalls.push(ctx.event); },
171+
},
172+
], { packageId: 'test-app' });
173+
});
174+
175+
afterEach(async () => {
176+
vi.useRealTimers();
177+
if (kernel.getState() === 'running') await kernel.shutdown();
178+
});
179+
180+
const storedByCode = (code: string) => {
181+
const hits = Array.from(stub.rows.values()).filter((r) => r.code === code);
182+
expect(hits.length, `exactly one stored row for code ${code}`).toBe(1);
183+
return hits[0]!;
184+
};
185+
186+
/** One insert as IMPORTER, then one update as EDITOR, under the given flag. */
187+
async function insertThenUpdate(code: string, skipAutomations: boolean) {
188+
vi.setSystemTime(new Date(T_INSERT));
189+
const created: any = await objectql.insert(
190+
OBJECT,
191+
{ code, title: 'first' },
192+
{ context: { userId: IMPORTER, tenantId: ORG, skipAutomations } as any },
193+
);
194+
const afterInsert = auditOf(storedByCode(code));
195+
vi.setSystemTime(new Date(T_UPDATE));
196+
await objectql.update(
197+
OBJECT,
198+
{ id: created.id, title: 'second' },
199+
{ context: { userId: EDITOR, tenantId: ORG, skipAutomations } as any },
200+
);
201+
return { afterInsert, afterUpdate: auditOf(storedByCode(code)) };
202+
}
203+
204+
const EXPECTED_AFTER_INSERT = {
205+
created_by: IMPORTER,
206+
updated_by: IMPORTER,
207+
created_at: T_INSERT,
208+
updated_at: T_INSERT,
209+
tenant_id: ORG,
210+
};
211+
const EXPECTED_AFTER_UPDATE = {
212+
created_by: IMPORTER,
213+
updated_by: EDITOR,
214+
created_at: T_INSERT,
215+
updated_at: T_UPDATE,
216+
tenant_id: ORG,
217+
};
218+
219+
it('(a) the flag changes no audit stamp: insert and update stamp the same columns with and without it', async () => {
220+
const without = await insertThenUpdate('plain', false);
221+
const withFlag = await insertThenUpdate('skipped', true);
222+
223+
expect(without.afterInsert).toEqual(EXPECTED_AFTER_INSERT);
224+
expect(without.afterUpdate).toEqual(EXPECTED_AFTER_UPDATE);
225+
expect(withFlag.afterInsert).toEqual(EXPECTED_AFTER_INSERT);
226+
expect(withFlag.afterUpdate).toEqual(EXPECTED_AFTER_UPDATE);
227+
// The organization reaches the driver either way: it is the engine's
228+
// `DriverOptions.tenantId`, never a hook's write.
229+
expect(stub.createOptions.map((o) => o?.tenantId)).toEqual([ORG, ORG]);
230+
});
231+
232+
it('(b) control: a metadata-bound app hook still does not run under the flag', async () => {
233+
await insertThenUpdate('skipped', true);
234+
expect(appHookCalls).toEqual([]);
235+
236+
await insertThenUpdate('plain', false);
237+
expect(appHookCalls).toEqual(['beforeInsert', 'beforeUpdate']);
238+
});
239+
240+
it('(c) the import door: runImport with automations off stamps the session user on created and updated rows', async () => {
241+
const protocol = new ObjectStackProtocolImplementation(objectql as never);
242+
const base = {
243+
p: protocol as never,
244+
objectName: OBJECT,
245+
metaMap: new Map([
246+
['code', { name: 'code', type: 'text' }],
247+
['title', { name: 'title', type: 'text' }],
248+
]) as never,
249+
matchFields: ['code'],
250+
dryRun: false,
251+
runAutomations: false,
252+
trimWhitespace: false,
253+
createMissingOptions: false,
254+
skipBlankMatchKey: true,
255+
};
256+
257+
vi.setSystemTime(new Date(T_INSERT));
258+
const created = await runImport({
259+
...base,
260+
writeMode: 'insert',
261+
context: { userId: IMPORTER, tenantId: ORG },
262+
rows: [{ code: 'imp', title: 'first' }],
263+
});
264+
expect(created.created, JSON.stringify(created.results)).toBe(1);
265+
expect(auditOf(storedByCode('imp'))).toEqual(EXPECTED_AFTER_INSERT);
266+
267+
vi.setSystemTime(new Date(T_UPDATE));
268+
const updated = await runImport({
269+
...base,
270+
writeMode: 'update',
271+
context: { userId: EDITOR, tenantId: ORG },
272+
rows: [{ code: 'imp', title: 'second' }],
273+
});
274+
expect(updated.updated, JSON.stringify(updated.results)).toBe(1);
275+
expect(auditOf(storedByCode('imp'))).toEqual(EXPECTED_AFTER_UPDATE);
276+
277+
// The opt-out still means what the user asked for at the same door.
278+
expect(appHookCalls).toEqual([]);
279+
});
280+
281+
it('(d) every builtin audit registration carries no metadata binding', () => {
282+
const hooks = (objectql as unknown as { hooks: Map<string, Array<{ packageId?: string; meta?: unknown }>> }).hooks;
283+
const builtins = [...hooks.values()].flat().filter((entry) => entry.packageId === 'sys:audit');
284+
// One `beforeInsert` and one `beforeUpdate` stamp today.
285+
expect(builtins.length).toBeGreaterThanOrEqual(2);
286+
expect(builtins.filter((entry) => entry.meta !== undefined)).toEqual([]);
287+
});
288+
});

‎packages/objectql/src/hook-binder.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,9 +305,13 @@ export function bindHooksToEngine(
305305
object,
306306
priority: typeof hook.priority === 'number' ? hook.priority : 100,
307307
packageId: opts.packageId,
308-
// Reflect metadata so future tooling can introspect / unregister
309-
// and so we can detect duplicate name collisions.
310-
// The engine ignores unknown options today; this is forward-only.
308+
// `meta` is the METADATA BINDING, and the engine reads it: the
309+
// `skipAutomations` opt-out skips every entry that carries it
310+
// (`triggerHooks`, `dispatchUnscopedMultiWriteHooks`). So every hook
311+
// bound here is skippable by that opt-out. A platform hook that must
312+
// run under it — audit, security — is registered in code with
313+
// `engine.registerHook` and no `meta`, never through this binder
314+
// (#22070). `hookName` names the hook in the engine's diagnostics.
311315
...({ meta: hook, hookName: hook.name } as any),
312316
} as any);
313317
result.registered += 1;

0 commit comments

Comments
 (0)