diff --git a/.changeset/hook-ctx-referential-field-clear.md b/.changeset/hook-ctx-referential-field-clear.md new file mode 100644 index 0000000000..aaf2cc5a00 --- /dev/null +++ b/.changeset/hook-ctx-referential-field-clear.md @@ -0,0 +1,47 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/runtime": minor +--- + +feat(spec,objectql,runtime): declare `ctx.referentialFieldClear` on `HookContextSchema`, populate it on every `set_null` reference-cleanup write, and carry it across the QuickJS sandbox boundary by contract (#13644) + +Adopted by maintainer ruling 2026-08-31 (issue #13644, decision record on the +card): a first-class, declared marker for the engine's own reference-cleanup +writes, with both mandated conditions in the same landing — the sandbox carry +and the populate-surface pin. + +The engine implements `deleteBehavior: 'set_null'` by UPDATING the row that +HOLDS the lookup, and it builds that cleanup write's context by inheriting the +caller's envelope — so on the path a real request takes (a `DELETE` carrying a +`userId`), `ctx.user`, `ctx.session` and `ctx.input` are identical between the +engine's cascade and a user's hand-clear of the same lookup. An app guard that +freezes settled records had no declared way to yield to the cleanup: the only +prior signal was the operation-private `__referentialFieldClear`, which the +platform's own `__` convention declares outside the contract and which the +sandbox marshalling never carried. + +- **spec (minor):** `HookContextSchema` declares `referentialFieldClear` + (boolean, optional) — `true` exactly when the write is the engine's own + reference cleanup (clearing the slot, or removing the deleted member from a + `multiple: true` lookup); absent on every other dispatch. Widens the accept + set by one optional engine-produced key on the deliberately non-strict + runtime context shape; nothing previously valid changes meaning. +- **objectql (minor):** `update()`'s hook-context assembly projects the marker + from the operation envelope onto the declared key, both phases and the + per-row fan-out included. Pinned write site by write site (scalar clear and + multi-value member removal, each beside a hand-clear control under the same + caller identity, plus an envelope-consistency leg) in + `engine-cascade-delete.test.ts`. +- **runtime (minor):** the QuickJS marshalling carries the declared key into a + shipped body (`buildSandboxContext` / `installCtx`), so + `ctx.referentialFieldClear === true` is readable from inside the VM — + pinned from inside a real QuickJS run in + `referential-field-clear-signal.integration.test.ts` (⛔ not a kernel-rig + read; the #11552 declared≠observable family is the reason the ruling makes + this a condition of adoption). + +The operation-private `__referentialFieldClear` stays: it remains the +engine/middleware authorization channel (plugin-security's ownership-anchor +exemption keys on it before any hook runs). The declared key is its read-only +hook-context projection — one fact, two faces, pinned together. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index bf6df0ba3f..be72c21552 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,18 +109,18 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10882` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11044` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9747` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10914` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11076` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9772` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1737` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9784`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5705` | -| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9809`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5730` | +| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3599`, `:3609`, `:3636` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6403` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11630` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11559` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6428` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11662` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11591` | ### 3. Sharing (`plugin-sharing`) @@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| | 62 | `objectql/src/engine.ts:3406` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:13971` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 63 | `objectql/src/engine.ts:14011` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9730`–`9747` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9755`–`9772` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` | diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index 9aa788bd38..bd12f4da9f 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -36,6 +36,7 @@ const result = HookContextSchema.parse(data); | **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record }` | optional | How this hook call relates to the caller's write (engine-produced) | | **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context | | **provenance** | `{ flowRunId?: string; attributedUserId?: 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. | | **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/core/src/security/operation-private-keys.ts b/packages/core/src/security/operation-private-keys.ts index ea7abddb28..e382e5d828 100644 --- a/packages/core/src/security/operation-private-keys.ts +++ b/packages/core/src/security/operation-private-keys.ts @@ -24,7 +24,12 @@ * `__expandRead` marks a read as a lookup EXPANSION sub-read (it no longer * relaxes any gate — #7626 removed that waiver — but it still travels with * one operation and must not be inherited by another), `__referentialFieldClear` - * authorizes the referential-clear write. + * authorizes the referential-clear write. [#13644] The latter also has a + * DECLARED, read-only projection — `HookContext.referentialFieldClear` + * (`@objectstack/spec/data`), populated by objectql's `update()` assembly + * and carried across the sandbox boundary by contract — which is what an + * APP reads; the `__` key here remains the engine/middleware authorization + * channel, and this file's stripping rule is unchanged by the projection. * * plugin-security is the PRODUCER of that vocabulary and would be the most * honest owner of the rule for consuming it, but none of the three consumers diff --git a/packages/objectql/src/engine-cascade-delete.test.ts b/packages/objectql/src/engine-cascade-delete.test.ts index c4cdd4139d..fe81f59cff 100644 --- a/packages/objectql/src/engine-cascade-delete.test.ts +++ b/packages/objectql/src/engine-cascade-delete.test.ts @@ -633,3 +633,157 @@ describe('cascadeDeleteRelations — [#9689] authored set_null on master_detail expect(all.filter((m) => m.includes("deleteBehavior: 'set_null'"))).toHaveLength(0); }); }); + +// [#13644] The DECLARED referential-cleanup marker — `HookContext. +// referentialFieldClear` — populated on EVERY reference-cleanup write the +// engine issues, as the read-only projection of the operation-private +// `__referentialFieldClear` the #3023 pin above holds on the envelope. +// +// Why this pin exists, and why its caller context carries a full identity: the +// filer's corrected measurement (#13644) showed the engine builds the cleanup +// write as `{ ...callerContext, transaction, __referentialFieldClear: true }` +// — it INHERITS whatever identity the caller supplied — so on the path a real +// request takes (a REST DELETE carrying a userId) `ctx.user`, `ctx.session` +// and `ctx.input` are IDENTICAL between the engine's cascade and a user's +// hand-clear of the same lookup. There is no app-observable discriminator at +// all except this key, which is why "the schema declares it" alone would be +// worthless: an engine that stopped populating any one write site would +// silently return every guard to that state. Hence one pin per write site +// (scalar clear, multi-value member removal), each beside its hand-clear +// control under the SAME identity, plus the one-fact-two-faces consistency +// leg against the envelope marker. +describe('cascadeDeleteRelations — [#13644] every reference-cleanup write carries the declared ctx.referentialFieldClear', () => { + let engine: ObjectQL; + + // The REST-shaped caller envelope — the corrected measurement's row 1, + // the one on which every other context member is identical between + // cascade and hand-clear. + const CALLER = { userId: 'u1', isSystem: true }; + + type Cap = { + event: unknown; marker: unknown; hasKey: boolean; + sessionUserId: unknown; data: unknown; + }; + const capture = (ctx: any): Cap => ({ + event: ctx.event, + marker: ctx.referentialFieldClear, + // Distinguishes ABSENT from present-but-undefined: the contract is + // "absent unless true", and a present-but-undefined key would survive + // spreads as a phantom member. + hasKey: 'referentialFieldClear' in ctx, + sessionUserId: ctx.session?.userId, + data: ctx.input?.data, + }); + + beforeEach(async () => { + engine = new ObjectQL(); + const { driver } = makeStubDriver(); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of [acct, noteOptional, watchlistOptionalMulti]) engine.registry.registerObject(o as any); + }); + + it('scalar set_null clear: true in BOTH phases with the caller identity inherited; the hand-clear control has no key at all', async () => { + const seen: Cap[] = []; + engine.on('beforeUpdate', 'note', (ctx: any) => { seen.push(capture(ctx)); }); + engine.on('afterUpdate', 'note', (ctx: any) => { seen.push(capture(ctx)); }); + // One fact, two faces: the same writes, observed on the envelope. + const ops: Array<{ marker: unknown; data: unknown }> = []; + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.operation === 'update' && opCtx.object === 'note') { + ops.push({ marker: opCtx.context?.__referentialFieldClear, data: opCtx.data }); + } + await next(); + }); + + const a = await engine.insert('acct', { name: 'Acme' }); + const n = await engine.insert('note', { body: 'hi', account: a.id }); + + // The user's hand-clear of the SAME lookup, under the SAME identity. + await engine.update('note', { id: n.id, account: null }, { context: { ...CALLER } } as any); + const hand = seen.splice(0); + // Restore the reference (and drop that restore's own dispatches) so + // the cascade below has a dependent to clear — a nulled slot matches + // no probe and the cleanup write would never be issued. + await engine.update('note', { id: n.id, account: a.id }, { context: { ...CALLER } } as any); + seen.splice(0); + // The engine's cascade when the referenced record is deleted. + await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any); + const cascade = seen.splice(0); + + expect(hand.length).toBe(2); + for (const o of hand) { + expect(o.marker, 'hand-clear must not read as a referential cleanup').toBeUndefined(); + expect(o.hasKey, 'the key must be ABSENT on a hand-clear, not present-but-undefined').toBe(false); + expect(o.sessionUserId).toBe('u1'); + } + expect(cascade.length).toBe(2); + expect(cascade.map((o) => o.event)).toEqual(['beforeUpdate', 'afterUpdate']); + for (const o of cascade) { + expect(o.marker, 'the cleanup write carries the declared marker').toBe(true); + // The inherited identity — the very thing that erases every other + // discriminator — is present alongside the marker. + expect(o.sessionUserId).toBe('u1'); + expect((o.data as any)?.account, 'and this really is the cleanup write').toBeNull(); + } + // Consistency: the declared face is true exactly where the envelope + // carries the operation-private marker, write for write. Three update + // ops reached the middleware, in order: the hand-clear, the restore, + // and the engine's cleanup — only the last rides the marked envelope. + expect(ops.length).toBe(3); + expect(ops.map((o) => o.marker)).toEqual([undefined, undefined, true]); + expect((ops[0].data as any)?.account, 'op 1 is the hand-clear').toBeNull(); + expect((ops[1].data as any)?.account, 'op 2 is the restore').toBe(a.id); + expect((ops[2].data as any)?.account, 'op 3 is the cleanup').toBeNull(); + // And the cleanup landed. + expect((await engine.findOne('note', { where: { id: n.id } }) as any).account).toBeNull(); + }); + + it('multiple:true member removal — the second cleanup write site — carries it identically', async () => { + const seen: Cap[] = []; + engine.on('beforeUpdate', 'watchlist', (ctx: any) => { seen.push(capture(ctx)); }); + engine.on('afterUpdate', 'watchlist', (ctx: any) => { seen.push(capture(ctx)); }); + + const a = await engine.insert('acct', { name: 'Acme' }); + const b = await engine.insert('acct', { name: 'Beta' }); + const w = await engine.insert('watchlist', { accounts: [a.id, b.id] }); + + // Hand-edit of the SAME multi-value lookup under the same identity — + // the control for THIS write site. + await engine.update('watchlist', { id: w.id, accounts: [a.id, b.id] }, { context: { ...CALLER } } as any); + const hand = seen.splice(0); + await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any); + const cascade = seen.splice(0); + + expect(hand.length).toBe(2); + for (const o of hand) { + expect(o.marker).toBeUndefined(); + expect(o.hasKey).toBe(false); + } + expect(cascade.length).toBe(2); + expect(cascade.map((o) => o.event)).toEqual(['beforeUpdate', 'afterUpdate']); + for (const o of cascade) { + expect(o.marker, 'the member-removal write is a reference-cleanup write too').toBe(true); + expect(o.sessionUserId).toBe('u1'); + expect((o.data as any)?.accounts, 'and it is the remainder write').toEqual([b.id]); + } + expect((await engine.findOne('watchlist', { where: { id: w.id } }) as any).accounts).toEqual([b.id]); + }); + + it('an identity-LESS delete still marks its cleanup writes (the marker does not ride the identity)', async () => { + // The original card's rig happened to measure exactly this shape (no + // userId on the DELETE); pinned so the marker provably keys on the + // operation, not on any identity member the envelope may or may not + // carry. + const seen: Cap[] = []; + engine.on('beforeUpdate', 'note', (ctx: any) => { seen.push(capture(ctx)); }); + + const a = await engine.insert('acct', { name: 'Acme' }); + await engine.insert('note', { body: 'hi', account: 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(); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 456bd2fdbb..07c5edbe5a 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3459,6 +3459,31 @@ export class ObjectQL implements IObjectQLEngine { }; } + /** + * [#13644] Build the declared `HookContext.referentialFieldClear` marker — + * the read-only hook-context projection of the operation-private + * `__referentialFieldClear` that {@link ObjectQL.cascadeDeleteRelations} + * stamps on the cleanup write's ExecutionContext (#3023). + * + * `true | undefined`, never `false`: the key is ABSENT from every dispatch + * whose envelope does not carry the marker, so a handler reads it as + * `ctx.referentialFieldClear === true` and an absent key keeps meaning "not + * a referential cleanup" — the same back-compatible direction as `dispatch`. + * + * Why a projection of the ENVELOPE rather than a fresh per-call-site signal: + * the `__` key's semantics have always been operation-scoped — the cleanup + * context is `{ ...callerContext, transaction, __referentialFieldClear: true }`, + * plugin-security's ownership-anchor exemption keys on the envelope, and a + * write a hook issues through `ctx.api` during a cleanup dispatch inherits + * it — so the declared face projects the fact faithfully instead of quietly + * re-scoping it. Consumed by `update()`'s hook-context assembly only: every + * reference-cleanup write the engine issues is an update, and the per-row / + * after-phase contexts ride the batch context's spread. + */ + private buildReferentialFieldClear(execCtx?: ExecutionContext): true | undefined { + return (execCtx as any)?.__referentialFieldClear === true ? true : undefined; + } + /** * Build the acting-user object (ADR-0068 EvalUser shape) surfaced to * validation-time predicates as `current_user` — notably per-option @@ -10327,6 +10352,13 @@ export class ObjectQL implements IObjectQLEngine { input: { id, data: opCtx.data, options: opCtx.options }, session: this.buildSession(opCtx.context), provenance: this.buildProvenance(opCtx.context), + // [#13644] The declared referential-cleanup marker. Conditional + // spread, not a bare assignment: the contract is "absent unless + // true", and an explicit `undefined` member would survive the + // per-row context spreads as a present-but-undefined key. + ...(this.buildReferentialFieldClear(opCtx.context) + ? { referentialFieldClear: true as const } + : {}), user: this.buildUser(opCtx.context), api: this.buildHookApi(opCtx.context), transaction: opCtx.context?.transaction, @@ -12103,6 +12135,14 @@ export class ObjectQL implements IObjectQLEngine { // rides a server-DERIVED context (set here, never from client input // — same trust model as `__expandRead`), so it cannot be forged from // a request to bypass the guard on an ordinary write. + // + // [#13644] This same marker is what `update()`'s hook-context + // assembly projects onto the DECLARED `HookContext. + // referentialFieldClear` (see buildReferentialFieldClear), so an + // app guard can recognise the cleanup without reading an + // operation-private key — on the real request path the inherited + // caller envelope makes the cleanup otherwise indistinguishable + // from a hand-clear of the same lookup. const referentialCtx = { ...(context ?? {}), __referentialFieldClear: true } as ExecutionContext; if (multiValued) { // The FK is a SET, so `set_null` clears the deleted MEMBER, not diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index 41fa89d8dc..7d56192a94 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -725,6 +725,12 @@ function buildSandboxContext( // dispatches for one write, and its params bag has no caller options. dispatch, inputOptions, + // [#13644] The declared referential-cleanup marker, carried across the + // sandbox boundary BY CONTRACT — copied only in its declared shape + // (`true`), the same unrecognised-shape rule as `dispatch` above: anything + // else is left ABSENT, so `ctx.referentialFieldClear === true` reads "not + // a referential cleanup" exactly as the spec prescribes. + ...(engineCtx?.referentialFieldClear === true ? { referentialFieldClear: true } : {}), crypto: globalThis.crypto, }; } diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index 6ecceb683b..c0b0e157d2 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -519,6 +519,15 @@ export class QuickJSScriptRunner implements ScriptRunner { if (ctx.result !== undefined) { setObjectJson(vm, ctxObj, 'result', ctx.result); } + // [#13644] The declared referential-cleanup marker — installed only in its + // declared shape (`true`), absent otherwise, so a body reads + // `ctx.referentialFieldClear === true` with the spec's own back-compatible + // absence semantics. A plain boolean: no freeze/graft ceremony needed — + // a VM-side reassignment cannot travel anywhere (the write-back channel + // reads only `ctx.input`). + if (ctx.referentialFieldClear === true) { + vm.setProp(ctxObj, 'referentialFieldClear', vm.true); + } const apiObj = vm.newObject(); const objectFn = vm.newFunction('object', (nameH) => { diff --git a/packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts b/packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts new file mode 100644 index 0000000000..e44768b052 --- /dev/null +++ b/packages/runtime/src/sandbox/referential-field-clear-signal.integration.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13644] The declared referential-cleanup marker, OBSERVED FROM INSIDE A + * SHIPPED BODY — the sandbox-reachability mandate of the adoption ruling. + * + * ## What is pinned, and why a kernel rig could not pin it + * + * `HookContext.referentialFieldClear` exists because the engine's `set_null` + * cleanup write is otherwise indistinguishable, from inside an app guard, from + * a user hand-clearing the same lookup: the engine builds the cleanup context + * by INHERITING the caller's envelope, so on the real request path (a DELETE + * carrying a `userId`) `ctx.user`, `ctx.session` and `ctx.input` are identical + * between the two writes. The only prior signal was the operation-private + * `__referentialFieldClear` at `ctx.api.executionContext` — reachable in a + * kernel rig, where `ctx.api` is the engine's own ScopedContext, and NOT + * proven through the shipped path, where a body runs body-only inside QuickJS + * and `buildSandboxApi` may hand it a `{ object }` shim with no + * `executionContext` at all. A predicate green in the rig and silently false + * in production is the #11552 inert-guard family, and the ruling on this card + * names that as the reason the declared key must be pinned FROM INSIDE THE VM + * (⛔ not "the kernel rig can read it"). + * + * So, like the #11552 pin next door, this drives the REAL `ObjectQL` + REAL + * `SqlDriver` (better-sqlite3) + REAL `QuickJSScriptRunner` behind + * `hookBodyRunnerFactory` — the same wiring `AppPlugin` performs — and every + * assertion lands on what the body OBSERVED, reported out through the `log` + * capability: + * + * - on the cleanup write's dispatches, `ctx.referentialFieldClear === true` + * inside the VM, in both phases — while the inherited caller identity is + * present alongside it (the discriminator-erasing condition is in force, + * not dodged by an identity-less rig context); + * - on the user's hand-clear of the SAME lookup under the SAME identity, the + * key is ABSENT (`typeof` reads `'undefined'`), so the guard idiom + * `ctx.referentialFieldClear === true` reads false — the spec's + * back-compatible absence semantics, observed rather than inferred. + */ + +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_rfc_account', + fields: { + name: { type: 'text' }, + }, +}; + +/** The holder: an optional lookup whose deleteBehavior defaults to set_null. */ +const HOLDER = { + name: 'probe_rfc_note', + fields: { + body: { type: 'text' }, + account: { type: 'lookup', reference: 'probe_rfc_account' }, + }, +}; + +/** + * Reports exactly what a shipped guard can read. `markerType` is the honest + * instrument: it 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, + markerType: typeof ctx.referentialFieldClear, + markerIsTrue: ctx.referentialFieldClear === true, + sessionUserId: ctx.session ? ctx.session.userId : null, + accountInInput: 'account' in ctx.input ? ctx.input.account : '(absent)', + }); +`; + +const ABSENT_TENANCY_TABLE = 'sys_organization'; + +describe('#13644 — a shipped body observes ctx.referentialFieldClear 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 cleanup write (both phases, identity inherited); absent on the identical hand-clear', async () => { + dir = mkdtempSync(join(tmpdir(), 'os-13644-')); + 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, HOLDER]); + engine = new ObjectQL(); + noise.captureEngine(engine); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(PARENT as any, 'probe'); + engine.registry.registerObject(HOLDER 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_field_clear', + object: 'probe_rfc_note', + events: ['beforeUpdate', 'afterUpdate'], + body: { language: 'js', source: PROBE_SOURCE, capabilities: ['log'] }, + } as any], { packageId: 'probe' }); + + // The REST-shaped caller envelope — the corrected #13644 measurement's + // row 1, on which every other context member is identical between the + // engine's cascade and the user's hand-clear. + const CALLER = { userId: 'u_probe', isSystem: true }; + + const a = (await engine.insert('probe_rfc_account', { name: 'Acme' })) as any; + const n = (await engine.insert('probe_rfc_note', { body: 'hi', account: a.id })) as any; + + // ── The user's hand-clear of the same lookup, same identity. + await engine.update('probe_rfc_note', { id: n.id, account: null }, { context: { ...CALLER } } as any); + const hand = seen.splice(0); + expect(hand.length).toBe(2); + expect(hand.map((o) => o.event)).toEqual(['beforeUpdate', 'afterUpdate']); + for (const o of hand) { + // Absent, not false — the guard idiom must read "not a cleanup". + expect(o.markerType).toBe('undefined'); + expect(o.markerIsTrue).toBe(false); + // The identity that erases every other discriminator is present… + expect(o.sessionUserId).toBe('u_probe'); + // …and this write really does clear the same slot the cascade clears. + expect(o.accountInInput).toBeNull(); + } + + // Restore the reference so the cascade has something to clear. + await engine.update('probe_rfc_note', { id: n.id, account: a.id }, { context: { ...CALLER } } as any); + seen.splice(0); + + // ── The engine's cascade when the referenced record is deleted. + await engine.delete('probe_rfc_account', { where: { id: a.id }, context: { ...CALLER } } as any); + const cascade = seen.splice(0); + expect(cascade.length).toBe(2); + expect(cascade.map((o) => o.event)).toEqual(['beforeUpdate', 'afterUpdate']); + for (const o of cascade) { + // The whole card: the declared key, readable from inside QuickJS. + expect(o.markerType).toBe('boolean'); + expect(o.markerIsTrue).toBe(true); + // Alongside the inherited identity — same session as the hand-clear. + expect(o.sessionUserId).toBe('u_probe'); + expect(o.accountInInput).toBeNull(); + } + + // The cleanup itself landed. + const cleared = (await engine.findOne('probe_rfc_note', { where: { id: n.id } })) as any; + expect(cleared.account).toBeNull(); + + // [#10629] Withheld-noise pin, same as the sibling harnesses. + expect(noise?.silentChannels() ?? ['no capture was installed']).toEqual([]); + }, 30000); +}); diff --git a/packages/runtime/src/sandbox/script-runner.ts b/packages/runtime/src/sandbox/script-runner.ts index f926d0a46b..0608cd28cf 100644 --- a/packages/runtime/src/sandbox/script-runner.ts +++ b/packages/runtime/src/sandbox/script-runner.ts @@ -222,6 +222,26 @@ export interface ScriptContext { * spec prescribes for the engine face. */ dispatch?: { mode: 'record' | 'per-row'; index: number }; + /** + * The engine's referential-cleanup marker, marshalled for the HOOK face + * (#13644) — `true` exactly when this write is the engine's own reference + * cleanup (the `set_null` cascade UPDATE clearing, or on a `multiple: true` + * lookup member-removing, a lookup that references a record being deleted). + * Mirrors the declared `HookContextSchema.referentialFieldClear` + * (`@objectstack/spec/data`), which objectql's `update()` assembly projects + * from the operation-private `__referentialFieldClear` on the execution + * envelope. + * + * Marshalled EXPLICITLY, like `dispatch`, because this is the half the + * operation-private spelling could never offer: `buildSandboxApi` may hand a + * body a `{ object }` shim with no `executionContext` at all, so a predicate + * reading `ctx.api.executionContext.__referentialFieldClear` could be green + * in a kernel rig and silently false in production — the #11552 + * declared≠observable family, and the whole point of declaring the key. + * Absent (never `false`) on every other dispatch and on the action face; + * read it as `ctx.referentialFieldClear === true`. + */ + referentialFieldClear?: 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/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 9ebe699707..47ce9bb799 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -459,6 +459,7 @@ "data/HookContext:previous", "data/HookContext:provenance", "data/HookContext:ql", + "data/HookContext:referentialFieldClear", "data/HookContext:result", "data/HookContext:session", "data/HookContext:transaction", diff --git a/packages/spec/src/data/hook.test.ts b/packages/spec/src/data/hook.test.ts index 08188207ed..405ad3d70d 100644 --- a/packages/spec/src/data/hook.test.ts +++ b/packages/spec/src/data/hook.test.ts @@ -440,6 +440,40 @@ describe('HookContextSchema', () => { }); }); + // [#13644] The declared referential-cleanup marker. The parse legs matter + // because this schema is non-strict in the STRIPPING sense: an UNDECLARED + // key is silently dropped by `.parse()` (the `roles` tombstone above is the + // history), so "the engine sets it" is worthless unless the schema declares + // it — a parsed context would lose the key and every guard downstream would + // read undefined. The populate and sandbox halves are pinned where they can + // execute (objectql's cascade suite; runtime's QuickJS integration pin). + describe('Referential-Cleanup Marker (#13644)', () => { + it('keeps referentialFieldClear through a parse, typed — the guard idiom evaluates on the parsed value', () => { + const context = HookContextSchema.parse({ + object: 'note', + event: 'beforeUpdate', + input: { id: 'n1', data: { account: null }, options: {} }, + referentialFieldClear: true, + ql: {}, + }); + + // Typed read, no cast — the declaration is what makes this compile. + expect(context.referentialFieldClear === true).toBe(true); + }); + + it('is ABSENT — never false — on a context that does not carry it', () => { + const context = HookContextSchema.parse({ + object: 'note', + event: 'beforeUpdate', + input: { id: 'n1', data: { account: null }, options: {} }, + ql: {}, + }); + + expect(context.referentialFieldClear).toBeUndefined(); + expect('referentialFieldClear' in context).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 079adc8dde..c4d3ff2bbf 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -827,6 +827,71 @@ export const HookContextSchema = lazySchema(() => z.object({ attributedUserId: z.string().optional().describe('The real human credited for a write whose authorization subject was the SYSTEM — e.g. the admin whose better-auth `update-member-role` call the identity adapter executes as `isSystem`. ATTRIBUTION ONLY: the audit writer records it as `sys_audit_log.user_id`; no security middleware reads it, and it never becomes the subject the write is authorized as.'), }).optional().describe('Server-stamped write provenance (never client-supplied, never an authorization input)'), + /** + * Referential-Cleanup Marker + * `true` exactly when THIS write is the engine's own reference cleanup — the + * UPDATE `cascadeDeleteRelations` issues against a row that HOLDS a lookup + * whose `deleteBehavior` resolves to `set_null`, while the record it + * references is being deleted (clearing the slot, or on a `multiple: true` + * lookup removing the deleted member). Absent on every other dispatch; read + * it as `ctx.referentialFieldClear === true`. + * + * ## Why a declared key (#13644) + * + * The engine builds the cleanup write's context by INHERITING the caller's + * envelope (`{ ...callerContext, transaction, __referentialFieldClear: true }`), + * so on the path a real request takes — a REST `DELETE` carrying a `userId` — + * `ctx.user`, `ctx.session` and `ctx.input` are IDENTICAL between the + * engine's cascade and a user's hand-clear of the same lookup (measured on + * 17.1.0; the shape difference earlier readings reported was a rig artifact + * of a `DELETE` that carried no `userId`). An app guard that freezes settled + * records therefore had NO declared way to yield to the cleanup, and a + * frozen record could keep the person it references undeletable — a GDPR + * erasure with no way to carry it out. + * + * ## One fact, two faces — this key does not replace `__referentialFieldClear` + * + * The engine already stamps the operation-private + * `__referentialFieldClear: true` on the cleanup write's ExecutionContext. + * That key stays: it is the AUTHORIZATION channel — plugin-security's + * ownership-anchor exemption reads it off the operation context, before any + * hook runs — and the `__` prefix is the platform's own "not part of the + * contract" convention (`packages/core/src/security/operation-private-keys.ts`), + * which is exactly why an app must not build correctness on it. THIS key is + * the declared, read-only projection of the same fact onto the hook context, + * populated at the engine's update hook-context assembly whenever the + * operation's envelope carries the private marker, and — being declared — + * carried across the QuickJS sandbox boundary by contract + * (`buildSandboxContext` / `installCtx` in `packages/runtime`), which the + * operation-private spelling never was: `buildSandboxApi` can hand a body a + * shim with no `executionContext` at all, so a predicate reading the `__` + * key could be green in a kernel rig and silently false in production (the + * #11552 declared≠observable family). Both faces are pinned together in + * objectql's cascade suite; the sandbox face in runtime's + * referential-field-clear integration pin. + * + * ## Trust model + * + * Server-derived, like `session.isSystem`: the marker is set by the engine at + * the cascade site and transports never accept an operation-private key from + * a client, so it cannot be forged from a request. A write a hook itself + * issues through `ctx.api` DURING a cleanup dispatch runs on the same + * inherited envelope and so carries the marker too — the operation-envelope + * semantics the `__` key has always had, projected faithfully rather than + * re-scoped. + * + * OPTIONAL for the same reason `dispatch` is: making it required would + * reject the partial contexts `HookContextSchema.parse` accepts today. The + * engine writes `true` or omits it — it never writes `false`. + */ + referentialFieldClear: z.boolean().optional().describe( + "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.', + ), /** * Transaction Handle