From 2302a5f07cc66dba436160a9b1e9a85b8b6ece68 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:33:48 +0000 Subject: [PATCH 1/5] feat(spec): EngineDeleteOptions.permanent, the delete that skips the recycle bin A by-id delete that sets `permanent: true` keeps nothing in the recycle bin and is authorized as the `purge` operation rather than `delete`: the privacy-erasure door ADR-0148 D3 keeps, and a recycle-bin manager's act. A delete by predicate never enters the bin, so the key is for by-id deletes only. The authorable surface and the reference page are regenerated. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr --- content/docs/references/data/data-engine.mdx | 1 + packages/spec/authorable-surface/data.json | 1 + packages/spec/src/data/data-engine.zod.ts | 11 +++++++++++ 3 files changed, 13 insertions(+) diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index e9784d337a4..e10bd2f6906 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -656,6 +656,7 @@ QueryAST-aligned options for DataEngine.delete operations | **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | | | **where** | `Record \| any` | optional | | | **multi** | `boolean` | optional (default: `false`) | | +| **permanent** | `boolean` | optional | Delete for good: the rows skip the recycle bin and cannot be restored. Authorized as the `purge` operation, not `delete`. By-id deletes only. | ### Nested Shape: `EngineDeleteOptions.context` diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index a7078d83037..af6db7a2e94 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -238,6 +238,7 @@ "data/EngineCountOptions:where", "data/EngineDeleteOptions:context", "data/EngineDeleteOptions:multi", + "data/EngineDeleteOptions:permanent", "data/EngineDeleteOptions:where", "data/EngineQueryOptions:context", "data/EngineQueryOptions:cursor [RETIRED]", diff --git a/packages/spec/src/data/data-engine.zod.ts b/packages/spec/src/data/data-engine.zod.ts index 2e864dad2a4..189f89702c6 100644 --- a/packages/spec/src/data/data-engine.zod.ts +++ b/packages/spec/src/data/data-engine.zod.ts @@ -444,6 +444,17 @@ export const EngineDeleteOptionsSchema = lazySchema(() => BaseEngineOptionsSchem where: z.union([z.record(z.string(), z.unknown()), FilterConditionSchema]).optional(), /** Delete multiple records? If false, only the first match is deleted. Default: false */ multi: z.boolean().default(false).optional(), + /** + * [ADR-0148 D3] Delete for good: the deleted rows are not kept in the + * recycle bin (`sys_recycle_bin`), so nothing can restore them. This is the + * privacy-erasure door, and a recycle-bin manager's act: it is authorized + * as the `purge` operation, not as `delete`. Only a by-id delete takes it: + * a delete by predicate never enters the bin, so the engine refuses + * `permanent: true` beside `multi: true` or without an id. + */ + permanent: z.boolean().optional().describe( + 'Delete for good: the rows skip the recycle bin and cannot be restored. Authorized as the `purge` operation, not `delete`. By-id deletes only.', + ), }).describe('QueryAST-aligned options for DataEngine.delete operations')); // -------------------------------------------------------------------------- From aa8b43fa6018cba24891fb6f52e0bcaf850b1b0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:51:41 +0000 Subject: [PATCH 2/5] feat(objectql): purge and empty the recycle bin, and the permanent delete, as the purge operation ObjectQL.purge(entryId) removes one recycle-bin entry and ObjectQL.empty(object) every entry of one object, scoped to the caller's organization. A by-id delete with permanent: true writes no entry. All three are dispatched as the new `purge` operation on the business object, never with an AST, so the recycle-bin manager's grant governs them; plugin-security refuses the unmapped verb for every non-system caller until it is mapped. The permanent delete is refused 400 off the by-id path, on a platform object and for a non-boolean value. `purge` advances the write epoch and names no hook pair, so beforePurge/afterPurge keep warning as before. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr --- ...30237-recycle-bin-purge-empty-permanent.md | 22 + ...ne-middleware-operation-vocabulary.test.ts | 102 ++++- packages/objectql/src/engine-top-level.ts | 35 +- packages/objectql/src/engine.ts | 57 ++- packages/objectql/src/index.ts | 1 + packages/objectql/src/recycle-bin/capture.ts | 21 +- .../objectql/src/recycle-bin/purge.test.ts | 414 ++++++++++++++++++ packages/objectql/src/recycle-bin/purge.ts | 223 ++++++++++ packages/objectql/src/write-epoch.test.ts | 7 +- packages/objectql/src/write-epoch.ts | 5 +- 10 files changed, 839 insertions(+), 48 deletions(-) create mode 100644 .changeset/30237-recycle-bin-purge-empty-permanent.md create mode 100644 packages/objectql/src/recycle-bin/purge.test.ts create mode 100644 packages/objectql/src/recycle-bin/purge.ts diff --git a/.changeset/30237-recycle-bin-purge-empty-permanent.md b/.changeset/30237-recycle-bin-purge-empty-permanent.md new file mode 100644 index 00000000000..13f3247a1de --- /dev/null +++ b/.changeset/30237-recycle-bin-purge-empty-permanent.md @@ -0,0 +1,22 @@ +--- +'@objectstack/objectql': minor +'@objectstack/spec': minor +--- + +feat(objectql,spec): purge and empty the recycle bin, and delete a record for good (ADR-0148): `ObjectQL.purge(entryId)`, `ObjectQL.empty(object)` and `delete(object, { where: { id }, permanent: true })`, all authorized as the `purge` operation + +Clause-②: yes (widening) +Metadata-protocol: added +Skills-docs: follow-up #30847 — `skills/objectstack-api/SKILL.md` :268, :417 teach that there is no recycle bin; the skills lane owns that file. + +The recycle bin's removal side. All three acts are dispatched through the middleware chain as one new operation, `purge`, on the business object the rows belong to, so one grant will govern all three: `manageRecycleBin`, the recycle-bin manager's (ADR-0148 D5). **Until `@objectstack/plugin-security` maps `purge` to that grant, it refuses `purge` for every caller that is not the system**, the fail-closed rule it already applies to the destructive verbs. So in this release only the system, or a composition without the security plugin, can purge, empty or delete for good. Restoring, the two grants and the REST doors are not in this release. + +- **`@objectstack/spec`:** `EngineDeleteOptions` gains `permanent?: boolean`: delete for good, so the rows skip the recycle bin and cannot be restored. It is authorized as the `purge` operation, not `delete`, and is honoured on a by-id delete only. +- **`@objectstack/objectql`, the permanent delete:** `delete(object, { where: { id }, permanent: true })` runs as the by-id delete does (its `beforeDelete` / `afterDelete` hooks, which read `permanent` off `input.options`, its cascade, its events), writes no recycle-bin entry, and is dispatched as `purge` with the row's id. These are refused `400 VALIDATION_ERROR` (`param: 'permanent'`) before any middleware or hook runs: + - `permanent: true` on a delete that is not by id, or that asks `multi: true`; + - `permanent: true` on a platform object (`sys_*`, `cloud_*`, `ai_*`), whose delete never enters the bin and is already permanent; + - a `permanent` that is not a boolean. + `permanent: false` is the ordinary delete. +- **`@objectstack/objectql`, `purge(entryId, { context })`:** removes one recycle-bin entry and answers `true`. It answers `false` when no entry with that id is visible to the caller's organization, or when the composition registers no bin. The entry is read first, scoped to the caller's organization, for the object its rows belong to; the removal is then dispatched as `purge` on that object, with no id. +- **`@objectstack/objectql`, `empty(object, { context })`:** removes every entry of one object that the caller's organization holds, and answers how many. It is one `purge` dispatch on `object`, so one authorization covers it. The object need not be registered any more, so an uninstalled package's entries can still be emptied. A platform object, or an empty name, is refused `400 VALIDATION_ERROR` (`param: 'object'`). It answers `0` when the composition registers no bin. +- **Middleware authors:** `OperationContext.operation` gains `'purge'`, which never carries an `ast`. A middleware that switches on the operation exhaustively, or keys write handling on `'delete'`, sees the permanent delete as `'purge'`. `purge` advances the engine's write epoch, as `insert`, `update` and `delete` do (`WRITE_EPOCH_OPERATIONS`). `beforePurge` / `afterPurge` are not engine hook events: registering one warns that it never fires, as before. diff --git a/packages/objectql/src/engine-middleware-operation-vocabulary.test.ts b/packages/objectql/src/engine-middleware-operation-vocabulary.test.ts index f6fe29fc445..9f151881436 100644 --- a/packages/objectql/src/engine-middleware-operation-vocabulary.test.ts +++ b/packages/objectql/src/engine-middleware-operation-vocabulary.test.ts @@ -1,7 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#7809] The engine's MIDDLEWARE DISPATCH VOCABULARY is fixed, and it contains -// no destructive lifecycle verb (`purge` / `transfer` / `restore`). +// [#7809] The engine's MIDDLEWARE DISPATCH VOCABULARY is fixed. Of the +// destructive lifecycle verbs (`purge` / `transfer` / `restore`) it contains +// `purge` alone (#30237, the recycle bin), and `purge` never carries an AST. // // ── Why a pin here, in objectql, for a finding filed against plugin-security ── // @@ -18,10 +19,14 @@ // the same select-only object is scoped (#7665). // // The finding's own first question was whether those verbs can reach that path -// at all. They cannot — not on the AST path, and not on ANY path: +// at all. They cannot reach the AST path. `transfer` and `restore` reach no +// path; `purge` reaches the by-id and object-level ones only: // -// 1. `OperationContext['operation']` is the 7-member union pinned below. -// No destructive lifecycle verb is a member. +// 1. `OperationContext['operation']` is the 8-member union pinned below. +// Of the destructive lifecycle verbs, `purge` alone is a member (#30237), +// and every dispatch of it is built without an `ast`: the permanent by-id +// delete (`permanent: true`, refused on any delete that is not by id), +// `purge(entryId)` and `empty(object)`. // 2. Middleware is invoked from exactly one place — the PRIVATE // `executeWithMiddleware(ctx: OperationContext, …)`. There is no other // caller, and no public seam that hands a middleware a context the engine @@ -30,12 +35,13 @@ // `operation` is a member of that union by construction. // // So step 3 passing `opCtx.operation` raw is SAFE TODAY — not by accident, but -// because the vocabulary cannot deliver a verb it fails to handle. This file -// exists so that stays true by assertion rather than by luck: the day a recycle -// bin (#3146, parked — `enable.trash` was retired in #2377/ADR-0049, which is -// why `API_METHOD_DERIVATION` gives `restore`/`purge` a permanent -// `flag: () => false`) makes one of these verbs dispatchable, this pin goes red -// and names the two sites that must normalise first. +// because the vocabulary cannot deliver a verb it fails to handle ON THAT PATH. +// This file exists so that stays true by assertion rather than by luck. The +// recycle bin (ADR-0148, #30237) made `purge` dispatchable, and kept it off the +// AST path, which the AST-set lock below proves; the by-id permanent delete is +// judged by step 2.7, which normalises `purge -> delete`. The day a destructive +// verb reaches the AST path, this pin goes red and names the two sites that +// must normalise first. // // ⚠️ Note the vocabularies are deliberately DIFFERENT and must not be conflated: // `ExplainOperationSchema` (spec/security/explain.zod.ts) is a 7-verb @@ -119,6 +125,8 @@ const DISPATCHED_OPERATIONS = [ 'delete', 'count', 'aggregate', + // [#30237] The recycle bin's removal verb: the permanent delete, purge, empty. + 'purge', ] as const; type Declared = (typeof DISPATCHED_OPERATIONS)[number]; @@ -180,11 +188,14 @@ function readDispatchedUnionFromEngineSource(): string[] { /** * The verbs #7809 is about. `plugin-security`'s step-2.7 gate normalises these * onto `update`/`delete` before deriving row scope; its step-3 AST path and the - * ADR-0090 D10 delegator half do NOT. Keeping them out of the dispatch - * vocabulary is what makes that difference unobservable. + * ADR-0090 D10 delegator half do NOT. Keeping them off the AST path is what + * makes that difference unobservable. */ const DESTRUCTIVE_LIFECYCLE_VERBS = ['purge', 'transfer', 'restore'] as const; +/** [#30237] The one destructive lifecycle verb the engine dispatches, never with an AST. */ +const DISPATCHED_DESTRUCTIVE_VERBS = ['purge'] as const; + const NOTE_SCHEMA = { name: 'note', fields: { @@ -202,6 +213,20 @@ const NOTE_SCHEMA = { */ const ROW = { id: 'n1', title: 'x', owner: 'me' }; +/** + * [#30237] The recycle bin, registered so the probe reaches its three `purge` + * dispatches: an entry the driver always has, of a `note`. + */ +const BIN_SCHEMA = { + name: 'sys_recycle_bin', + fields: { + id: { type: 'text' }, + object_name: { type: 'text' }, + record_id: { type: 'text' }, + }, +}; +const ENTRY = { id: 'e1', object_name: 'note', record_id: 'n1' }; + // `any` on the double itself, matching the sibling read-filter test: this is a // deliberately PARTIAL driver (the engine only reaches the members listed), and // annotating it `IDataDriver` would demand the whole surface for no assertion. @@ -214,7 +239,7 @@ function makeDriver(): any { supports: {}, connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), - find: vi.fn(async () => [{ ...ROW }]), + find: vi.fn(async (object: string) => [object === 'sys_recycle_bin' ? { ...ENTRY } : { ...ROW }]), findOne: vi.fn(async () => ({ ...ROW })), count: vi.fn(async () => 1), aggregate: vi.fn(async () => []), @@ -229,11 +254,13 @@ function makeDriver(): any { interface Seen { operation: string; hasAst: boolean; + /** A scalar `options.where.id`: the live row a by-id write names. */ + hasId: boolean; } async function makeEngine() { vi.mocked((SchemaRegistry as any).getObject).mockImplementation((name: string) => - name === 'note' ? NOTE_SCHEMA : undefined, + name === 'note' ? NOTE_SCHEMA : name === 'sys_recycle_bin' ? BIN_SCHEMA : undefined, ); const ql = new ObjectQL(); ql.registerDriver(makeDriver(), true); @@ -244,7 +271,8 @@ async function makeEngine() { // precisely why that plugin can compare against verbs the union does not // contain without TypeScript ever objecting. ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { - seen.push({ operation: opCtx.operation, hasAst: !!opCtx.ast }); + const id = opCtx.options?.where?.id; + seen.push({ operation: opCtx.operation, hasAst: !!opCtx.ast, hasId: typeof id === 'string' || typeof id === 'number' }); await next(); }); return { ql, seen }; @@ -285,6 +313,10 @@ async function driveEveryPublicMethod(ql: ObjectQL): Promise { await ql.update('note', { title: 'bulk' }, { multi: true, where: { owner: 'me' }, context: ctx } as any); await ql.delete('note', { where: { id: 'n1' }, context: ctx } as any); await ql.delete('note', { multi: true, where: { owner: 'me' }, context: ctx } as any); + // [#30237] The recycle bin's three `purge` dispatches, in this order. + await ql.delete('note', { where: { id: 'n1' }, permanent: true, context: ctx }); + await ql.purge('e1', { context: ctx }); + await ql.empty('note', { context: ctx }); } describe('[#7809] engine middleware dispatch vocabulary', () => { @@ -297,7 +329,7 @@ describe('[#7809] engine middleware dispatch vocabulary', () => { expect(fromSource.slice().sort()).toEqual([...DISPATCHED_OPERATIONS].sort()); }); - it('the engine union declares no destructive lifecycle verb', () => { + it('the engine union declares `purge` and no other destructive lifecycle verb', () => { const fromSource = readDispatchedUnionFromEngineSource(); const destructive = fromSource.filter((op) => (DESTRUCTIVE_LIFECYCLE_VERBS as readonly string[]).includes(op), @@ -305,10 +337,10 @@ describe('[#7809] engine middleware dispatch vocabulary', () => { // Asserted against the SOURCE list, not against `DISPATCHED_OPERATIONS` — // checking this file's own array against this file's own array would prove // nothing. - expect(destructive).toEqual([]); + expect(destructive).toEqual([...DISPATCHED_DESTRUCTIVE_VERBS]); }); - it('a real engine driven through every public method dispatches exactly the 7', async () => { + it('a real engine driven through every public method dispatches exactly the 8', async () => { const { ql, seen } = await makeEngine(); await driveEveryPublicMethod(ql); @@ -316,7 +348,7 @@ describe('[#7809] engine middleware dispatch vocabulary', () => { expect(observed).toEqual([...DISPATCHED_OPERATIONS].sort()); }); - it('no destructive lifecycle verb reaches a middleware, on any path', async () => { + it('of the destructive lifecycle verbs, only `purge` reaches a middleware, and never with an AST', async () => { const { ql, seen } = await makeEngine(); await driveEveryPublicMethod(ql); @@ -324,7 +356,29 @@ describe('[#7809] engine middleware dispatch vocabulary', () => { const destructive = seen.filter((s) => (DESTRUCTIVE_LIFECYCLE_VERBS as readonly string[]).includes(s.operation), ); - expect(destructive).toEqual([]); + // [#30237] The permanent delete, `purge` and `empty`, in the order driven. + // Only the permanent delete names a live row: step 2.7 judges that row + // as a delete (`purge -> delete`); `purge` and `empty` are object-level. + expect(destructive).toEqual([ + { operation: 'purge', hasAst: false, hasId: true }, + { operation: 'purge', hasAst: false, hasId: false }, + { operation: 'purge', hasAst: false, hasId: false }, + ]); + }); + + it('[#30237] `permanent: true` off the by-id path is refused before any middleware runs', async () => { + const { ql, seen } = await makeEngine(); + const ctx: ExecutionContext = { userId: 'u1', isSystem: true }; + + // A delete by filter: it would carry its `where` as an AST. + await expect( + ql.delete('note', { multi: true, where: { owner: 'me' }, permanent: true, context: ctx }), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + // A by-id delete asked as a predicate one: the bin never takes it. + await expect( + ql.delete('note', { multi: true, where: { id: 'n1' }, permanent: true, context: ctx }), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(seen).toEqual([]); }); it('every AST-carrying dispatch — the #7809 path — carries a handled operation', async () => { @@ -355,10 +409,14 @@ describe('[#7809] engine middleware dispatch vocabulary', () => { const astOperations = [...new Set(astCarrying.map((s) => s.operation))].sort(); expect(astOperations).toEqual(['aggregate', 'count', 'delete', 'find', 'findOne', 'update']); - // The by-id write branches, conversely, must NOT carry an ast. + // The by-id write branches, conversely, must NOT carry an ast; nor does + // any `purge` (#30237: the permanent delete, `purge`, `empty`). expect(seen.filter((s) => !s.hasAst).map((s) => s.operation).sort()).toEqual([ 'delete', 'insert', + 'purge', + 'purge', + 'purge', 'update', ]); }); diff --git a/packages/objectql/src/engine-top-level.ts b/packages/objectql/src/engine-top-level.ts index e86fd97bcf7..54150047236 100644 --- a/packages/objectql/src/engine-top-level.ts +++ b/packages/objectql/src/engine-top-level.ts @@ -137,10 +137,11 @@ const DISPATCHABLE_HOOK_EVENTS: ReadonlySet = new Set([ * [#17713] Every verb the engine can put on an {@link OperationContext}, as * DATA rather than as a second hand-written list. * - * The type annotation is the weld: `Record` is exhaustive in both - * directions, so a new engine method that widens `OperationContext['operation']` + * The type annotation is the weld: `Record` is exhaustive in + * both directions, so a new engine method that widens `OperationContext['operation']` * fails to compile here until the verb is added, and a verb dropped from the - * union makes its entry an excess property. `pnpm typecheck` compiles this file + * union makes its entry an excess property. The value says whether the verb + * names a `before`/`after` hook pair. `pnpm typecheck` compiles this file * (it is `src`, not the test layer), so the weld is enforced by the package's * own type check and not only by a test. * @@ -148,7 +149,7 @@ const DISPATCHABLE_HOOK_EVENTS: ReadonlySet = new Set([ * hand-written list of "the events we refuse" would be this card's own defect * one layer up: it would go stale the moment a verb is added, and silently. */ -const ENGINE_OPERATION_VERBS: Record = { +const ENGINE_OPERATION_VERBS: Record = { find: true, findOne: true, insert: true, @@ -156,15 +157,22 @@ const ENGINE_OPERATION_VERBS: Record = { delete: true, count: true, aggregate: true, + // [#30237] The recycle bin's removal verb names no hook pair. `beforePurge` / + // `afterPurge` stay outside the lifecycle namespace, where they were before + // the verb existed: inside it they would be refused at registration, a + // narrowing of `registerHook` this verb does not make. The permanent delete + // still fires `beforeDelete` / `afterDelete`. + purge: false, }; /** * [#17713] The engine's own lifecycle-event NAMESPACE — `before`/`after` × every - * verb in {@link ENGINE_OPERATION_VERBS}. Fourteen names, of which - * {@link DISPATCHABLE_HOOK_EVENTS} is the eight the engine actually dispatches. + * verb in {@link ENGINE_OPERATION_VERBS} that names a hook pair. Fourteen names, + * of which {@link DISPATCHABLE_HOOK_EVENTS} is the eight the engine actually + * dispatches. */ const ENGINE_LIFECYCLE_HOOK_EVENTS: ReadonlySet = new Set( - Object.keys(ENGINE_OPERATION_VERBS).flatMap((verb) => { + Object.keys(ENGINE_OPERATION_VERBS).filter((verb) => ENGINE_OPERATION_VERBS[verb as OperationContext['operation']]).flatMap((verb) => { const suffix = `${verb.charAt(0).toUpperCase()}${verb.slice(1)}`; return [`before${suffix}`, `after${suffix}`]; }), @@ -321,7 +329,7 @@ const ENGINE_UPDATE_OPTION_KEYS: ReadonlySet = new Set([ ...ENGINE_DRIVER_PASSTHROUGH_KEYS, ]); const ENGINE_DELETE_OPTION_KEYS: ReadonlySet = new Set([ - 'context', 'where', 'multi', + 'context', 'where', 'multi', 'permanent', ...ENGINE_DRIVER_PASSTHROUGH_KEYS, ]); // `search` / `searchFields` are READ by `count` and `aggregate` through the @@ -3659,7 +3667,16 @@ export interface FunctionRegistrationOptions { */ export interface OperationContext { object: string; - operation: 'find' | 'findOne' | 'insert' | 'update' | 'delete' | 'count' | 'aggregate'; + /** + * The verb the middleware chain authorizes. `purge` [#30237, ADR-0148] is the + * recycle bin's removal verb, dispatched on the business object the rows + * belong to: a by-id `delete` with `permanent: true` (its `where.id` the live + * row's), `purge(entryId)` and `empty(object)` (no id). It never carries an + * `ast`, so `@objectstack/plugin-security`'s AST path (step 3), which hands the + * verb to the RLS compiler raw, never sees it (#7809, + * `engine-middleware-operation-vocabulary.test.ts`). + */ + operation: 'find' | 'findOne' | 'insert' | 'update' | 'delete' | 'count' | 'aggregate' | 'purge'; ast?: QueryAST; data?: any; options?: any; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 0a08f253a67..b80e5f1e33d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -84,6 +84,7 @@ import { // [#30237, ADR-0148] The recycle bin's write side: the capture body, and the // retention seam the host hands over (`RecycleBinCaptureHost`). import { openRecycleBinCapture, type RecycleBinCapture } from './recycle-bin/capture.js'; +import { emptyRecycleBin, purgeRecycleBinEntry, readPermanentDelete, type RecycleBinRemovalOptions } from './recycle-bin/purge.js'; import type { RecycleBinRetentionSettings } from './recycle-bin/retention.js'; // [#25522 cut 6] The predicate-write hook dispatch bodies; the methods of the // same names below hand each body the members it reads (`HookDispatchHost`). @@ -8825,7 +8826,7 @@ export class ObjectQL implements IObjectQLEngine { object: string, id: string | number, context: ExecutionContext | undefined, - predicate: boolean, + skip: boolean, ): RecycleBinCapture | undefined { // [#30237] The body lives in `recycle-bin/capture.ts`, its docblock with it (see `RecycleBinCaptureHost`). const readRegistry = () => this._registry; @@ -8842,7 +8843,49 @@ export class ObjectQL implements IObjectQLEngine { getDriver: (...a) => this.getDriver(...a), buildDriverOptions: (...a) => this.buildDriverOptions(...a), }; - return openRecycleBinCapture.call(host, object, id, context, predicate); + return openRecycleBinCapture.call(host, object, id, context, skip); + } + + /** + * [#30237, ADR-0148 D1] Purge one recycle-bin entry: its rows can no longer be + * restored. `true` when the entry was removed; `false` when no entry with that + * id is visible to the caller's organization, or the composition registers no + * bin (`PlatformObjectsPlugin`). + * + * Authorized as the `purge` operation on the object the entry's rows belong + * to, the recycle-bin manager's verb: until `@objectstack/plugin-security` + * maps it to its grant, every caller that is not the system is refused. The + * body lives in `recycle-bin/purge.ts`, its docblock with it. + */ + purge(entryId: string | number, options?: RecycleBinRemovalOptions): Promise { + const readRegistry = () => this._registry; + const host: ThisParameterType = { + get _registry() { return readRegistry(); }, + getDriver: (...a) => this.getDriver(...a), + buildDriverOptions: (...a) => this.buildDriverOptions(...a), + executeWithMiddleware: (...a) => this.executeWithMiddleware(...a), + }; + return purgeRecycleBinEntry.call(host, entryId, options); + } + + /** + * [#30237, ADR-0148 D1] Empty the recycle bin of one object: remove every + * entry of `object` the caller's organization holds, and answer how many. + * `0` when the composition registers no bin. A platform object (`sys_`, + * `cloud_`, `ai_`) is refused `400`: the bin never takes one. + * + * One `purge` operation on `object`, so one authorization covers the act, + * as for {@link purge}. The body lives in `recycle-bin/purge.ts`. + */ + empty(object: string, options?: RecycleBinRemovalOptions): Promise { + const readRegistry = () => this._registry; + const host: ThisParameterType = { + get _registry() { return readRegistry(); }, + getDriver: (...a) => this.getDriver(...a), + buildDriverOptions: (...a) => this.buildDriverOptions(...a), + executeWithMiddleware: (...a) => this.executeWithMiddleware(...a), + }; + return emptyRecycleBin.call(host, object, options); } /** @@ -9103,13 +9146,18 @@ 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; + // [#30237, ADR-0148, Q3 = A] A permanent delete writes no recycle-bin entry + // and is authorized as `purge`, the recycle-bin manager's verb. Refused here, + // before any middleware or hook, unless it is a by-id delete of an object + // the bin takes (see `readPermanentDelete`). + const permanent = readPermanentDelete(object, options, dispatch.kind); // [#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, - operation: 'delete', + operation: permanent ? 'purge' : 'delete', options, context: options?.context, }; @@ -9519,8 +9567,9 @@ export class ObjectQL implements IObjectQLEngine { // with it: the transaction's rollback when one covers the bin, // the capture's compensation otherwise. A caller's `multi: true` // opens none, even here: the lifecycle sweep reaps by id with it. + // Nor does a permanent delete, which is the point of it. const runByIdDelete = async (writeContext?: ExecutionContext) => { - const bin = this.openRecycleBinCapture(object, hookContext.input.id as string | number, writeContext, options?.multi === true); + const bin = this.openRecycleBinCapture(object, hookContext.input.id as string | number, writeContext, options?.multi === true || permanent); await this.cascadeDeleteRelations(object, [hookContext.input.id as string | number], writeContext, 0, bin?.onCollected) .catch((e: unknown) => { throw cascadeStoppedRefusal(e, object); }); const entry = await bin?.write(); diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index b3c927e85ab..a47a7b98ccd 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -632,6 +632,7 @@ export type { export { parseLifecycleDuration } from './lifecycle/duration.js'; // [#30237, ADR-0148 D3] What a host hands `ObjectQLPlugin`'s `recycleBinRetention`. export type { RecycleBinRetentionSettings } from './recycle-bin/retention.js'; +export type { RecycleBinRemovalOptions } from './recycle-bin/purge.js'; export { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js'; // [#4551] Read-only referential-integrity audit — the reporting half of the diff --git a/packages/objectql/src/recycle-bin/capture.ts b/packages/objectql/src/recycle-bin/capture.ts index f2055c52db3..9cb58c61ea0 100644 --- a/packages/objectql/src/recycle-bin/capture.ts +++ b/packages/objectql/src/recycle-bin/capture.ts @@ -33,9 +33,9 @@ * and `ai_` (`hasPlatformObjectPrefix`, the tenancy census's population; seat * ruling on the contract review's question 7). Not: a delete by predicate * (`multi: true`, which every lifecycle reap is, even though it addresses each - * reaped row by id), a child the cascade deletes (it is a row of its root's - * entry), and a platform object. A platform row the cascade reaches is left - * out of the snapshot too. + * reaped row by id), a permanent delete (`permanent: true`, `purge.ts`), a + * child the cascade deletes (it is a row of its root's entry), and a platform + * object. A platform row the cascade reaches is left out of the snapshot too. * * ## Why raw, and not `engine.insert` * @@ -314,19 +314,22 @@ export class RecycleBinCapture { * host's resolver or `OS_RECYCLE_BIN_RETENTION_DAYS` holds a defect, before * the delete has written anything. * - * `predicate` is the caller's own `multi: true`. The engine routes a delete by - * its id, not by that flag (#5574, #11009), so a predicate delete that names - * one row by id, as the lifecycle sweep's per-row reap does, arrives here on - * the by-id path and is told apart only by the flag it was asked with. + * `skip` is `true` when the caller asked the delete to stay out of the bin: + * + * - its own `multi: true`. The engine routes a delete by its id, not by that + * flag (#5574, #11009), so a predicate delete that names one row by id, as + * the lifecycle sweep's per-row reap does, arrives here on the by-id path + * and is told apart only by the flag it was asked with; + * - `permanent: true`, the delete for good (`purge.ts`). */ export function openRecycleBinCapture( this: RecycleBinCaptureHost, object: string, id: string | number, context: ExecutionContext | undefined, - predicate: boolean, + skip: boolean, ): RecycleBinCapture | undefined { - if (predicate || !recycleBinTakesObject(object)) return undefined; + if (skip || !recycleBinTakesObject(object)) return undefined; // A child the cascade deletes is a row of its root's entry, never an entry. const inherited = this.cascadeDeleteSets.getStore(); if (inherited && cascadeSetHas(inherited.members, object, id)) return undefined; diff --git a/packages/objectql/src/recycle-bin/purge.test.ts b/packages/objectql/src/recycle-bin/purge.test.ts new file mode 100644 index 00000000000..49a014eeef9 --- /dev/null +++ b/packages/objectql/src/recycle-bin/purge.test.ts @@ -0,0 +1,414 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30237, ADR-0148 D1/D3, Q3 = A] The recycle bin's removal side, through a + * real `ObjectQL`: the permanent delete (`permanent: true`) writes no entry and + * is dispatched as `purge`; `purge(entryId)` removes one entry and `empty(object)` + * every entry of one object, each as ONE `purge` dispatch on the business + * object, with no AST and no id, scoped to the caller's organization. + * + * The driver keeps real rows and honours `options.tenantId` the way the SQL + * driver does (`organization_id = :tenant`), because "another organization's + * entry is out of reach" cannot be told apart from "it is not" by a stub. + */ + +import { describe, it, expect } from 'vitest'; +import { RECYCLE_BIN_ENTRY_FIELDS } from '@objectstack/spec/data'; +import { ObjectQL } from '../engine.js'; + +type Row = Record; + +function makeDriver(opts: { deleteMany?: boolean } = {}) { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + let nextId = 0; + const matches = (row: Row, where: any, options: any): boolean => { + if (options?.tenantId !== undefined && 'organization_id' in row && row.organization_id !== options.tenantId) return false; + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) { + if (!(v as any).$in.some((x: unknown) => String(x) === String(row[k]))) return false; + continue; + } + if ((row[k] ?? null) !== (v ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'primary', + version: '0.0.0', + supports: {}, + finds: [] as Array<{ object: string; limit: unknown }>, + rowsOf: (o: string) => Array.from(storeFor(o).values()), + seed(o: string, row: Row) { storeFor(o).set(String(row.id), { ...row }); }, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async syncSchema() {}, + async find(o: string, ast: any, options: any) { + driver.finds.push({ object: o, limit: ast?.limit }); + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where, options)); + const page = typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + return page.map((r) => ({ ...r })); + }, + async findOne(o: string, ast: any, options: any) { + for (const r of storeFor(o).values()) if (matches(r, ast?.where, options)) return { ...r }; + return null; + }, + async create(o: string, data: Row) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = JSON.parse(JSON.stringify({ ...data, id })); + storeFor(o).set(id, row); + return { ...row }; + }, + async update(o: string, id: string, data: Row) { + const s = storeFor(o); + const cur = s.get(String(id)); + if (!cur) throw new Error(`not found ${o}/${id}`); + const up = { ...cur, ...data, id }; + s.set(String(id), up); + return { ...up }; + }, + async delete(o: string, id: string, options: any) { + const row = storeFor(o).get(String(id)); + if (!row || !matches(row, undefined, options)) return false; + return storeFor(o).delete(String(id)); + }, + async count(o: string, ast: any, options: any) { return (await driver.find(o, ast, options)).length; }, + }; + if (opts.deleteMany !== false) { + driver.deleteMany = async (o: string, ast: any, options: any) => { + const doomed = await driver.find(o, { where: ast?.where }, options); + for (const r of doomed) storeFor(o).delete(String((r as any).id)); + return doomed.length; + }; + } + return driver; +} + +const text = (name: string) => ({ name, type: 'text' as const }); + +/** The bin, declared as `@objectstack/platform-objects` declares it. */ +const bin = { + name: 'sys_recycle_bin', + label: 'Recycle Bin Entry', + managedBy: 'engine-owned', + fields: { + ...RECYCLE_BIN_ENTRY_FIELDS, + organization_id: { name: 'organization_id', type: 'lookup' as const, reference: 'sys_organization', deleteBehavior: 'cascade' }, + }, +}; +/** account ← contact (master_detail). */ +const account = { + name: 'account', + label: 'Account', + fields: { id: text('id'), name: text('name'), organization_id: text('organization_id') }, +}; +const contact = { + name: 'contact', + label: 'Contact', + fields: { + id: text('id'), + name: text('name'), + account: { name: 'account', type: 'master_detail' as const, reference: 'account' }, + }, +}; +const loner = { name: 'loner', label: 'Loner', fields: { id: text('id'), name: text('name') } }; +const sysThing = { name: 'sys_thing', label: 'Thing', fields: { id: text('id'), name: text('name') } }; + +interface Dispatch { object: string; operation: string; hasAst: boolean; hasId: boolean } + +async function makeEngine(options: { withBin?: boolean; deleteMany?: boolean } = {}) { + const engine = new ObjectQL(); + // The multi-organization posture, so a caller's organization scopes its reads + // and writes (the default under test is `single`, which scopes nothing). + engine.setTenancyPostureProvider(() => 'isolated'); + const driver = makeDriver({ deleteMany: options.deleteMany }); + engine.registerDriver(driver, true); + await engine.init(); + const objects: any[] = [account, contact, loner, sysThing]; + if (options.withBin !== false) objects.push(bin); + for (const o of objects) engine.registry.registerObject(o as any, '__test__'); + const dispatches: Dispatch[] = []; + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + const id = opCtx.options?.where?.id; + dispatches.push({ + object: opCtx.object, + operation: opCtx.operation, + hasAst: !!opCtx.ast, + hasId: typeof id === 'string' || typeof id === 'number', + }); + await next(); + }); + return { engine, driver, dispatches }; +} + +/** + * plugin-security's posture until it maps `purge` to `manageRecycleBin`: the + * destructive verb, unmapped, is refused for every caller but the system. + */ +function refuseNonSystemPurge(engine: ObjectQL) { + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.operation === 'purge' && opCtx.context?.isSystem !== true) { + const err: any = new Error(`[Security] Access denied: purge on '${opCtx.object}'`); + err.code = 'PERMISSION_DENIED'; + err.status = 403; + throw err; + } + await next(); + }); +} + +const ORG1_USER = { context: { userId: 'u_alice', tenantId: 'org_1' } } as const; +const ORG2_USER = { context: { userId: 'u_bob', tenantId: 'org_2' } } as const; +const SYSTEM = { context: { isSystem: true } } as const; +const entriesOf = (driver: any) => driver.rowsOf('sys_recycle_bin') as Row[]; + +/** Delete two loners in each organization, so each holds two entries. */ +async function seedEntries(engine: ObjectQL, driver: any) { + for (const [id, who] of [['l1', ORG1_USER], ['l2', ORG1_USER], ['l3', ORG2_USER], ['l4', ORG2_USER]] as const) { + driver.seed('loner', { id, name: id }); + await engine.delete('loner', { where: { id }, ...who }); + } + driver.seed('account', { id: 'acc1', name: 'Acme', organization_id: 'org_1' }); + await engine.delete('account', { where: { id: 'acc1' }, ...ORG1_USER }); + return { + of: (record: string) => entriesOf(driver).find((e) => e.record_id === record)!, + }; +} + +describe('the permanent delete (Q3 = A)', () => { + it('deletes the record and its cascade, writes no entry, and is dispatched as `purge` on the row', async () => { + const { engine, driver, dispatches } = await makeEngine(); + driver.seed('account', { id: 'acc1', name: 'Acme', organization_id: 'org_1' }); + driver.seed('contact', { id: 'c1', name: 'Ada', account: 'acc1' }); + + await expect(engine.delete('account', { where: { id: 'acc1' }, permanent: true, ...ORG1_USER })).resolves.toBeTruthy(); + + expect(driver.rowsOf('account')).toHaveLength(0); + expect(driver.rowsOf('contact')).toHaveLength(0); + expect(entriesOf(driver)).toHaveLength(0); + const own = dispatches.filter((d) => d.object === 'account'); + expect(own).toEqual([{ object: 'account', operation: 'purge', hasAst: false, hasId: true }]); + }); + + it('still fires the delete hooks, which read `permanent` off the options', async () => { + const { engine, driver } = await makeEngine(); + driver.seed('loner', { id: 'l1', name: 'one' }); + const seen: Array<{ event: string; permanent: unknown }> = []; + for (const event of ['beforeDelete', 'afterDelete']) { + engine.registerHook(event, async (ctx: any) => { + seen.push({ event, permanent: ctx.input?.options?.permanent }); + }, { object: 'loner' }); + } + + await engine.delete('loner', { where: { id: 'l1' }, permanent: true, ...ORG1_USER }); + + expect(seen).toEqual([ + { event: 'beforeDelete', permanent: true }, + { event: 'afterDelete', permanent: true }, + ]); + }); + + it('`permanent: false` is the ordinary delete, which keeps an entry', async () => { + const { engine, driver, dispatches } = await makeEngine(); + driver.seed('loner', { id: 'l1', name: 'one' }); + + await engine.delete('loner', { where: { id: 'l1' }, permanent: false, ...ORG1_USER }); + + expect(entriesOf(driver)).toHaveLength(1); + expect(dispatches.map((d) => d.operation)).toEqual(['delete']); + }); + + it('is refused, and changes nothing, where the security layer refuses `purge`', async () => { + const { engine, driver } = await makeEngine(); + refuseNonSystemPurge(engine); + driver.seed('loner', { id: 'l1', name: 'one' }); + + await expect(engine.delete('loner', { where: { id: 'l1' }, permanent: true, ...ORG1_USER })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + expect(driver.rowsOf('loner')).toHaveLength(1); + expect(entriesOf(driver)).toHaveLength(0); + + // The ordinary delete is not the purge verb, and is not refused. + await engine.delete('loner', { where: { id: 'l1' }, ...ORG1_USER }); + expect(driver.rowsOf('loner')).toHaveLength(0); + expect(entriesOf(driver)).toHaveLength(1); + }); + + it.each([ + ['a delete by filter', { where: { name: 'one' }, multi: true }], + ['a by-id delete asked as a predicate', { where: { id: 'l1' }, multi: true }], + ])('is refused 400 on %s, before any middleware or write', async (_label, shape) => { + const { engine, driver, dispatches } = await makeEngine(); + driver.seed('loner', { id: 'l1', name: 'one' }); + + await expect(engine.delete('loner', { ...shape, permanent: true, ...ORG1_USER } as any)) + .rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400, param: 'permanent', object: 'loner' }); + expect(dispatches).toEqual([]); + expect(driver.rowsOf('loner')).toHaveLength(1); + }); + + it('is refused 400 on a platform object, whose delete is already permanent', async () => { + const { engine, driver, dispatches } = await makeEngine(); + driver.seed('sys_thing', { id: 's1', name: 'one' }); + + await expect(engine.delete('sys_thing', { where: { id: 's1' }, permanent: true, ...SYSTEM })) + .rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400, param: 'permanent', object: 'sys_thing' }); + expect(dispatches).toEqual([]); + expect(driver.rowsOf('sys_thing')).toHaveLength(1); + }); + + it('refuses a `permanent` that is not a boolean, rather than reading it as either', async () => { + const { engine, driver } = await makeEngine(); + driver.seed('loner', { id: 'l1', name: 'one' }); + + await expect(engine.delete('loner', { where: { id: 'l1' }, permanent: 'true', ...ORG1_USER } as any)) + .rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400, param: 'permanent' }); + expect(driver.rowsOf('loner')).toHaveLength(1); + }); +}); + +describe('purge(entryId)', () => { + it('removes the one entry, dispatched once as `purge` on its object with no AST and no id', async () => { + const { engine, driver, dispatches } = await makeEngine(); + const seeded = await seedEntries(engine, driver); + const entry = seeded.of('l1'); + dispatches.length = 0; + + await expect(engine.purge(entry.id as string, ORG1_USER)).resolves.toBe(true); + + expect(entriesOf(driver).map((e) => e.record_id).sort()).toEqual(['acc1', 'l2', 'l3', 'l4']); + expect(dispatches).toEqual([{ object: 'loner', operation: 'purge', hasAst: false, hasId: false }]); + }); + + it("answers false for an entry of another organization, and leaves it", async () => { + const { engine, driver, dispatches } = await makeEngine(); + const seeded = await seedEntries(engine, driver); + dispatches.length = 0; + + await expect(engine.purge(seeded.of('l3').id as string, ORG1_USER)).resolves.toBe(false); + + expect(entriesOf(driver)).toHaveLength(5); + expect(dispatches).toEqual([]); + }); + + it('answers false for an id no entry has, and when the composition registers no bin', async () => { + const withBin = await makeEngine(); + await expect(withBin.engine.purge('no_such_entry', ORG1_USER)).resolves.toBe(false); + + const noBin = await makeEngine({ withBin: false }); + await expect(noBin.engine.purge('no_such_entry', ORG1_USER)).resolves.toBe(false); + expect(noBin.dispatches).toEqual([]); + }); + + it('removes nothing where the security layer refuses `purge`; the system purges', async () => { + const { engine, driver } = await makeEngine(); + refuseNonSystemPurge(engine); + const seeded = await seedEntries(engine, driver); + const id = seeded.of('l1').id as string; + + await expect(engine.purge(id, ORG1_USER)).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + expect(entriesOf(driver)).toHaveLength(5); + + await expect(engine.purge(id, SYSTEM)).resolves.toBe(true); + expect(entriesOf(driver)).toHaveLength(4); + }); + + it.each([[''], [null], [{ id: 'x' }]])('refuses %j as the entry id, 400', async (entryId) => { + const { engine, dispatches } = await makeEngine(); + await expect(engine.purge(entryId as any, ORG1_USER)).rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400, param: 'entryId' }); + expect(dispatches).toEqual([]); + }); +}); + +describe('empty(object)', () => { + it("removes the caller's organization's entries of that object, in one `purge` dispatch, and counts them", async () => { + const { engine, driver, dispatches } = await makeEngine(); + await seedEntries(engine, driver); + dispatches.length = 0; + + await expect(engine.empty('loner', ORG1_USER)).resolves.toBe(2); + + // Org 2's loners and org 1's account stay. + expect(entriesOf(driver).map((e) => e.record_id).sort()).toEqual(['acc1', 'l3', 'l4']); + expect(dispatches).toEqual([{ object: 'loner', operation: 'purge', hasAst: false, hasId: false }]); + }); + + it('removes every organization\'s entries of that object for the system', async () => { + const { engine, driver } = await makeEngine(); + await seedEntries(engine, driver); + + await expect(engine.empty('loner', SYSTEM)).resolves.toBe(4); + expect(entriesOf(driver).map((e) => e.record_id)).toEqual(['acc1']); + }); + + it('removes them one page at a time on a driver with no `deleteMany`', async () => { + const { engine, driver } = await makeEngine({ deleteMany: false }); + await seedEntries(engine, driver); + driver.finds.length = 0; + + await expect(engine.empty('loner', SYSTEM)).resolves.toBe(4); + expect(entriesOf(driver).map((e) => e.record_id)).toEqual(['acc1']); + // Every read of the bin was bounded. + expect(driver.finds.filter((f: any) => f.object === 'sys_recycle_bin').every((f: any) => f.limit === 500)).toBe(true); + }); + + it('reads as many pages as it takes, and stops on a page the driver removes nothing from', async () => { + const { engine, driver } = await makeEngine({ deleteMany: false }); + for (let i = 0; i < 1_100; i += 1) { + driver.seed('sys_recycle_bin', { id: `e${i}`, object_name: 'loner', record_id: `l${i}` }); + } + driver.finds.length = 0; + + await expect(engine.empty('loner', SYSTEM)).resolves.toBe(1_100); + expect(entriesOf(driver)).toHaveLength(0); + expect(driver.finds.filter((f: any) => f.object === 'sys_recycle_bin')).toHaveLength(3); + + // A driver that answers `false` for every removal cannot hold the loop, + // even on a full page. + for (let i = 0; i < 500; i += 1) { + driver.seed('sys_recycle_bin', { id: `stuck${i}`, object_name: 'loner', record_id: `s${i}` }); + } + driver.delete = async () => false; + await expect(engine.empty('loner', SYSTEM)).resolves.toBe(0); + expect(entriesOf(driver)).toHaveLength(500); + }); + + it('empties the entries of an object that is no longer registered', async () => { + const { engine, driver } = await makeEngine(); + driver.seed('sys_recycle_bin', { id: 'e_gone', object_name: 'uninstalled_thing', record_id: 'x1', organization_id: 'org_1' }); + + await expect(engine.empty('uninstalled_thing', ORG1_USER)).resolves.toBe(1); + expect(entriesOf(driver)).toHaveLength(0); + }); + + it('removes nothing where the security layer refuses `purge`', async () => { + const { engine, driver } = await makeEngine(); + refuseNonSystemPurge(engine); + await seedEntries(engine, driver); + + await expect(engine.empty('loner', ORG1_USER)).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + expect(entriesOf(driver)).toHaveLength(5); + }); + + it('answers 0 when the composition registers no bin', async () => { + const { engine, dispatches } = await makeEngine({ withBin: false }); + await expect(engine.empty('loner', ORG1_USER)).resolves.toBe(0); + expect(dispatches).toEqual([]); + }); + + it.each([ + ['', 'object'], + ['sys_thing', 'object'], + ['cloud_anything', 'object'], + ])('refuses %j, 400', async (object, param) => { + const { engine, dispatches } = await makeEngine(); + await expect(engine.empty(object, SYSTEM)).rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400, param }); + expect(dispatches).toEqual([]); + }); +}); diff --git a/packages/objectql/src/recycle-bin/purge.ts b/packages/objectql/src/recycle-bin/purge.ts new file mode 100644 index 00000000000..d856be89217 --- /dev/null +++ b/packages/objectql/src/recycle-bin/purge.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30237, ADR-0148 D1 / D3] The recycle bin's removal side: the permanent + * delete, purging one entry, and emptying an object's entries. + * + * ## One verb: `purge` + * + * All three are dispatched through the middleware chain as the `purge` + * operation, on the BUSINESS object the rows belong to, so the one grant that + * authorizes the verb governs all three (`manageRecycleBin`, ADR-0148 D5, Q3 + * ruled A: only a recycle-bin manager deletes for good). Until + * `@objectstack/plugin-security` maps the verb to that grant, its fail-closed + * backstop for the destructive verbs (`DESTRUCTIVE_OPERATIONS`) refuses every + * caller that is not the system. A composition without the security plugin + * authorizes nothing, as for every other verb. + * + * - `ObjectQL.delete(object, { where: { id }, permanent: true })`: the by-id + * delete, with its hooks, cascade and events, that writes no entry. Its + * `where.id` is the live row's, so the security plugin's by-id pre-image + * check judges the row as it judges a delete ({@link readPermanentDelete}). + * - `ObjectQL.purge(entryId)`: the entry's `object_name` is the object; the + * dispatch carries no id ({@link purgeRecycleBinEntry}). + * - `ObjectQL.empty(object)`: every entry of one object, one dispatch, one + * authorization; the dispatch carries no id ({@link emptyRecycleBin}). + * + * ## Why `purge` never carries an AST (#7809) + * + * The security plugin's AST path hands `opCtx.operation` to the RLS compiler + * raw, and the compiler knows `select` / `insert` / `update` / `delete` only. + * So `purge` and `empty` dispatch with no `ast`, and the permanent delete is + * refused unless it is a by-id delete (a predicate delete carries its `where` + * as an AST). `engine-middleware-operation-vocabulary.test.ts` pins both. + * + * ## Why raw + * + * An entry is engine-owned bookkeeping, as at capture (`capture.ts`): it is + * read and removed through the bin's driver, with the caller's driver options, + * so the caller's organization scopes the read and the removal. + */ + +import { RECYCLE_BIN_OBJECT } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { OperationContext } from '../engine-top-level.js'; +import { recycleBinTakesObject } from './capture.js'; + +/** What {@link purgeRecycleBinEntry} and {@link emptyRecycleBin} read off the engine. */ +export interface RecycleBinRemovalHost { + readonly _registry: { getObject(name: string): unknown }; + getDriver(object: string): any; + buildDriverOptions(object: string, execCtx?: ExecutionContext): any; + executeWithMiddleware(ctx: OperationContext, executor: () => Promise): Promise; +} + +/** The options `ObjectQL.purge` and `ObjectQL.empty` take. */ +export interface RecycleBinRemovalOptions { + /** The caller; it scopes the entries to its organization and is authorized for `purge`. */ + context?: ExecutionContext; +} + +/** How many entry ids one page of {@link emptyRecycleBin}'s fallback reads. */ +const EMPTY_PAGE_SIZE = 500; + +/** A `400 VALIDATION_ERROR` refusal of a recycle-bin call's own shape. */ +function recycleBinCallRefusal(message: string, developerMessage: string, object: string | undefined, param: string): Error { + const err: any = new Error(message); + err.developerMessage = developerMessage; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.httpStatus = 400; + err.param = param; + if (object !== undefined) err.object = object; + return err; +} + +/** The refusal for a platform object, whose deletes the bin never takes. */ +function platformObjectRefusal(object: string, param: string, what: string): Error { + return recycleBinCallRefusal( + `'${object}' is a platform object: its deletes never enter the recycle bin, so ${what}.`, + `${what.charAt(0).toUpperCase()}${what.slice(1)}: the recycle bin takes no object in the platform namespaces ` + + `(sys_, cloud_, ai_), so '${object}' has no entries and its plain delete is already permanent.`, + object, + param, + ); +} + +/** + * Read a delete's `permanent` key: `true` when the delete is permanent. Throws + * `400 VALIDATION_ERROR` when the key is not a boolean, or is `true` on a delete + * that cannot be permanent: + * + * - a delete that is not by id, or that asks `multi: true`: the bin takes a + * by-id delete only, so there is nothing to skip, and a predicate delete + * carries an AST, which `purge` never does (#7809); + * - a platform object, which the bin never takes. + * + * Called before the operation context is built, so a refusal runs no + * middleware and no hook. + */ +export function readPermanentDelete( + object: string, + options: { permanent?: unknown; multi?: unknown } | undefined, + dispatchKind: 'by-id' | 'multi' | 'reject', +): boolean { + const permanent = options?.permanent; + if (permanent === undefined || permanent === false) return false; + if (permanent !== true) { + throw recycleBinCallRefusal( + `The delete's 'permanent' option must be true or false: got ${JSON.stringify(permanent)}.`, + `EngineDeleteOptions.permanent is a boolean; ${typeof permanent} given on a delete of '${object}'.`, + object, + 'permanent', + ); + } + if (dispatchKind !== 'by-id' || options?.multi === true) { + throw recycleBinCallRefusal( + `A permanent delete names one record by its id: 'permanent' is refused on a delete by filter.`, + `permanent: true is honoured on a by-id delete of '${object}' only (where: { id }, no multi: true). ` + + 'Only a by-id delete enters the recycle bin, so a delete by filter has no entry to skip; delete each record ' + + 'by its id to delete it for good.', + object, + 'permanent', + ); + } + if (!recycleBinTakesObject(object)) throw platformObjectRefusal(object, 'permanent', "'permanent' has nothing to skip"); + return true; +} + +/** + * The body of `ObjectQL.purge`: remove one recycle-bin entry, so its rows can + * no longer be restored. `true` when the entry was removed; `false` when no + * entry with that id is visible to the caller's organization, or the + * composition registers no bin. + * + * The entry is read first, raw and scoped to the caller's organization, for + * the object its rows belong to; the removal is then dispatched as `purge` on + * that object, and runs only if the chain lets it through. + */ +export async function purgeRecycleBinEntry( + this: RecycleBinRemovalHost, + entryId: string | number, + options?: RecycleBinRemovalOptions, +): Promise { + if (!((typeof entryId === 'string' && entryId !== '') || (typeof entryId === 'number' && Number.isFinite(entryId)))) { + throw recycleBinCallRefusal( + 'A purge names the recycle-bin entry by its id.', + `ObjectQL.purge(entryId) takes the entry id as a non-empty string or a number; got ${JSON.stringify(entryId)}.`, + undefined, + 'entryId', + ); + } + if (!this._registry.getObject(RECYCLE_BIN_OBJECT)) return false; + const context = options?.context; + const driver = this.getDriver(RECYCLE_BIN_OBJECT); + const rows = ((await driver.find( + RECYCLE_BIN_OBJECT, + { object: RECYCLE_BIN_OBJECT, where: { id: entryId }, fields: ['id', 'object_name'], limit: 1 }, + this.buildDriverOptions(RECYCLE_BIN_OBJECT, context), + )) ?? []) as Array>; + const entry = rows[0]; + if (entry?.id == null || typeof entry.object_name !== 'string' || entry.object_name === '') return false; + const opCtx: OperationContext = { object: entry.object_name, operation: 'purge', options: { context }, context }; + return (await this.executeWithMiddleware(opCtx, async () => { + const removed = await driver.delete(RECYCLE_BIN_OBJECT, entry.id as string | number, this.buildDriverOptions(RECYCLE_BIN_OBJECT, opCtx.context)); + return removed === true || (typeof removed === 'number' && removed > 0); + })) === true; +} + +/** + * The body of `ObjectQL.empty`: remove every recycle-bin entry of one object + * that the caller's organization holds. Answers how many were removed; `0` + * when the composition registers no bin. + * + * One dispatch of `purge` on `object`, so one authorization covers the whole + * act. `object` may name an object that is no longer registered (its package + * was uninstalled): its entries can still be emptied. A platform object is + * refused, since the bin never takes one. + */ +export async function emptyRecycleBin( + this: RecycleBinRemovalHost, + object: string, + options?: RecycleBinRemovalOptions, +): Promise { + if (typeof object !== 'string' || object === '') { + throw recycleBinCallRefusal( + 'Emptying the recycle bin names the object whose entries are removed.', + `ObjectQL.empty(object) takes the object name as a non-empty string; got ${JSON.stringify(object)}.`, + undefined, + 'object', + ); + } + if (!recycleBinTakesObject(object)) throw platformObjectRefusal(object, 'object', 'there is nothing to empty'); + if (!this._registry.getObject(RECYCLE_BIN_OBJECT)) return 0; + const context = options?.context; + const opCtx: OperationContext = { object, operation: 'purge', options: { context }, context }; + return await this.executeWithMiddleware(opCtx, async () => { + const driver = this.getDriver(RECYCLE_BIN_OBJECT); + const driverOptions = this.buildDriverOptions(RECYCLE_BIN_OBJECT, opCtx.context); + const where = { object_name: object }; + if (typeof driver.deleteMany === 'function') { + return (await driver.deleteMany(RECYCLE_BIN_OBJECT, { object: RECYCLE_BIN_OBJECT, where }, driverOptions)) as number; + } + // A driver with no `deleteMany`: remove the entries one by one, a page of + // ids at a time. A page that removes nothing ends the loop, so an entry the + // driver will not remove cannot hold it forever. + let removed = 0; + for (;;) { + const page = ((await driver.find( + RECYCLE_BIN_OBJECT, + { object: RECYCLE_BIN_OBJECT, where, fields: ['id'], limit: EMPTY_PAGE_SIZE }, + driverOptions, + )) ?? []) as Array>; + let removedHere = 0; + for (const row of page) { + if (row?.id == null) continue; + const answer = await driver.delete(RECYCLE_BIN_OBJECT, row.id as string | number, driverOptions); + if (answer === true || (typeof answer === 'number' && answer > 0)) removedHere += 1; + } + removed += removedHere; + if (page.length < EMPTY_PAGE_SIZE || removedHere === 0) return removed; + } + }); +} diff --git a/packages/objectql/src/write-epoch.test.ts b/packages/objectql/src/write-epoch.test.ts index 2b184c21342..0c0a07a0f1e 100644 --- a/packages/objectql/src/write-epoch.test.ts +++ b/packages/objectql/src/write-epoch.test.ts @@ -306,9 +306,10 @@ describe('[#11968] the counter contract (unit level)', () => { expect(epoch.listenerCount).toBe(0); }); - it('the counted operations are the three write verbs, and nothing else', () => { - expect([...WRITE_EPOCH_OPERATIONS].sort()).toEqual(['delete', 'insert', 'update']); - for (const op of ['insert', 'update', 'delete']) { + it('the counted operations are the four write verbs, and nothing else', () => { + // [#30237] `purge`: the permanent delete, and purging or emptying the recycle bin. + expect([...WRITE_EPOCH_OPERATIONS].sort()).toEqual(['delete', 'insert', 'purge', 'update']); + for (const op of ['insert', 'update', 'delete', 'purge']) { expect(isWriteEpochOperation(op)).toBe(true); } for (const op of ['find', 'findOne', 'count', 'aggregate', '', undefined, null, 7]) { diff --git a/packages/objectql/src/write-epoch.ts b/packages/objectql/src/write-epoch.ts index c40a7af090b..d77b7074d95 100644 --- a/packages/objectql/src/write-epoch.ts +++ b/packages/objectql/src/write-epoch.ts @@ -137,8 +137,11 @@ export class WriteEpoch implements WriteEpochLike { * ⚠️ Read operations are deliberately absent, and adding one would not be a * tightening: an epoch that moved on reads would retire every entry on every * request, which reads as "the cache never hits" rather than as a failure. + * + * `purge` [#30237] is a write: the permanent delete removes rows, and purging + * or emptying the recycle bin removes entries. */ -export const WRITE_EPOCH_OPERATIONS = new Set(['insert', 'update', 'delete']); +export const WRITE_EPOCH_OPERATIONS = new Set(['insert', 'update', 'delete', 'purge']); /** True when `operation` is a write the seam counts. */ export function isWriteEpochOperation(operation: unknown): boolean { From d8e95344d4ba2c182a3f6dd49eddeca9b2d056cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:48:26 +0000 Subject: [PATCH 3/5] fix(runtime,spec): app-authored bodies cannot pass permanent on a delete; the hook face omits it HookDeleteOptions omits permanent, and the drift pin lists it under omitted. The sandbox body API refuses a body's delete carrying permanent (any value but false) with 400 VALIDATION_ERROR, param permanent, before the delete runs, so a job body under its system envelope cannot purge: its deletes enter the recycle bin (ADR-0148 D3, maintainer ruling A). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibxhbtrhtRZ958iyyJp --- packages/runtime/src/sandbox/body-runner.ts | 89 ++++++++++++++++++++- packages/spec/src/data/hook-api.test.ts | 4 +- packages/spec/src/data/hook-api.ts | 17 +++- 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index 26867d3a279..0a33b238c2c 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -64,7 +64,7 @@ import { servedRecordTitle, type FormulaServingChannel, } from '@objectstack/objectql'; -import { refuseStoredMetadataBodyReads, refuseStoredMetadataBodyWrites } from '../stored-metadata-reader-seam.js'; +import { deriveThroughSeam, refuseStoredMetadataBodyReads, refuseStoredMetadataBodyWrites } from '../stored-metadata-reader-seam.js'; import { answerLandedWritesDespiteStaleSummary } from '../landed-write-answer.js'; import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; import { @@ -1161,12 +1161,93 @@ function buildEngineRepoFacade(ql: any, objectName: string, context?: any) { * the API is built from: the body runs on to its own result. An action's * host-built `api` already answers so (`buildActionApi`); the layer is * idempotent. + * + * [ADR-0148 D3, ruling A recorded in 6691358077] Above every layer, a body's + * `delete` that asks for `permanent` is refused + * ({@link refuseBodyPermanentDeletes}): app-authored code cannot purge, so its + * deletes enter the recycle bin, whatever envelope the body runs under. */ function buildSandboxApi(engineCtx: any, ql: any, errLabel: string) { - return refuseStoredMetadataBodyWrites( - refuseStoredMetadataBodyReads( - answerLandedWritesDespiteStaleSummary(buildSandboxApiSource(engineCtx, ql, errLabel), errLabel), + return refuseBodyPermanentDeletes( + refuseStoredMetadataBodyWrites( + refuseStoredMetadataBodyReads( + answerLandedWritesDespiteStaleSummary(buildSandboxApiSource(engineCtx, ql, errLabel), errLabel), + ), ), + errLabel, + ); +} + +/** Marks a scoped API whose permanent deletes are already refused for a body. */ +const BODY_PERMANENT_DELETES_REFUSED = Symbol.for('objectstack.runtime.bodyPermanentDeletesRefused'); + +/** + * The refusal of a body's `delete(…, { permanent })`: `400 VALIDATION_ERROR`, + * `param: 'permanent'`. The message names the rule, because a job that asked + * for a purge has to learn it got none: dropping the key would delete into the + * bin and answer success, which reads as the purge it asked for. + */ +function bodyPermanentDeleteRefusal(objectName: string, errLabel: string): Error { + const err: any = new Error( + `A ${errLabel} cannot delete a record permanently: app-authored code cannot purge; ` + + `its deletes enter the recycle bin. Remove 'permanent' from the delete of '${objectName}'.`, + ); + err.developerMessage = + `'permanent' was passed to ctx.api.object('${objectName}').delete() in a ${errLabel}. App-authored code ` + + `(hook, action and job bodies) cannot erase a record past the recycle bin, under any execution context, ` + + `a job's system envelope included; the permanent delete stays with a principal holding manageRecycleBin ` + + `(ADR-0148 D3).`; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + err.httpStatus = 400; + err.param = 'permanent'; + err.object = objectName; + return err; +} + +/** + * One repository as a body holds it: `delete(options)` whose `options` carry a + * `permanent` key with any value but `false` is refused before the underlying + * verb is called, so nothing is removed and no bin entry is skipped. `false` + * and an absent key are the ordinary delete, and go through unchanged. Every + * other member is handed through. + */ +function refuseRepositoryPermanentDelete(objectName: string, repo: unknown, errLabel: string): unknown { + if (repo === null || typeof repo !== 'object') return repo; + return new Proxy(repo as Record, { + get(target, prop) { + const value = Reflect.get(target, prop, target); + if (typeof value !== 'function') return value; + if (prop !== 'delete') return value.bind(target); + return async (...args: unknown[]) => { + const options = args[0]; + if (options !== null && typeof options === 'object') { + const permanent = (options as { permanent?: unknown }).permanent; + if (permanent !== undefined && permanent !== false) { + throw bodyPermanentDeleteRefusal(objectName, errLabel); + } + } + return value.apply(target, args); + }; + }, + }); +} + +/** + * [ADR-0148 D3, ruling A recorded in 6691358077] The scoped API a sandboxed + * BODY (a hook, action or job body) holds, with the permanent delete refused: + * app-authored code cannot purge a record, so its deletes always enter the + * recycle bin. A job body runs under a fresh `{ isSystem: true, tenantId }` + * envelope and the system is never refused the engine's `purge` verb, so the + * rule is held here, at the one place every body face gets its API, and not by + * the engine, whose `delete(…, { permanent: true })` stays open to every other + * caller. Every context the API derives (`object(name)`, `sudo()`, + * `withRunAs(...)`, a transaction's context) is refused the same way. + * Idempotent. + */ +function refuseBodyPermanentDeletes(api: T, errLabel: string): T { + return deriveThroughSeam(api, BODY_PERMANENT_DELETES_REFUSED, (name, repo) => + refuseRepositoryPermanentDelete(name, repo, errLabel), ); } diff --git a/packages/spec/src/data/hook-api.test.ts b/packages/spec/src/data/hook-api.test.ts index 139fe1eb857..85f014f6d0c 100644 --- a/packages/spec/src/data/hook-api.test.ts +++ b/packages/spec/src/data/hook-api.test.ts @@ -223,7 +223,9 @@ describe('HookApi — the published hook ctx.api face', () => { name: 'HookDeleteOptions / EngineDeleteOptionsSchema', schema: EngineDeleteOptionsSchema, kept: ['where', 'multi'], - omitted: ['context'], + // `permanent`: app-authored code cannot purge; its deletes enter the + // recycle bin (maintainer ruling A, recorded in 6691358077). + omitted: ['context', 'permanent'], }, ]; diff --git a/packages/spec/src/data/hook-api.ts b/packages/spec/src/data/hook-api.ts index f0f7e6bd86d..3ad6a62d325 100644 --- a/packages/spec/src/data/hook-api.ts +++ b/packages/spec/src/data/hook-api.ts @@ -78,6 +78,13 @@ * the schemas to carry their migration text; the engine rejects them by * quoting that text. They are not part of an authoring surface published * after their retirement. + * - `permanent` (on the delete bag) — app-authored code cannot erase a record + * past the recycle bin; its deletes always enter the bin, and the permanent + * delete stays with a principal holding `manageRecycleBin` (ADR-0148 D3; + * maintainer ruling A, recorded in 6691358077). A hook's elevated `api` + * (`Hook.runAs: 'system'`) would otherwise pass the system's purge through, + * so the key is off the published face, and the sandbox refuses a body's + * `permanent` at runtime rather than dropping it. * - `sudo()` — the #5945 exclusion STANDS. It is privilege escalation, no * document teaches it as first-hook vocabulary, and since #14010 the * declared way for a hook to run elevated is `Hook.runAs: 'system'`, which @@ -202,8 +209,14 @@ export type HookUpdateOptions = Omit WriteObservabilityOptions & HookDriverPassthroughOptions; -/** The options bag `ctx.api.object(n).delete()` accepts. */ -export type HookDeleteOptions = Omit & +/** + * The options bag `ctx.api.object(n).delete()` accepts. + * + * `EngineDeleteOptions` minus the injected `context` and minus `permanent`: + * a hook's delete always enters the recycle bin (ADR-0148 D3, ruling A in + * 6691358077), so the purge door is not on this face. + */ +export type HookDeleteOptions = Omit & HookDriverPassthroughOptions; /** From 9f765466ea1fe5a6705aa0d2fea04c55899f4e78 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:49:39 +0000 Subject: [PATCH 4/5] test(runtime): a hook, action or job body passing permanent on a delete is refused and removes nothing; false or absent deletes into the bin Changeset: @objectstack/runtime minor, and one sentence that hooks and app bodies cannot pass permanent. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibxhbtrhtRZ958iyyJp --- ...30237-recycle-bin-purge-empty-permanent.md | 2 + .../runtime/src/sandbox/body-runner.test.ts | 164 +++++++++++++++++- 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/.changeset/30237-recycle-bin-purge-empty-permanent.md b/.changeset/30237-recycle-bin-purge-empty-permanent.md index 13f3247a1de..6f34361794f 100644 --- a/.changeset/30237-recycle-bin-purge-empty-permanent.md +++ b/.changeset/30237-recycle-bin-purge-empty-permanent.md @@ -1,6 +1,7 @@ --- '@objectstack/objectql': minor '@objectstack/spec': minor +'@objectstack/runtime': minor --- feat(objectql,spec): purge and empty the recycle bin, and delete a record for good (ADR-0148): `ObjectQL.purge(entryId)`, `ObjectQL.empty(object)` and `delete(object, { where: { id }, permanent: true })`, all authorized as the `purge` operation @@ -12,6 +13,7 @@ Skills-docs: follow-up #30847 — `skills/objectstack-api/SKILL.md` :268, :417 t The recycle bin's removal side. All three acts are dispatched through the middleware chain as one new operation, `purge`, on the business object the rows belong to, so one grant will govern all three: `manageRecycleBin`, the recycle-bin manager's (ADR-0148 D5). **Until `@objectstack/plugin-security` maps `purge` to that grant, it refuses `purge` for every caller that is not the system**, the fail-closed rule it already applies to the destructive verbs. So in this release only the system, or a composition without the security plugin, can purge, empty or delete for good. Restoring, the two grants and the REST doors are not in this release. - **`@objectstack/spec`:** `EngineDeleteOptions` gains `permanent?: boolean`: delete for good, so the rows skip the recycle bin and cannot be restored. It is authorized as the `purge` operation, not `delete`, and is honoured on a by-id delete only. +- **Hooks and app bodies cannot pass `permanent`:** app-authored code cannot erase a record past the recycle bin; its deletes always enter the bin, and the permanent delete stays with a principal holding `manageRecycleBin`. `HookDeleteOptions` (the bag `ctx.api.object(n).delete()` takes) omits `permanent`, and `@objectstack/runtime`'s sandbox refuses a hook, action or job body's `delete` carrying `permanent` (any value but `false`) with `400 VALIDATION_ERROR` (`param: 'permanent'`) before anything is removed, whatever envelope the body runs under, a job's system envelope included. `permanent: false` and an absent key are the ordinary delete. - **`@objectstack/objectql`, the permanent delete:** `delete(object, { where: { id }, permanent: true })` runs as the by-id delete does (its `beforeDelete` / `afterDelete` hooks, which read `permanent` off `input.options`, its cascade, its events), writes no recycle-bin entry, and is dispatched as `purge` with the row's id. These are refused `400 VALIDATION_ERROR` (`param: 'permanent'`) before any middleware or hook runs: - `permanent: true` on a delete that is not by id, or that asks `multi: true`; - `permanent: true` on a platform object (`sys_*`, `cloud_*`, `ai_*`), whose delete never enters the bin and is already permanent; diff --git a/packages/runtime/src/sandbox/body-runner.test.ts b/packages/runtime/src/sandbox/body-runner.test.ts index 9eae4f3a0fa..ad54322a1a0 100644 --- a/packages/runtime/src/sandbox/body-runner.test.ts +++ b/packages/runtime/src/sandbox/body-runner.test.ts @@ -1,7 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './body-runner.js'; +import { ObjectQL, ScopedContext } from '@objectstack/objectql'; +import { RECYCLE_BIN_ENTRY_FIELDS } from '@objectstack/spec/data'; +import { hookBodyRunnerFactory, actionBodyRunnerFactory, jobBodyRunnerFactory } from './body-runner.js'; import { QuickJSScriptRunner } from './quickjs-runner.js'; describe('hookBodyRunnerFactory', () => { @@ -576,3 +578,163 @@ describe('actionBodyRunnerFactory', () => { }); }); }); + +// [ADR-0148 D3, maintainer ruling A recorded in 6691358077] App-authored code +// cannot purge: a body's delete always enters the recycle bin. A job body runs +// under a fresh `{ isSystem: true, tenantId }` envelope, and the system is never +// refused the engine's `purge` verb, so the rule is held by the body's API. +// Real `ObjectQL` over a row-keeping driver: "nothing removed, no entry skipped" +// is a reading of the rows, not of a stub's call log. +describe('a body cannot delete permanently: its deletes enter the recycle bin', () => { + type Row = Record; + const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000 }); + const WRITE_CAPS = ['api.read', 'api.write']; + + function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { + let st = stores.get(o); + if (!st) { st = new Map(); stores.set(o, st); } + return st; + }; + const matches = (row: Row, where: any) => + !where || Object.entries(where).every(([k, v]) => k.startsWith('$') || (row[k] ?? null) === (v ?? null)); + const driver: any = { + name: 'primary', version: '0.0.0', supports: {}, + rowsOf: (o: string) => Array.from(storeFor(o).values()), + seed(o: string, row: Row) { storeFor(o).set(String(row.id), { ...row }); }, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async syncSchema() {}, + async find(o: string, ast: any) { + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + return page.map((r) => ({ ...r })); + }, + async findOne(o: string, ast: any) { + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(o: string, data: Row) { + const id = (data.id as string) ?? `r_${storeFor(o).size + 1}`; + const row = JSON.parse(JSON.stringify({ ...data, id })); + storeFor(o).set(id, row); + return { ...row }; + }, + async update(o: string, id: string, data: Row) { + const up = { ...storeFor(o).get(String(id)), ...data, id }; + storeFor(o).set(String(id), up); + return { ...up }; + }, + async delete(o: string, id: string) { return storeFor(o).delete(String(id)); }, + async count(o: string, ast: any) { return (await driver.find(o, ast)).length; }, + }; + return driver; + } + + async function makeEngine() { + const engine = new ObjectQL(); + const driver = makeDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject({ + name: 'sys_recycle_bin', label: 'Recycle Bin Entry', managedBy: 'engine-owned', fields: { ...RECYCLE_BIN_ENTRY_FIELDS }, + } as any, '__test__'); + engine.registry.registerObject({ + name: 'pdb_ticket', label: 'Ticket', fields: { id: { name: 'id', type: 'text' }, name: { name: 'name', type: 'text' } }, + } as any, '__test__'); + const deletes: Array<{ id: unknown; operation: string }> = []; + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.object === 'pdb_ticket' && (opCtx.operation === 'delete' || opCtx.operation === 'purge')) { + deletes.push({ id: opCtx.options?.where?.id, operation: opCtx.operation }); + } + await next(); + }); + // One entry already in the bin, so "no entry lost" is a reading of a non-empty bin. + driver.seed('pdb_ticket', { id: 't0', name: 'old' }); + await engine.delete('pdb_ticket', { where: { id: 't0' }, context: { isSystem: true } }); + driver.seed('pdb_ticket', { id: 't1', name: 'live' }); + deletes.length = 0; + const entries = () => driver.rowsOf('sys_recycle_bin').map((e: Row) => e.record_id).sort(); + return { engine, driver, deletes, entries }; + } + + const jobRunning = (engine: unknown, source: string) => + jobBodyRunnerFactory(runner, { ql: engine, appId: 'pdb' })({ + name: 'nightly_purge', + body: { language: 'js', capabilities: WRITE_CAPS, source }, + })!; + + it('a job body (system envelope) passing `permanent: true` is refused 400 VALIDATION_ERROR naming the rule; nothing is removed', async () => { + const { engine, driver, deletes, entries } = await makeEngine(); + expect(entries()).toEqual(['t0']); + const outcome = await jobRunning(engine, ` + try { + await ctx.api.object('pdb_ticket').delete({ where: { id: 't1' }, permanent: true }); + return { outcome: 'completed', reason: 'purged' }; + } catch (e) { + return { outcome: 'degraded', reason: e.code + ' ' + e.status + ' ' + e.message }; + }`)({} as any); + expect(outcome?.outcome).toBe('degraded'); + expect(outcome?.reason).toMatch(/^VALIDATION_ERROR 400 /); + expect(outcome?.reason).toContain('app-authored code cannot purge; its deletes enter the recycle bin'); + expect(deletes).toEqual([]); + expect(driver.rowsOf('pdb_ticket').map((r: Row) => r.id)).toEqual(['t1']); + expect(entries()).toEqual(['t0']); + }); + + it('uncaught, the refusal fails the job run with the envelope on the host side (`param: permanent` stays host-side)', async () => { + const { engine, driver, entries } = await makeEngine(); + await expect(jobRunning(engine, ` + await ctx.api.object('pdb_ticket').delete({ where: { id: 't1' }, permanent: true }); + return { outcome: 'completed' };`)({} as any)).rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); + expect(driver.rowsOf('pdb_ticket').map((r: Row) => r.id)).toEqual(['t1']); + expect(entries()).toEqual(['t0']); + }); + + it('a hook body and an action body are refused the same way', async () => { + const { engine, driver, deletes, entries } = await makeEngine(); + const source = ` + try { await ctx.api.object('pdb_ticket').delete({ where: { id: 't1' }, permanent: true }); return { got: 'purged' }; } + catch (e) { return { got: e.code + ' ' + e.status }; }`; + const hook = hookBodyRunnerFactory(runner, { ql: engine, appId: 'pdb' })({ + name: 'purge_on_close', object: 'pdb_case', events: ['beforeUpdate'], + body: { language: 'js', capabilities: WRITE_CAPS, source }, + } as any)!; + const engineCtx: any = { object: 'pdb_case', event: 'beforeUpdate', input: {}, api: new ScopedContext({ isSystem: true } as any, engine as any) }; + await hook(engineCtx); + expect(engineCtx.input.got).toBe('VALIDATION_ERROR 400'); + const action = actionBodyRunnerFactory(runner, { ql: engine, appId: 'pdb' })({ + name: 'purge_ticket', object: 'pdb_ticket', type: 'script', + body: { language: 'js', capabilities: WRITE_CAPS, source }, + })!; + const value = await action({ params: {}, record: {}, executionContext: { isSystem: true } }); + expect(value).toEqual({ got: 'VALIDATION_ERROR 400' }); + expect(deletes).toEqual([]); + expect(driver.rowsOf('pdb_ticket').map((r: Row) => r.id)).toEqual(['t1']); + expect(entries()).toEqual(['t0']); + }); + + for (const [label, opts] of [ + ['`permanent: false`', `{ where: { id: 't1' }, permanent: false }`], + ['no `permanent` key', `{ where: { id: 't1' } }`], + ] as const) { + it(`a job body's delete with ${label} is the ordinary delete: the record enters the bin`, async () => { + const { engine, driver, deletes, entries } = await makeEngine(); + const outcome = await jobRunning(engine, ` + await ctx.api.object('pdb_ticket').delete(${opts}); + return { outcome: 'completed' };`)({} as any); + expect(outcome).toEqual({ outcome: 'completed' }); + expect(deletes).toEqual([{ id: 't1', operation: 'delete' }]); + expect(driver.rowsOf('pdb_ticket')).toEqual([]); + expect(entries()).toEqual(['t0', 't1']); + }); + } + + it('host code keeps the engine\'s permanent delete (Q3 = A): only app-authored bodies are refused', async () => { + const { engine, driver, deletes, entries } = await makeEngine(); + await engine.delete('pdb_ticket', { where: { id: 't1' }, permanent: true, context: { isSystem: true } }); + expect(deletes).toEqual([{ id: 't1', operation: 'purge' }]); + expect(driver.rowsOf('pdb_ticket')).toEqual([]); + expect(entries()).toEqual(['t0']); + }); +}); From 9cfc766723c1e79bd24fb924e14a57e3f227470b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 19:10:57 +0000 Subject: [PATCH 5/5] test(spec): a hook delete literal carrying permanent fails to compile; changeset headline names runtime Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibxhbtrhtRZ958iyyJp --- .../30237-recycle-bin-purge-empty-permanent.md | 2 +- packages/spec/src/data/hook-api.test.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.changeset/30237-recycle-bin-purge-empty-permanent.md b/.changeset/30237-recycle-bin-purge-empty-permanent.md index 6f34361794f..1081ac28b75 100644 --- a/.changeset/30237-recycle-bin-purge-empty-permanent.md +++ b/.changeset/30237-recycle-bin-purge-empty-permanent.md @@ -4,7 +4,7 @@ '@objectstack/runtime': minor --- -feat(objectql,spec): purge and empty the recycle bin, and delete a record for good (ADR-0148): `ObjectQL.purge(entryId)`, `ObjectQL.empty(object)` and `delete(object, { where: { id }, permanent: true })`, all authorized as the `purge` operation +feat(objectql,spec,runtime): purge and empty the recycle bin, and delete a record for good (ADR-0148): `ObjectQL.purge(entryId)`, `ObjectQL.empty(object)` and `delete(object, { where: { id }, permanent: true })`, all authorized as the `purge` operation; hooks and app-authored bodies cannot pass `permanent` Clause-②: yes (widening) Metadata-protocol: added diff --git a/packages/spec/src/data/hook-api.test.ts b/packages/spec/src/data/hook-api.test.ts index 85f014f6d0c..8ac9e099372 100644 --- a/packages/spec/src/data/hook-api.test.ts +++ b/packages/spec/src/data/hook-api.test.ts @@ -174,10 +174,18 @@ describe('HookApi — the published hook ctx.api face', () => { const wrong: HookDeleteOptions = { where: { id: 'a' }, // @ts-expect-error `returning` is an UPDATE option; the engine's delete - // set is `{ context, where, multi }` plus the pass-throughs. + // set is `{ context, where, multi, permanent }` plus the pass-throughs, + // and this face carries `{ where, multi }` of it. returning: true, }; - expect([ok, wrong].length).toBe(2); + const purge: HookDeleteOptions = { + where: { id: 'a' }, + // @ts-expect-error `permanent` is off this face: app-authored code + // cannot purge, its deletes enter the recycle bin (maintainer ruling A, + // recorded in 6691358077). + permanent: true, + }; + expect([ok, wrong, purge].length).toBe(3); }); });