From 0f9b2b150340203ab0675ac8107c25710d36c3eb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:28:23 +0000 Subject: [PATCH 1/5] feat(spec,objectql,runtime): declare HookContext.referentialCascadeDelete The delete twin of referentialFieldClear: true exactly on each child delete the engine's cascade issues while deleting a parent, absent on every other dispatch, and carried into the QuickJS sandbox by contract. The engine recognises the delete it issued through an engine-private, (object, id)-keyed AsyncLocalStorage token claimed once by that delete, not a context key: a context key would turn an absent caller context into a present one, and would ride buildHookApi into deletes a child's hooks issue through ctx.api. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- packages/objectql/src/engine.ts | 86 ++++++++++++++++++- packages/runtime/src/sandbox/body-runner.ts | 7 ++ .../runtime/src/sandbox/quickjs-runner.ts | 7 ++ packages/runtime/src/sandbox/script-runner.ts | 16 ++++ packages/spec/src/data/hook.test.ts | 52 +++++++++++ packages/spec/src/data/hook.zod.ts | 68 +++++++++++++++ 6 files changed, 235 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a8fde20ca13..a35c61ec2a4 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -4127,6 +4127,25 @@ function cascadeSetAdd(index: Map>, object: string, id: unkn return true; } +/** + * [#23221] The one child delete a cascade walk is issuing, for that delete to + * recognise itself by — see {@link ObjectQL.cascadeIssuedDeletes}. The walk + * opens one per `cascade` dependent, around its own `delete()` call, and the + * `delete()` whose object and id match claims it. + */ +interface CascadeIssuedDelete { + /** The child object the walk is deleting from, as the registry names it. */ + readonly object: string; + /** The child row's id, as a string (the walk's `String(depId)`). */ + readonly id: string; + /** + * Set by the `delete()` that recognised itself. One token, one dispatch: a + * later delete of the same row inside that call (a hook's own) finds it + * claimed and is not marked. + */ + claimed: boolean; +} + /** * [ADR-0010 §3.7] Stamp a manifest item with its owning package's * `(packageId, packageVersion)` before the registry stores it: the call the @@ -4269,6 +4288,27 @@ export class ObjectQL implements IObjectQLEngine { */ private readonly cascadeDeleteSets = new AsyncLocalStorage(); + /** + * [#23221] The child delete {@link walkReferencingRelations} is issuing right + * now, so that `delete()` can tell the cascade's own child delete from every + * other delete and project the fact onto the DECLARED + * `HookContext.referentialCascadeDelete` (see + * {@link claimReferentialCascadeDelete}). + * + * Ambient, for {@link cascadeDeleteSets}' reason — a context key would turn a + * caller's absent context into a present one on the child delete, and both + * `buildSession` and `buildHookApi` branch on its absence — and for one more + * the update twin's `__referentialFieldClear` never had to weigh, because its + * envelope semantics predated its declared projection: the child delete's + * context is the very object `buildHookApi` hands to `ScopedContext`, so a + * key stamped on it would ride into every write that child's hooks issue + * through `ctx.api`, and a hook's own delete during the cascade would read + * as cascade-issued. Keyed on the exact object and id the walk issues, and + * claimed once, the marker reaches that one delete's dispatches and no other. + * Engine-private: nothing a request carries can name a record into it. + */ + private readonly cascadeIssuedDeletes = new AsyncLocalStorage(); + private drivers = new Map(); private defaultDriver: string | null = null; private logger: Logger; @@ -6363,6 +6403,28 @@ export class ObjectQL implements IObjectQLEngine { return (execCtx as any)?.__referentialFieldClear === true ? true : undefined; } + /** + * [#23221] Build the declared `HookContext.referentialCascadeDelete` marker, + * the delete twin of {@link buildReferentialFieldClear}: `true` when THIS + * `delete()` of `object`/`id` is the child delete + * {@link walkReferencingRelations} issued for a `cascade` relation, and + * `undefined` for every other delete — the same "absent unless true" + * contract, so a handler reads `ctx.referentialCascadeDelete === true`. + * + * Reads {@link cascadeIssuedDeletes}, not the envelope (that field says why), + * and CLAIMS the token it matches: called once per `delete()`, after the + * `afterDelete` unit re-entry has returned, so the call that recognises + * itself is the one that dispatches the hooks, and nothing later inside it + * can match the same token again. + */ + private claimReferentialCascadeDelete(object: string, id: unknown): true | undefined { + const issued = this.cascadeIssuedDeletes.getStore(); + if (!issued || issued.claimed || id == null) return undefined; + if (issued.object !== object || issued.id !== String(id)) return undefined; + issued.claimed = true; + return true; + } + /** * Build the acting-user object (ADR-0068 EvalUser shape) surfaced to * validation-time predicates as `current_user` — notably per-option @@ -18688,7 +18750,21 @@ export class ObjectQL implements IObjectQLEngine { if (cascadeSetHas(cascadeSet.entered, childName, depId)) continue; // Recurse via the public delete so the child's own cascade, // hooks and events fire. - await this.delete(childName, { where: { id: depId }, context } as any); + // + // [#23221] Inside a token naming exactly this child, which that + // delete claims and projects onto the DECLARED + // `HookContext.referentialCascadeDelete` (see + // claimReferentialCascadeDelete): on the real request path the + // caller's context reaches the child unchanged, so without it a + // freeze guard cannot tell this delete from a hand delete of the + // same row. The context is passed as before, by reference and + // unmarked — see `cascadeIssuedDeletes` for why the marker is not + // a context key. A grandchild is issued by the child's own walk, + // inside a token of its own. + await this.cascadeIssuedDeletes.run( + { object: childName, id: String(depId), claimed: false }, + () => this.delete(childName, { where: { id: depId }, context } as any), + ); } else { // [#3023] Clear the FK as an engine-internal referential-integrity // write, tagged so plugin-security's ownership-anchor guard (#3004) @@ -18799,6 +18875,9 @@ export class ObjectQL implements IObjectQLEngine { // reads THIS predicate cannot be looser than this method. const dispatch = resolveEngineDeleteDispatch(options as EngineDeleteDispatchInput | undefined); const id: any = dispatch.kind === 'by-id' ? dispatch.id : undefined; + // [#23221] Is this the child delete a cascade issued? Decided here, once, + // before any middleware or hook runs, so the claim is this call's alone. + const referentialCascadeDelete = this.claimReferentialCascadeDelete(object, id); const opCtx: OperationContext = { object, @@ -18828,6 +18907,11 @@ export class ObjectQL implements IObjectQLEngine { input: { id, options: opCtx.options }, session: this.buildSession(opCtx.context), provenance: this.buildProvenance(opCtx.context), + // [#23221] The declared cascade-delete marker, the delete twin of + // `update()`'s `referentialFieldClear` spread: conditional for the + // same reason, "absent unless true". Bound once on this context, + // which `afterDelete` reuses, so both phases carry it. + ...(referentialCascadeDelete ? { referentialCascadeDelete: true as const } : {}), user: this.buildUser(opCtx.context), api: this.buildHookApi(opCtx.context), transaction: opCtx.context?.transaction, diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index 88b9571b5aa..4d95a252dff 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -1163,6 +1163,13 @@ function buildSandboxContext( // else is left ABSENT, so `ctx.referentialFieldClear === true` reads "not // a referential cleanup" exactly as the spec prescribes. ...(engineCtx?.referentialFieldClear === true ? { referentialFieldClear: true } : {}), + // [#23221] Its delete twin, the declared cascade-delete marker — carried the + // same way, in its declared shape only (`true`), absent otherwise, so a + // body's `ctx.referentialCascadeDelete === true` reads exactly what an + // in-process handler reads. A body has no other way to learn it: the engine + // keeps no envelope key for this fact at all, so `ctx.api` carries nothing + // a body could sniff instead. + ...(engineCtx?.referentialCascadeDelete === true ? { referentialCascadeDelete: true } : {}), crypto: globalThis.crypto, }; } diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index db81f98f411..450aaacf903 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -587,6 +587,13 @@ export class QuickJSScriptRunner implements ScriptRunner { if (ctx.referentialFieldClear === true) { vm.setProp(ctxObj, 'referentialFieldClear', vm.true); } + // [#23221] Its delete twin, the declared cascade-delete marker — the same + // true-only installation for the same reasons: a body reads + // `ctx.referentialCascadeDelete === true`, an absent key means "not a delete + // the cascade issued", and a plain boolean needs no freeze/graft ceremony. + if (ctx.referentialCascadeDelete === true) { + vm.setProp(ctxObj, 'referentialCascadeDelete', vm.true); + } // [commit f19475c0a] The action face's caller-scope load verdict — same true-only // installation, same reason: a body reads `ctx.recordLoadDenied === true` // and an absent key means "nothing was refused". A plain boolean, so no diff --git a/packages/runtime/src/sandbox/script-runner.ts b/packages/runtime/src/sandbox/script-runner.ts index 0ac8193d726..f988cddfddd 100644 --- a/packages/runtime/src/sandbox/script-runner.ts +++ b/packages/runtime/src/sandbox/script-runner.ts @@ -252,6 +252,22 @@ export interface ScriptContext { * read it as `ctx.referentialFieldClear === true`. */ referentialFieldClear?: boolean; + /** + * The engine's cascade-delete marker, marshalled for the HOOK face (#23221) + * — the delete twin of {@link referentialFieldClear}: `true` exactly when + * this delete is the child delete the engine's cascade issued for a row that + * references a record being deleted through a `cascade` relation, at any + * depth, in both phases of that delete. Mirrors the declared + * `HookContextSchema.referentialCascadeDelete` (`@objectstack/spec/data`), + * which objectql's `delete()` assembly sets. + * + * Marshalled EXPLICITLY, and here more than anywhere: the engine keeps no + * envelope key for this fact, so there is nothing on `ctx.api` a body could + * read instead — this member is the body's only channel. Absent (never + * `false`) on every other dispatch and on the action face; read it as + * `ctx.referentialCascadeDelete === true`. + */ + referentialCascadeDelete?: boolean; /** * The caller's options bag, PROJECTED to the two members ADR-0058's D2 * declares visible to the `before*` phase — `multi` and `where` — for the diff --git a/packages/spec/src/data/hook.test.ts b/packages/spec/src/data/hook.test.ts index 1996e245073..2d2fee23136 100644 --- a/packages/spec/src/data/hook.test.ts +++ b/packages/spec/src/data/hook.test.ts @@ -553,6 +553,58 @@ describe('HookContextSchema', () => { }); }); + // [#23221] The declared cascade-delete marker, the delete twin of the block + // above. Same reason for the parse legs: an undeclared key is stripped by + // `.parse()`, so the declaration is what lets a parsed context keep it. The + // populate half is pinned in objectql's cascade suite, the sandbox half in + // runtime's QuickJS integration pin. + describe('Cascade-Delete Marker — declared, and true only on a delete the engine cascade issued', () => { + it('keeps referentialCascadeDelete through a parse, typed — the guard idiom evaluates on the parsed value', () => { + const context = HookContextSchema.parse({ + object: 'quote_line', + event: 'beforeDelete', + input: { id: 'l1', options: {} }, + referentialCascadeDelete: true, + ql: {}, + }); + + // Typed read, no cast — the declaration is what makes this compile. + expect(context.referentialCascadeDelete === true).toBe(true); + }); + + it('is ABSENT — never false — on a context that does not carry it', () => { + const context = HookContextSchema.parse({ + object: 'quote_line', + event: 'beforeDelete', + input: { id: 'l1', options: {} }, + ql: {}, + }); + + expect(context.referentialCascadeDelete).toBeUndefined(); + expect('referentialCascadeDelete' in context).toBe(false); + }); + + it('is a key of its own, not a spelling of the update twin: either parses without the other', () => { + const cascade = HookContextSchema.parse({ + object: 'quote_line', + event: 'beforeDelete', + input: { id: 'l1', options: {} }, + referentialCascadeDelete: true, + ql: {}, + }); + const cleanup = HookContextSchema.parse({ + object: 'note', + event: 'beforeUpdate', + input: { id: 'n1', data: { account: null }, options: {} }, + referentialFieldClear: true, + ql: {}, + }); + + expect('referentialFieldClear' in cascade).toBe(false); + expect('referentialCascadeDelete' in cleanup).toBe(false); + }); + }); + describe('Input Parameters', () => { it('should accept find input', () => { const context = HookContextSchema.parse({ diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 321bf811dc5..d9bccd9d784 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -1242,6 +1242,74 @@ export const HookContextSchema = lazySchema(() => z.object({ + 'the sandbox boundary by contract; server-derived and never client-supplied.', ), + /** + * Cascade-Delete Marker + * `true` exactly when THIS delete is one the engine's cascade issued — the + * child DELETE `cascadeDeleteRelations` performs for each row that + * references a record being deleted through a relation whose + * `deleteBehavior` resolves to `cascade` (a `master_detail` unless it + * declares `restrict`; a `lookup` or `tree` that declares `cascade`), at any + * depth: a grandchild the child's own cascade removes is marked too. Present + * in both phases of that delete (`beforeDelete`, `afterDelete`). Absent on + * every other dispatch; read it as `ctx.referentialCascadeDelete === true`. + * + * The delete twin of {@link referentialFieldClear}. That key marks the + * `set_null` cleanup UPDATE; this one marks the `cascade` child DELETE. A + * cleanup update never carries this key, and a cascade child delete never + * carries that one. + * + * ## Why a declared key + * + * The engine deletes each child AS THE CALLER: the cascade recurses through + * the public delete with the caller's own context, so on the path a real + * request takes (a REST `DELETE` carrying a `userId`) `ctx.user`, + * `ctx.session` and `ctx.input` are identical between a child the cascade + * removes and a user's hand delete of that same child. An app guard that + * freezes settled records (a quote's lines once the quote is accepted) had + * no declared way to refuse the hand delete while yielding to the cascade, + * and refusing both leaves the parent, and every record whose own cascade + * reaches it, undeletable. + * + * ## No operation-private spelling: this key is the only channel + * + * Unlike `referentialFieldClear`, this fact has no `__`-prefixed twin on the + * ExecutionContext, and nothing on that envelope is the contract. The engine + * recognises the delete it issued through an engine-private ambient record + * keyed on the exact object and id its cascade is deleting, claimed by that + * one delete. Two consequences follow, and both are part of the contract: + * + * - it does not ride the envelope, so a write a hook issues through + * `ctx.api` during a cascade dispatch is NOT marked, a delete included: + * that is the hook's own write, not one the cascade issued; + * - it does not turn a caller's absent context into a present one, so the + * child of a cascade that carried no context still sees no `ctx.session`. + * + * Being declared, it is carried across the QuickJS sandbox boundary by + * contract (`buildSandboxContext` / `installCtx` in `packages/runtime`), so a + * lowered `*.hook.ts` body reads it exactly as an in-process handler does. + * + * ## Trust model + * + * Server-derived, like `session.isSystem`: only the engine's cascade walk + * sets it, and nothing a request carries can reach the record it is read + * from. It is a signal to app code, not an authorization input: each child + * delete is still authorized as the caller, exactly as before. + * + * OPTIONAL for the same reason `referentialFieldClear` is: making it + * required would reject the partial contexts `HookContextSchema.parse` + * accepts today. The engine writes `true` or omits it — it never writes + * `false`. + */ + referentialCascadeDelete: z.boolean().optional().describe( + "Engine-produced marker: true exactly when this delete is one the engine's cascade issued " + + '(the child delete removing a row that references a record being deleted through a relation ' + + 'whose deleteBehavior resolves to cascade, at any depth), in both phases of that delete. ' + + 'Absent on every other delete, including a user hand-deleting the same child and a delete a ' + + 'hook issues through ctx.api; read as `ctx.referentialCascadeDelete === true`. The delete twin ' + + 'of referentialFieldClear, carried across the sandbox boundary by contract; server-derived, ' + + 'never client-supplied, and with no operation-private spelling to read instead.', + ), + /** * Transaction Handle * If the operation is part of a transaction, use this handle for side-effects. From eebe5efdbb9e9ac9c861e76de9fb2f85e53650f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:32:29 +0000 Subject: [PATCH 2/5] test(objectql,runtime): pin referentialCascadeDelete per face; add changeset objectql cascade suite: cascaded child (both phases, identity inherited) vs hand-delete control and root delete; grandchild; set_null cleanup keeps the update twin only; ctx.api writes from a cascaded child's hooks are not marked; the token is claimed once; an identity-less cascade marks its child and leaves no __ key on the envelope. runtime: the key observed from inside a real QuickJS body. Docblock wording corrected to what holds under the atomic plan. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- .../23221-cascade-delete-hook-signal.md | 26 ++ .../src/engine-cascade-delete.test.ts | 237 ++++++++++++++++++ packages/objectql/src/engine.ts | 7 +- ...-cascade-delete-signal.integration.test.ts | 167 ++++++++++++ packages/spec/src/data/hook.zod.ts | 5 +- 5 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 .changeset/23221-cascade-delete-hook-signal.md create mode 100644 packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts diff --git a/.changeset/23221-cascade-delete-hook-signal.md b/.changeset/23221-cascade-delete-hook-signal.md new file mode 100644 index 00000000000..79ca05d795a --- /dev/null +++ b/.changeset/23221-cascade-delete-hook-signal.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +'@objectstack/runtime': minor +--- + +A hook can now tell a child delete that a parent's cascade issues from a hand delete: `HookContext.referentialCascadeDelete`, the delete twin of `referentialFieldClear` + +Clause-②: yes + +When a record is deleted and a relation whose `deleteBehavior` resolves to `cascade` (a `master_detail` unless it declares `restrict`, or a `lookup` / `tree` that declares `cascade`) removes its children, the engine deletes each child as the calling user. The child's `beforeDelete` and `afterDelete` hooks therefore saw the same `ctx.user`, `ctx.session` and `ctx.input` as a user deleting that one child by hand. A guard that freezes settled records, such as a quote's lines once the quote is accepted, could refuse both deletes or neither, and refusing both made the parent, and every record whose cascade reaches it, undeletable. + +- **What the key means.** `ctx.referentialCascadeDelete === true` exactly on a delete the engine's cascade issued: the child delete, in both phases, at any depth (a grandchild that the child's own cascade removes is marked too). It is absent, never `false`, on every other delete: the delete the caller asked for, a user's hand delete of the same child, and any write a hook issues through `ctx.api` during a cascade, deletes included. The `set_null` cleanup update keeps `referentialFieldClear` and never carries this key, and a cascade child delete never carries `referentialFieldClear`. +- **The guard idiom.** Refuse a hand delete of a frozen line and yield to the cascade: + + ```ts + if (ctx.referentialCascadeDelete !== true && ctx.previous?.locked === true) { + throw new Error('This line belongs to a settled quote and cannot be deleted.'); + } + ``` + +- **Only the declared key is the contract.** There is no operation-private (`__`-prefixed) spelling of this fact on the execution context. The engine recognises the delete its cascade issued through an engine-private record keyed on that child's object and id, so nothing on `ctx.api`, `ctx.transaction` or any `__` key can stand in for it. Read `ctx.referentialCascadeDelete`. +- **In a sandboxed hook body.** The key is carried across the QuickJS boundary by contract, so a lowered `*.hook.ts` body reads `ctx.referentialCascadeDelete` exactly as an in-process handler does. +- **spec (minor):** `HookContextSchema` declares `referentialCascadeDelete` (boolean, optional). One optional engine-produced key on the non-strict runtime hook context; nothing previously valid changes meaning. +- **objectql (minor):** `delete()`'s hook-context assembly sets the key on the delete the cascade walk issues. The walk still hands the child the caller's context unchanged, so no other hook-context member moves. +- **runtime (minor):** `buildSandboxContext` and `installCtx` carry the key into the VM. diff --git a/packages/objectql/src/engine-cascade-delete.test.ts b/packages/objectql/src/engine-cascade-delete.test.ts index d259e862b57..b55d8e6c3aa 100644 --- a/packages/objectql/src/engine-cascade-delete.test.ts +++ b/packages/objectql/src/engine-cascade-delete.test.ts @@ -787,3 +787,240 @@ describe('cascadeDeleteRelations — [#13644] every reference-cleanup write carr expect(seen[0].sessionUserId).toBeUndefined(); }); }); + +// [#23221] The DECLARED cascade-delete marker — `HookContext. +// referentialCascadeDelete`, the delete twin of the block above. The cascade +// deletes each child AS THE CALLER (the walk recurses through the public +// `delete()` with the caller's context), so `ctx.user`, `ctx.session` and +// `ctx.input` are identical between a child the cascade removes and a hand +// delete of the same child; this key is the one discriminator. One pin per +// face: the cascade child (both phases, at any depth), its hand-delete control +// under the SAME identity, the set_null cleanup that must carry the update twin +// and not this key, and the writes a cascaded child's hooks issue through +// `ctx.api`, which must not be marked. The engine keeps no envelope key for +// this fact; the last pin holds that too. +describe('cascadeDeleteRelations — [#23221] every cascade-issued child delete carries the declared ctx.referentialCascadeDelete', () => { + let engine: ObjectQL; + + const CALLER = { userId: 'u1', isSystem: true }; + + // A frozen-lines shape: a master_detail child (cascade by default) and its + // own master_detail grandchild, plus an unrelated object a child's hook can + // write to through `ctx.api`. + const qline = { + name: 'qline', + label: 'Quote Line', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', type: 'text' as const }, + parent: { name: 'parent', type: 'master_detail' as const, reference: 'acct' }, + }, + }; + const qlineNote = { + name: 'qline_note', + label: 'Line Note', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + body: { name: 'body', type: 'text' as const }, + line: { name: 'line', type: 'master_detail' as const, reference: 'qline' }, + }, + }; + const sideLog = { + name: 'side_log', + label: 'Side Log', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + body: { name: 'body', type: 'text' as const }, + }, + }; + + type Cap = { + object: unknown; event: unknown; id: unknown; + marker: unknown; hasKey: boolean; hasFieldClear: boolean; sessionUserId: unknown; + }; + const capture = (ctx: any): Cap => ({ + object: ctx.object, + event: ctx.event, + id: ctx.input?.id, + marker: ctx.referentialCascadeDelete, + // ABSENT, not present-but-undefined: the contract is "absent unless true". + hasKey: 'referentialCascadeDelete' in ctx, + hasFieldClear: 'referentialFieldClear' in ctx, + sessionUserId: ctx.session?.userId, + }); + const listen = (seen: Cap[], object: string, events: string[]) => { + for (const e of events) engine.on(e, object, (ctx: any) => { seen.push(capture(ctx)); }); + }; + + beforeEach(async () => { + engine = new ObjectQL(); + const { driver } = makeStubDriver(); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of [acct, noteOptional, qline, qlineNote, sideLog]) engine.registry.registerObject(o as any); + }); + + it('true in BOTH phases of the cascaded child delete, identity inherited; the hand delete of the same child and the root delete carry no key', async () => { + const seen: Cap[] = []; + listen(seen, 'qline', ['beforeDelete', 'afterDelete']); + listen(seen, 'acct', ['beforeDelete', 'afterDelete']); + + const a = await engine.insert('acct', { name: 'Acme' }); + const l1 = await engine.insert('qline', { title: 'one', parent: a.id }); + const l2 = await engine.insert('qline', { title: 'two', parent: a.id }); + + // The user's hand delete of a line, under the SAME identity. + await engine.delete('qline', { where: { id: l1.id }, context: { ...CALLER } } as any); + const hand = seen.splice(0); + // The engine's cascade when the parent is deleted. + await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any); + const cascade = seen.splice(0); + + expect(hand.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']); + for (const o of hand) { + expect(o.marker, 'a hand delete must not read as cascade-issued').toBeUndefined(); + expect(o.hasKey, 'the key must be ABSENT on a hand delete, not present-but-undefined').toBe(false); + expect(o.sessionUserId).toBe('u1'); + } + + const lineDispatches = cascade.filter((o) => o.object === 'qline'); + expect(lineDispatches.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']); + for (const o of lineDispatches) { + expect(o.id, 'this really is the cascade removing l2').toBe(l2.id); + expect(o.marker, 'the cascaded child delete carries the declared marker').toBe(true); + // The inherited identity, the thing that erases every other + // discriminator, is present alongside the marker. + expect(o.sessionUserId).toBe('u1'); + expect(o.hasFieldClear, 'a cascade child delete is not the update twin').toBe(false); + } + const rootDispatches = cascade.filter((o) => o.object === 'acct'); + expect(rootDispatches.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']); + for (const o of rootDispatches) { + expect(o.hasKey, 'the delete the caller asked for is not cascade-issued').toBe(false); + } + expect(await engine.findOne('qline', { where: { id: l2.id } })).toBeNull(); + }); + + it('a grandchild the child cascade removes is cascade-issued too', async () => { + const seen: Cap[] = []; + listen(seen, 'qline_note', ['beforeDelete', 'afterDelete']); + + const a = await engine.insert('acct', { name: 'Acme' }); + const l = await engine.insert('qline', { title: 'one', parent: a.id }); + const n = await engine.insert('qline_note', { body: 'hi', line: l.id }); + + await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any); + + expect(seen.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']); + for (const o of seen) { + expect(o.id).toBe(n.id); + expect(o.marker).toBe(true); + expect(o.sessionUserId).toBe('u1'); + } + expect(await engine.findOne('qline_note', { where: { id: n.id } })).toBeNull(); + }); + + it('the set_null cleanup in the same cascade carries referentialFieldClear and NOT this key', async () => { + const seen: Cap[] = []; + listen(seen, 'note', ['beforeUpdate', 'afterUpdate']); + listen(seen, 'qline', ['beforeDelete', 'afterDelete']); + + const a = await engine.insert('acct', { name: 'Acme' }); + const n = await engine.insert('note', { body: 'hi', account: a.id }); + await engine.insert('qline', { title: 'one', parent: a.id }); + + await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any); + + const cleanup = seen.filter((o) => o.object === 'note'); + expect(cleanup.map((o) => o.event)).toEqual(['beforeUpdate', 'afterUpdate']); + for (const o of cleanup) { + expect(o.hasFieldClear, 'the cleanup update keeps its own marker').toBe(true); + expect(o.hasKey, 'and does not carry the delete twin').toBe(false); + } + const cascaded = seen.filter((o) => o.object === 'qline'); + expect(cascaded.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']); + for (const o of cascaded) { + expect(o.marker).toBe(true); + expect(o.hasFieldClear).toBe(false); + } + expect((await engine.findOne('note', { where: { id: n.id } }) as any).account).toBeNull(); + }); + + it("writes a cascaded child's hooks issue through ctx.api are not marked — a delete included, and an update", async () => { + const seen: Cap[] = []; + listen(seen, 'side_log', ['beforeDelete', 'beforeUpdate']); + const lineMarkers: unknown[] = []; + + const a = await engine.insert('acct', { name: 'Acme' }); + await engine.insert('qline', { title: 'one', parent: a.id }); + const s1 = await engine.insert('side_log', { body: 'keep' }); + const s2 = await engine.insert('side_log', { body: 'drop' }); + + engine.on('beforeDelete', 'qline', async (ctx: any) => { + lineMarkers.push(ctx.referentialCascadeDelete); + await ctx.api.object('side_log').deleteById(s2.id); + }); + engine.on('afterDelete', 'qline', async (ctx: any) => { + lineMarkers.push(ctx.referentialCascadeDelete); + await ctx.api.object('side_log').update({ id: s1.id, body: 'touched' }); + }); + + await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any); + + expect(lineMarkers, 'the hooks ran on the cascaded child delete').toEqual([true, true]); + expect(seen.map((o) => o.event)).toEqual(['beforeDelete', 'beforeUpdate']); + for (const o of seen) { + expect(o.hasKey, `the hook's own ${String(o.event)} is not one the cascade issued`).toBe(false); + // Same identity as the cascade — the hook's writes ride its context. + expect(o.sessionUserId).toBe('u1'); + } + expect(await engine.findOne('side_log', { where: { id: s2.id } })).toBeNull(); + expect((await engine.findOne('side_log', { where: { id: s1.id } }) as any).body).toBe('touched'); + }); + + it('a delete of the very same row from inside the cascaded dispatch finds the token claimed', async () => { + const markers: Array<{ depth: number; hasKey: boolean }> = []; + let depth = 0; + + const a = await engine.insert('acct', { name: 'Acme' }); + const l = await engine.insert('qline', { title: 'one', parent: a.id }); + + engine.on('beforeDelete', 'qline', async (ctx: any) => { + depth += 1; + markers.push({ depth, hasKey: 'referentialCascadeDelete' in ctx }); + // Once: the hook's own delete of the row it is being asked about. + if (depth === 1) await ctx.api.object('qline').deleteById(l.id); + }); + + await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any); + + expect(markers).toEqual([ + { depth: 1, hasKey: true }, + { depth: 2, hasKey: false }, + ]); + }); + + it('an identity-LESS cascade still marks its child, and puts no operation-private key on the envelope', async () => { + const seen: Cap[] = []; + listen(seen, 'qline', ['beforeDelete']); + const childEnvelopeKeys: string[][] = []; + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.operation === 'delete' && opCtx.object === 'qline') { + childEnvelopeKeys.push(Object.keys(opCtx.context ?? {})); + } + await next(); + }); + + const a = await engine.insert('acct', { name: 'Acme' }); + await engine.insert('qline', { title: 'one', parent: a.id }); + await engine.delete('acct', { where: { id: a.id } } as any); + + expect(seen.length).toBe(1); + expect(seen[0].marker).toBe(true); + expect(seen[0].sessionUserId).toBeUndefined(); + // The fact rides no `__` key: there is nothing on the envelope for an + // app to sniff instead of the declared key. + expect(childEnvelopeKeys.length).toBe(1); + expect(childEnvelopeKeys[0].filter((k) => k.startsWith('__'))).toEqual([]); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a35c61ec2a4..a612e7c88a7 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -4295,9 +4295,10 @@ export class ObjectQL implements IObjectQLEngine { * `HookContext.referentialCascadeDelete` (see * {@link claimReferentialCascadeDelete}). * - * Ambient, for {@link cascadeDeleteSets}' reason — a context key would turn a - * caller's absent context into a present one on the child delete, and both - * `buildSession` and `buildHookApi` branch on its absence — and for one more + * Ambient, for {@link cascadeDeleteSets}' reason — a context key would change + * what the child delete is handed (where no transaction is opened, an absent + * caller context would become a present one, and both `buildSession` and + * `buildHookApi` branch on its absence) — and for one more * the update twin's `__referentialFieldClear` never had to weigh, because its * envelope semantics predated its declared projection: the child delete's * context is the very object `buildHookApi` hands to `ScopedContext`, so a diff --git a/packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts b/packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts new file mode 100644 index 00000000000..9620bc187c4 --- /dev/null +++ b/packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23221] The declared cascade-delete marker, OBSERVED FROM INSIDE A SHIPPED + * BODY — the sandbox face of `HookContext.referentialCascadeDelete`, the delete + * twin of the referential-field-clear pin next door. + * + * ## What is pinned, and why a kernel rig could not pin it + * + * The engine's cascade deletes each child AS THE CALLER: it recurses through + * the public `delete()` with the caller's own context, so on the real request + * path (a DELETE carrying a `userId`) `ctx.user`, `ctx.session` and + * `ctx.input` are identical between a child the cascade removes and a user's + * hand delete of that same child. Unlike the update twin, there is not even an + * operation-private envelope key to sniff: the engine recognises the delete it + * issued through an engine-private ambient token. So the declared key is a + * lowered body's ONLY channel, and it reaches the body only if + * `buildSandboxContext` and `installCtx` carry it — which is what this proves, + * from inside the VM, rather than from a handler running in-process. + * + * Same wiring as the twin: the REAL `ObjectQL` + REAL `SqlDriver` + * (better-sqlite3) + REAL `QuickJSScriptRunner` behind `hookBodyRunnerFactory` + * — what `AppPlugin` composes — and every assertion lands on what the body + * OBSERVED, reported out through the `log` capability: + * + * - on the cascaded child delete's dispatches, `ctx.referentialCascadeDelete + * === true` inside the VM, in both phases, beside the inherited caller + * identity (the discriminator-erasing condition in force); + * - on the user's hand delete of a line of the same parent under the SAME + * identity, the key is ABSENT (`typeof` reads `'undefined'`), so the guard + * idiom reads false; + * - the update twin is absent on both, so neither key reads as the other. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { hookBodyRunnerFactory } from './body-runner.js'; +import { QuickJSScriptRunner } from './quickjs-runner.js'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from '../expected-read-refusal-noise.js'; + +const PARENT = { + name: 'probe_rcd_quote', + fields: { + name: { type: 'text' }, + }, +}; + +/** The child: a master_detail line, whose deleteBehavior resolves to cascade. */ +const LINE = { + name: 'probe_rcd_line', + fields: { + title: { type: 'text' }, + quote: { type: 'master_detail', reference: 'probe_rcd_quote' }, + }, +}; + +/** + * Reports exactly what a shipped guard can read. `markerType` distinguishes an + * absent key from every present shape, so the control case cannot pass by + * accident of a falsy value. + */ +const PROBE_SOURCE = ` + ctx.log.info('probe', { + event: ctx.event, + id: ctx.input ? ctx.input.id : null, + markerType: typeof ctx.referentialCascadeDelete, + markerIsTrue: ctx.referentialCascadeDelete === true, + twinType: typeof ctx.referentialFieldClear, + sessionUserId: ctx.session ? ctx.session.userId : null, + }); +`; + +// [#21516] The engine refuses a name its registry does not hold before any driver, +// so this read no longer reaches the driver; the pin asserts nothing was withheld. +const ABSENT_TENANCY_TABLE = 'sys_organization'; + +describe('#23221 — a shipped body observes ctx.referentialCascadeDelete across the sandbox boundary', () => { + let engine: ObjectQL | null = null; + let dir: string | null = null; + let noise: ExpectedReadRefusalCapture | null = null; + + afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + engine = null; + if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; } + }); + + it('true inside the VM on the cascaded child delete (both phases, identity inherited); absent on the hand delete', async () => { + dir = mkdtempSync(join(tmpdir(), 'os-23221-')); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true }); + noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(driver); + await driver.initObjects([PARENT, LINE]); + engine = new ObjectQL(); + noise.captureEngine(engine); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(PARENT as any, 'probe'); + engine.registry.registerObject(LINE as any, 'probe'); + + const seen: any[] = []; + const logger = { + debug: () => {}, + info: (_msg: string, meta?: any) => { seen.push(meta); }, + warn: () => {}, + error: () => {}, + }; + engine.setDefaultBodyRunner( + hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'probe', logger }), + ); + bindHooksToEngine(engine, [{ + name: 'probe_referential_cascade_delete', + object: 'probe_rcd_line', + events: ['beforeDelete', 'afterDelete'], + body: { language: 'js', source: PROBE_SOURCE, capabilities: ['log'] }, + } as any], { packageId: 'probe' }); + + // The REST-shaped caller envelope, on which every other context member is + // identical between the engine's cascade and the user's hand delete. + const CALLER = { userId: 'u_probe', isSystem: true }; + + const q = (await engine.insert('probe_rcd_quote', { name: 'Q-1' })) as any; + const l1 = (await engine.insert('probe_rcd_line', { title: 'one', quote: q.id })) as any; + const l2 = (await engine.insert('probe_rcd_line', { title: 'two', quote: q.id })) as any; + + // ── The user's hand delete of one line, same identity. + await engine.delete('probe_rcd_line', { where: { id: l1.id }, context: { ...CALLER } } as any); + const hand = seen.splice(0); + expect(hand.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']); + for (const o of hand) { + expect(o.id).toBe(l1.id); + // Absent, not false — the guard idiom must read "not cascade-issued". + expect(o.markerType).toBe('undefined'); + expect(o.markerIsTrue).toBe(false); + expect(o.twinType).toBe('undefined'); + // The identity that erases every other discriminator is present. + expect(o.sessionUserId).toBe('u_probe'); + } + + // ── The engine's cascade when the quote is deleted. + await engine.delete('probe_rcd_quote', { where: { id: q.id }, context: { ...CALLER } } as any); + const cascade = seen.splice(0); + expect(cascade.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']); + for (const o of cascade) { + expect(o.id).toBe(l2.id); + // The whole card: the declared key, readable from inside QuickJS. + expect(o.markerType).toBe('boolean'); + expect(o.markerIsTrue).toBe(true); + expect(o.twinType).toBe('undefined'); + // Beside the inherited identity — the same session as the hand delete. + expect(o.sessionUserId).toBe('u_probe'); + } + + // The cascade itself landed. + expect(await engine.findOne('probe_rcd_line', { where: { id: l2.id } })).toBeFalsy(); + + // Withheld-noise pin, same as the sibling harnesses: quiet by construction. + expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]); + }, 30000); +}); diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index d9bccd9d784..65accbc72c3 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -1281,8 +1281,9 @@ export const HookContextSchema = lazySchema(() => z.object({ * - it does not ride the envelope, so a write a hook issues through * `ctx.api` during a cascade dispatch is NOT marked, a delete included: * that is the hook's own write, not one the cascade issued; - * - it does not turn a caller's absent context into a present one, so the - * child of a cascade that carried no context still sees no `ctx.session`. + * - it changes nothing the child delete is handed: the context reaches it + * exactly as it did before this key existed, so no `ctx.session`, + * `ctx.user` or `ctx.api` reading moves because of it. * * Being declared, it is carried across the QuickJS sandbox boundary by * contract (`buildSandboxContext` / `installCtx` in `packages/runtime`), so a From c39bfffdce898dd725b15183835e747a3efa6770 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:38:37 +0000 Subject: [PATCH 3/5] test(runtime): name the probed row through ctx.previous, the delete's pre-image Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- ...ferential-cascade-delete-signal.integration.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts b/packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts index 9620bc187c4..aeb3f38c95e 100644 --- a/packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts +++ b/packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts @@ -64,12 +64,14 @@ const LINE = { /** * Reports exactly what a shipped guard can read. `markerType` distinguishes an * absent key from every present shape, so the control case cannot pass by - * accident of a falsy value. + * accident of a falsy value. The row is named through `ctx.previous`, the + * delete's pre-image in both phases: a body's `ctx.input` is the flattened + * record payload, which a delete does not carry. */ const PROBE_SOURCE = ` ctx.log.info('probe', { event: ctx.event, - id: ctx.input ? ctx.input.id : null, + previousId: ctx.previous ? ctx.previous.id : null, markerType: typeof ctx.referentialCascadeDelete, markerIsTrue: ctx.referentialCascadeDelete === true, twinType: typeof ctx.referentialFieldClear, @@ -135,7 +137,7 @@ describe('#23221 — a shipped body observes ctx.referentialCascadeDelete across const hand = seen.splice(0); expect(hand.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']); for (const o of hand) { - expect(o.id).toBe(l1.id); + expect(o.previousId).toBe(l1.id); // Absent, not false — the guard idiom must read "not cascade-issued". expect(o.markerType).toBe('undefined'); expect(o.markerIsTrue).toBe(false); @@ -149,7 +151,7 @@ describe('#23221 — a shipped body observes ctx.referentialCascadeDelete across const cascade = seen.splice(0); expect(cascade.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']); for (const o of cascade) { - expect(o.id).toBe(l2.id); + expect(o.previousId).toBe(l2.id); // The whole card: the declared key, readable from inside QuickJS. expect(o.markerType).toBe('boolean'); expect(o.markerIsTrue).toBe(true); From 670114eecf1b24b64b9e817c1187d731051ac652 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:50:58 +0000 Subject: [PATCH 4/5] chore(spec): regenerate authorable-surface and the hook reference for referentialCascadeDelete Produced by the spec build and check:generated --fix (check:docs was the only artifact it proved stale). Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- content/docs/references/data/hook.mdx | 1 + packages/spec/authorable-surface/data.json | 1 + 2 files changed, 2 insertions(+) diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index e1a712d27ec..0f1d01ca8a9 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -99,6 +99,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context | | **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) | | **referentialFieldClear** | `boolean` | optional | Engine-produced marker: true exactly when this write is the engine's own referential cleanup (the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member from — a lookup that references a record being deleted). Absent on every other write, including a user hand-clearing the same lookup; read as `ctx.referentialFieldClear === true`. The declared projection of the operation-private `__referentialFieldClear`, carried across the sandbox boundary by contract; server-derived and never client-supplied. | +| **referentialCascadeDelete** | `boolean` | optional | Engine-produced marker: true exactly when this delete is one the engine's cascade issued (the child delete removing a row that references a record being deleted through a relation whose deleteBehavior resolves to cascade, at any depth), in both phases of that delete. Absent on every other delete, including a user hand-deleting the same child and a delete a hook issues through ctx.api; read as `ctx.referentialCascadeDelete === true`. The delete twin of referentialFieldClear, carried across the sandbox boundary by contract; server-derived, never client-supplied, and with no operation-private spelling to read instead. | | **transaction** | `any` | optional | Database transaction handle | | **ql** | `any` | ✅ | ObjectQL Engine Reference | | **api** | `any` | optional | Cross-object data access (IScopedContext — `object(name)` + `transaction(cb)`) | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index cfcacaaa115..beda22ea5de 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -489,6 +489,7 @@ "data/HookContext:previous", "data/HookContext:provenance", "data/HookContext:ql", + "data/HookContext:referentialCascadeDelete", "data/HookContext:referentialFieldClear", "data/HookContext:result", "data/HookContext:session", From 7e4509b6cb666ad6fa8fa5b26a063ee7ae15886f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:55:57 +0000 Subject: [PATCH 5/5] docs(objectql): say where the cascade-delete claim sits relative to the afterDelete unit re-entry Comment only. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- packages/objectql/src/engine.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index e7c447f34cf..b4c82f9f454 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -6414,10 +6414,11 @@ export class ObjectQL implements IObjectQLEngine { * contract, so a handler reads `ctx.referentialCascadeDelete === true`. * * Reads {@link cascadeIssuedDeletes}, not the envelope (that field says why), - * and CLAIMS the token it matches: called once per `delete()`, after the - * `afterDelete` unit re-entry has returned, so the call that recognises - * itself is the one that dispatches the hooks, and nothing later inside it - * can match the same token again. + * and CLAIMS the token it matches. Called once per `delete()`, below the + * `afterDelete` unit re-entry (`opensAfterAbortUnit`): a call that re-enters + * itself returns before reaching here, so the claim is made by the inner + * call, the one that dispatches the hooks, and nothing later inside it can + * match the same token again. */ private claimReferentialCascadeDelete(object: string, id: unknown): true | undefined { const issued = this.cascadeIssuedDeletes.getStore();