From 6b269992d482d149ee1d06e11ca675e7384698c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 02:21:21 +0000 Subject: [PATCH 1/4] fix(showcase): lock a paid invoice's lines against adding, removing and moving Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FmarW2qJYp6PYJJz9cpvK5 --- .../24684-showcase-paid-invoice-line-lock.md | 11 + cj-38921 | 5 + .../areas/records-forms.json | 18 +- examples/app-showcase/src/data/hooks/index.ts | 117 +++++++++ .../src/data/objects/invoice.object.ts | 5 + .../test/paid-invoice-line-lock.test.ts | 234 ++++++++++++++++++ 6 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 .changeset/24684-showcase-paid-invoice-line-lock.md create mode 100644 cj-38921 create mode 100644 examples/app-showcase/test/paid-invoice-line-lock.test.ts diff --git a/.changeset/24684-showcase-paid-invoice-line-lock.md b/.changeset/24684-showcase-paid-invoice-line-lock.md new file mode 100644 index 00000000000..a2d2aa84d0b --- /dev/null +++ b/.changeset/24684-showcase-paid-invoice-line-lock.md @@ -0,0 +1,11 @@ +--- +'@objectstack/example-showcase': patch +--- + +fix(showcase): a paid invoice's lines can no longer be added, removed or moved, so its total stays what was paid + +Clause-②: no (narrowing) (the showcase refuses two writes it accepted; if the measurement shows the platform half would relax a refusal a faulting rule makes today, the dev stops and reports before building it, and the seat re-declares) + +`readonlyWhen: parent.status == 'paid'` on a line's `product`, `quantity` and `unit_price` locks EDITS of a line under a paid invoice, but it is insert-exempt by design and judges field values, not whether a line exists. So `POST /api/v1/data/showcase_invoice_line` under the paid INV-1003 answered `201`, a `DELETE` of its line `200`, and a `PATCH` moving a line's `invoice` onto or off it `200`, and the paid `total` moved every time. + +Three hooks on `showcase_invoice_line` (`src/data/hooks/index.ts`) now refuse each of those writes with a sentence (`400`): `showcase_guard_paid_invoice_line_add` (beforeInsert), `showcase_guard_paid_invoice_line_move` (beforeUpdate) and `showcase_guard_paid_invoice_line_remove` (beforeDelete). Each reads the header invoice under `runAs: 'system'`. A system write passes (the seed loads INV-1003's line under a header that is already paid), and so does the cascade delete of a deleted invoice's lines. A draft or sent invoice's lines add, edit, move and remove as before. diff --git a/cj-38921 b/cj-38921 new file mode 100644 index 00000000000..6a4b4945d66 --- /dev/null +++ b/cj-38921 @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 1793067182 better-auth.session_token HO6oxN6htVNKuwawVf9awTJElUwiBuYQ.2IqTNFJ93OYTDs7BwxYoZxNI8EL7hn%2FblM3bFPGS0r4%3D diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 0412d6fb5bf..5d3770aa68f 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -3467,7 +3467,7 @@ "title": "Object lifecycle hooks fire on the write path with the right timing, gate, async and error semantics — driven over /api/v1/data/*, oracled by the record effect and the log line", "since": "v15", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "mixed", "personas": [ @@ -3476,7 +3476,7 @@ "fixtures": { "app": "showcase", "requires": [ - "the five seeded showcase hooks (examples/app-showcase/src/data/hooks/index.ts, bound via defineStack({ hooks: allHooks })) — the four this item drives, plus showcase_guard_task_reopen (showcase_task, beforeUpdate, condition previous.done == true && record.done != true, body throws a user-facing sentence, onError:'abort', priority 60), the refusal fixture records-forms.script-action-hook-refusal-toast drives; showcase_normalize_task_title (showcase_task, events ['beforeInsert','beforeUpdate'], trims title, priority 50, onError:'abort'); showcase_stamp_inquiry_defaults (showcase_inquiry, beforeInsert, stamps status='new'/source='web', onError:'abort'); showcase_audit_task_completion (showcase_task, afterUpdate, condition previous.done!=true && record.done==true, async, retryPolicy {maxRetries:3,backoffMs:1000}, capabilities:['log'], onError:'log', priority 90); showcase_warn_over_budget (showcase_project, afterUpdate, condition record.spent!=null && record.budget!=null && record.spent>record.budget, async, capabilities:['log'], onError:'log')", + "the eight seeded showcase hooks (examples/app-showcase/src/data/hooks/index.ts, bound via defineStack({ hooks: allHooks })) — the four this item drives, plus showcase_guard_task_reopen (showcase_task, beforeUpdate, condition previous.done == true && record.done != true, body throws a user-facing sentence, onError:'abort', priority 60), the refusal fixture records-forms.script-action-hook-refusal-toast drives, plus the three paid-invoice line guards on showcase_invoice_line, which this item does not drive: showcase_guard_paid_invoice_line_add (beforeInsert), showcase_guard_paid_invoice_line_move (beforeUpdate, condition record.invoice != previous.invoice) and showcase_guard_paid_invoice_line_remove (beforeDelete), each reading the header invoice under runAs:'system' and throwing a user-facing sentence when it is paid (onError:'abort', priority 60; a system write and a cascade delete pass); showcase_normalize_task_title (showcase_task, events ['beforeInsert','beforeUpdate'], trims title, priority 50, onError:'abort'); showcase_stamp_inquiry_defaults (showcase_inquiry, beforeInsert, stamps status='new'/source='web', onError:'abort'); showcase_audit_task_completion (showcase_task, afterUpdate, condition previous.done!=true && record.done==true, async, retryPolicy {maxRetries:3,backoffMs:1000}, capabilities:['log'], onError:'log', priority 90); showcase_warn_over_budget (showcase_project, afterUpdate, condition record.spent!=null && record.budget!=null && record.spent>record.budget, async, capabilities:['log'], onError:'log')", "showcase_task (title required text + done boolean, defaultValue false), showcase_inquiry (status select whose 'new' option is default:true + source plain text with NO default), showcase_project (budget/spent currency + the spent_within_budget script rule that REJECTS spent > budget*1.2)", "seeded rows: not-done task 'Build homepage' (done:false) for the transition PATCH; already-done tasks 'Audit current IA' and 'App wireframes' (done:true) for the non-transition proof; projects 'Data Platform' (budget 600000 / spent 420000) and 'Website Relaunch' (budget 150000 / spent 60000)", "server log capture — the async audit/warn bodies call ctx.log.info/warn, routed to the engine logger (packages/runtime/src/sandbox/body-runner.ts log: engineCtx.logger); AND the ability to register a scratch hook (throwing / ordered) for the variants the four fixtures cannot observe. Run-proven recipe (QA run #24616): on a scratch app copy, put every scratch hook on showcase_inquiry behind a name-prefix condition (record.name.startsWith('qa-…')) so it touches only the probe rows; for the fail-closed probe, `record.name.startsWith('qa-fc') && int(record.name) > 0` compiles and faults only for a 'qa-fc…' row", @@ -3484,9 +3484,9 @@ ], "knownGaps": [ "afterInsert and afterDelete have NO fixture hook — the four seeded hooks cover beforeInsert, beforeUpdate and afterUpdate only. Exercise these two events with a scratch log hook or record the variant knownGap; do not fake coverage.", - "beforeDelete has NO seeded showcase hook — the abort-blocks-delete contract is pinned only by the dogfood hef_ref_guard fixture (packages/qa/dogfood/test/hook-error-format.dogfood.test.ts), a test-only stack, not the showcase app; drive beforeDelete via a scratch hook or cite that pin.", + "beforeDelete's one seeded showcase hook is showcase_guard_paid_invoice_line_remove, an abort fixture that refuses removing a line under a paid invoice; it logs nothing and this item does not drive it, so for this item the abort-blocks-delete contract is pinned by the dogfood hef_ref_guard fixture (packages/qa/dogfood/test/hook-error-format.dogfood.test.ts), a test-only stack, not the showcase app; drive beforeDelete via a scratch hook or cite that pin.", "priority ordering (lowest-first on the SAME object+event) is not fixture-covered: showcase_normalize_task_title (priority 50) and showcase_guard_task_reopen (priority 60) do share showcase_task/beforeUpdate, but neither logs and the guard fires only on a reopening write it then refuses, so their order is not observable from outside; ordering needs a scratch pair of logging hooks on one object+event with distinct priorities.", - "onError:'abort' ROLLBACK and onError:'log' SUPPRESSION are only observable with a hook that THROWS — the four hooks this item drives never throw (their trim/stamp/log bodies always succeed). The one seeded hook that throws, showcase_guard_task_reopen, is a beforeUpdate abort and does not stand in for this item's beforeInsert abort-rollback clause. The abort-rollback, log-tolerate and fail-closed-condition clauses each require a scratch throwing hook (or the cited dogfood abort pin).", + "onError:'abort' ROLLBACK and onError:'log' SUPPRESSION are only observable with a hook that THROWS — the four hooks this item drives never throw (their trim/stamp/log bodies always succeed). The seeded hooks that throw are showcase_guard_task_reopen (beforeUpdate) and the three paid-invoice line guards on showcase_invoice_line; they are refusal fixtures this item does not drive, and the abort-rollback clause's verify names a scratch throwing beforeInsert, so none stands in for it here. The abort-rollback, log-tolerate and fail-closed-condition clauses each require a scratch throwing hook (or the cited dogfood abort pin).", "the async audit/warn lines land AFTER the HTTP write resolves (fire-and-forget runs once the engine has moved on) — a log read taken before the async body executes shows no line; settle before judging any no-fire." ] }, @@ -3495,7 +3495,7 @@ "beforeUpdate — FIXTURE: showcase_normalize_task_title (trims title on every task update)", "afterUpdate — FIXTURE: showcase_audit_task_completion (task done-transition audit) and showcase_warn_over_budget (project over-budget warn)", "afterInsert — SCRATCH/knownGap: no showcase hook targets afterInsert; add a scratch afterInsert log hook or record knownGap", - "beforeDelete — SCRATCH/knownGap: no seeded showcase hook; the abort-blocks-delete shape is pinned only by the dogfood hef_ref_guard fixture (hook-error-format.dogfood.test.ts)", + "beforeDelete — SCRATCH/knownGap: the one seeded showcase hook (showcase_guard_paid_invoice_line_remove) is an abort fixture this item does not drive; the abort-blocks-delete shape is pinned by the dogfood hef_ref_guard fixture (hook-error-format.dogfood.test.ts)", "afterDelete — knownGap: no showcase hook and no dogfood pin; add a scratch afterDelete log hook to exercise", "multi-event targeting — FIXTURE: showcase_normalize_task_title lists events ['beforeInsert','beforeUpdate'] — ONE hook fires on two write kinds (hook-binder registers per-event × per-object)", "CEL condition — two-root transition semantics (#4770/#4784) — FIXTURE: showcase_audit_task_completion `previous.done != true && record.done == true`; since #4770 `record` is the record's STATE (stored ⊕ payload, total over declared fields), so `record.done == true` alone is true on every edit of an already-done row — `previous` is what makes 'just became done' expressible", @@ -3600,7 +3600,7 @@ "ref": "packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts (pins the beforeInsert stamp: status='new'/source='web' on an anonymous inquiry submit); packages/qa/dogfood/test/hook-error-format.dogfood.test.ts (pins the beforeDelete onError:'abort' throw → REST error body). Declarative-wrapper MECHANISMS are unit-pinned in packages/objectql (each file run at objectstack f89d29e382, 83 tests, 0 failed): hook-condition-previous-scope.test.ts (acceptance[3]'s two-root transition — 'fires on the update that FLIPS the field, not on later updates of a done record'), hook-condition-merged-record.test.ts (acceptance[5]'s stored ⊕ payload record — 'fires on a declared field the update payload does NOT carry'), hook-binder.test.ts (acceptance[7] — 'swallows errors when onError=log', 'runs async after-events fire-and-forget'; plus retry, timeout and async-ignored-on-before*) and hook-condition-fail-loud.test.ts (acceptance[8] — a HookConditionError that onError:'log' does not swallow, retryPolicy does not retry and an async after* hook still raises). They score the mechanism only: acceptance[2]–[5]'s LOG oracles, the scratch variants' API reads and acceptance[8]'s wire envelope stay the run's. The transition audit / over-budget warn / priority-ordering LOG oracles and the abort/log/priority SCRATCH variants are NOT yet dogfood-pinned — drive them by hand." }, "source": [ - "examples/app-showcase/src/data/hooks/index.ts#allHooks (the four fixture hooks this item drives, the showcase_guard_task_reopen refusal fixture beside them, + allHooks export; header comments spell out the two-root #4784 transition and the != null / not has() #4770 rationale verbatim)", + "examples/app-showcase/src/data/hooks/index.ts#allHooks (the four fixture hooks this item drives, the showcase_guard_task_reopen refusal fixture and the three showcase_guard_paid_invoice_line_* guards beside them, + allHooks export; header comments spell out the two-root #4784 transition and the != null / not has() #4770 rationale verbatim)", "packages/spec/src/data/hook.zod.ts#HookSchema (HookSchema + HookEvent enum beforeFind/afterFind/beforeInsert/afterInsert/beforeUpdate/afterUpdate/beforeDelete/afterDelete; defineHook; async 'after* only'; onError default 'abort'; empty-target refusal #4001)", "packages/objectql/src/hook-wrappers.ts#wrapDeclarativeHook (wrapDeclarativeHook wrapping order condition→async→retry→timeout→onError; pickRecordPayload #4770 record = stored ⊕ payload total over declared fields; pickPreviousPayload #4784 previous binding; HookConditionError #4775 fail-closed, raised OUTSIDE onError; fireAndForget = async && isAfterEvent)", "packages/objectql/src/hook-binder.ts#bindHooksToEngine (bindHooksToEngine: per-event × per-object engine.registerHook with priority; unresolved-body / empty-target skips)", @@ -3646,6 +3646,12 @@ "date": "2026-10-19", "change": "steps[0], negative[2], fixtures.requires[3], acceptance[8] (verify, evidence) and automated.ref corrected from wave anchor #24579 round 2 part 1 (6272032736) and QA runs #24598 / #24616 (ba4afcc2), re-read at objectstack f89d29e382. (1) The hook list route is GET /api/v1/meta/hook; /meta/types/hook answers 404. (2) acceptance[8]'s refusal answers 500 INTERNAL_ERROR with the class in the server log only; #24619 row (b) (open) tracks the coded refusal, so the clause states the shipped envelope and scores against #24619. (3) The ref named source files (hook-wrappers.ts, hook-binder.ts) no resolver can run; it now names four test files, each measured at base under os-verify-lock (83 passed), mapped to the clause whose mechanism it pins. (4) The run-proven scratch-hook recipe is recorded in requires[3]. Part 1's note on a `$select` in the steps does not hold: no step of this item spells `$select` (0 hits in records-forms.json)", "ref": "#24671" + }, + { + "revision": 7, + "date": "2026-10-20", + "change": "fixture roster only, no clause moved — the showcase gained three hooks, the paid-invoice line guards on showcase_invoice_line (beforeInsert, beforeUpdate, beforeDelete; each throws a user-facing refusal when the line's invoice is paid). Three stated facts they made false ('the five seeded showcase hooks', 'beforeDelete has NO seeded showcase hook', 'the one seeded hook that throws') now name them, and say why they do not close this item's beforeDelete variant or abort-rollback clause: this item does not drive them, and the clauses' verify steps name scratch hooks.", + "ref": "#24684" } ] }, diff --git a/examples/app-showcase/src/data/hooks/index.ts b/examples/app-showcase/src/data/hooks/index.ts index 21eeb6b6537..eeb196a2115 100644 --- a/examples/app-showcase/src/data/hooks/index.ts +++ b/examples/app-showcase/src/data/hooks/index.ts @@ -159,10 +159,127 @@ export const GuardTaskReopenHook = { description: 'Refuses reopening a finished task (done true to false) with a user-facing sentence.', }; +/** + * The sentences the paid-invoice line guards refuse with. Exported so the + * fixture's test quotes the same strings the hooks throw. + */ +export const PAID_INVOICE_LINE_ADD_REFUSAL = + 'This invoice is paid, so its lines are locked: a line cannot be added to it.'; +export const PAID_INVOICE_LINE_REMOVE_REFUSAL = + 'This invoice is paid, so its lines are locked: a line cannot be removed from it.'; +export const PAID_INVOICE_LINE_MOVE_REFUSAL = + 'A paid invoice\'s lines are locked: a line cannot be moved onto or off a paid invoice.'; + +/** + * The shared prelude of the three guards: `paid(id)` reads the header's status. + * + * Two kinds of write pass every guard: + * - a SYSTEM write (`ctx.session.isSystem`): the seed loads INV-1003's line + * under a header that is already paid, and a migration or import restores + * history. The platform's own read-only strips exempt system writes the + * same way; + * - a header that cannot be read: the engine's reference check answers a + * dangling `invoice` with its own refusal, so the guard does not invent one. + */ +const PAID_HEADER_PRELUDE = + "if (ctx.session && ctx.session.isSystem) return;" + + "var paid = async function (id) {" + + " if (!id) return false;" + + " var header = await ctx.api.object('showcase_invoice').findOne({ where: { id: id } });" + + " return !!header && header.status === 'paid';" + + "};"; + +/** + * beforeInsert / beforeUpdate / beforeDelete — a PAID invoice's lines are + * locked against adding, moving and removing. + * + * `readonlyWhen: parent.status == 'paid'` on the line's `product`, `quantity` + * and `unit_price` (`invoice.object.ts`) locks EDITS of an existing line. It is + * insert-exempt by design, and it judges field values, not a line's existence, + * so it cannot stop a line from being added, removed, or repointed at another + * invoice. Each of those moves the paid `total`. A `validations[]` rule cannot + * hold these either: no `validations[]` rule binds `parent`, and none runs on + * delete. So the three guards are hooks, the mechanism + * `ValidationRuleSchema.events` prescribes for that case. + * + * Each guard reads the header itself, under `runAs: 'system'`. The verdict is + * the system's, and it must not depend on the writer's read grants on + * invoices. + */ +export const GuardPaidInvoiceLineAddHook = { + name: 'showcase_guard_paid_invoice_line_add', + label: 'Guard Paid Invoice Line Add', + object: 'showcase_invoice_line', + events: ['beforeInsert'] as LifecycleEvent[], + body: { + language: 'js' as const, + source: + PAID_HEADER_PRELUDE + + `if (await paid(ctx.input.invoice)) throw new Error(${JSON.stringify(PAID_INVOICE_LINE_ADD_REFUSAL)});`, + capabilities: ['api.read'] as ('api.read')[], + }, + runAs: 'system' as const, + priority: 60, + onError: 'abort' as const, + description: 'Refuses adding a line to a paid invoice, with a user-facing sentence.', +}; + +/** + * The repoint half: an update that moves a line's `invoice` onto or off a paid + * header. The condition gates the hook to that write, so an ordinary line + * edit pays no header read. + */ +export const GuardPaidInvoiceLineMoveHook = { + name: 'showcase_guard_paid_invoice_line_move', + label: 'Guard Paid Invoice Line Move', + object: 'showcase_invoice_line', + events: ['beforeUpdate'] as LifecycleEvent[], + condition: 'record.invoice != previous.invoice', + body: { + language: 'js' as const, + source: + PAID_HEADER_PRELUDE + + "var before = ctx.previous ? ctx.previous.invoice : undefined;" + + `if ((await paid(before)) || (await paid(ctx.input.invoice))) throw new Error(${JSON.stringify(PAID_INVOICE_LINE_MOVE_REFUSAL)});`, + capabilities: ['api.read'] as ('api.read')[], + }, + runAs: 'system' as const, + priority: 60, + onError: 'abort' as const, + description: 'Refuses moving a line onto or off a paid invoice, with a user-facing sentence.', +}; + +/** + * The remove half. A cascade delete (`ctx.referentialCascadeDelete`) passes: + * that is the header itself being deleted, not a line removed from a header + * that stays. + */ +export const GuardPaidInvoiceLineRemoveHook = { + name: 'showcase_guard_paid_invoice_line_remove', + label: 'Guard Paid Invoice Line Remove', + object: 'showcase_invoice_line', + events: ['beforeDelete'] as LifecycleEvent[], + body: { + language: 'js' as const, + source: + PAID_HEADER_PRELUDE + + "if (ctx.referentialCascadeDelete === true || !ctx.previous) return;" + + `if (await paid(ctx.previous.invoice)) throw new Error(${JSON.stringify(PAID_INVOICE_LINE_REMOVE_REFUSAL)});`, + capabilities: ['api.read'] as ('api.read')[], + }, + runAs: 'system' as const, + priority: 60, + onError: 'abort' as const, + description: 'Refuses removing a line from a paid invoice, with a user-facing sentence.', +}; + export const allHooks = [ NormalizeTaskTitleHook, StampInquiryDefaultsHook, GuardTaskReopenHook, + GuardPaidInvoiceLineAddHook, + GuardPaidInvoiceLineMoveHook, + GuardPaidInvoiceLineRemoveHook, AuditTaskCompletionHook, WarnOverBudgetHook, ]; diff --git a/examples/app-showcase/src/data/objects/invoice.object.ts b/examples/app-showcase/src/data/objects/invoice.object.ts index c89347b7cff..b37c69a1c4f 100644 --- a/examples/app-showcase/src/data/objects/invoice.object.ts +++ b/examples/app-showcase/src/data/objects/invoice.object.ts @@ -264,6 +264,11 @@ export const InvoiceLine = ObjectSchema.create({ // Paid, its lines are frozen. `readonlyWhen` here references the header as // `parent`, so the inline grid evaluates it per row against the live invoice // record and locks the cell — the "paid invoice → lock lines" case (#1581). + // It locks EDITS of a line, on the server too (the write drops the change). + // It is insert-exempt by design and judges field values, not whether a line + // exists, so adding, removing or moving a line under a paid header is locked + // elsewhere: by the `showcase_guard_paid_invoice_line_*` hooks + // (`src/data/hooks/index.ts`), which refuse each with a sentence. product: Field.lookup('showcase_product', { label: 'Product', required: true, diff --git a/examples/app-showcase/test/paid-invoice-line-lock.test.ts b/examples/app-showcase/test/paid-invoice-line-lock.test.ts new file mode 100644 index 00000000000..db43677304f --- /dev/null +++ b/examples/app-showcase/test/paid-invoice-line-lock.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A PAID invoice's lines are locked: a line cannot be added to it, removed + * from it, or moved onto or off it, and its `total` does not move. + * + * `readonlyWhen: parent.status == 'paid'` on the line's `product`, `quantity` + * and `unit_price` locks EDITS of a line (#4889). It is insert-exempt by + * design and judges field values, so measured on a booted showcase a new line + * under the paid INV-1003 answered `201`, a delete of its line `200`, and a + * repoint of a line onto or off it `200`, and the paid `total` moved every + * time. The three guards that close those doors are the app's own hooks + * (`src/data/hooks/index.ts`); this file pins them on the production harness + * `task-reopen-hook-refusal.test.ts` uses: real `ObjectQL`, real `SqlDriver` + * (better-sqlite3), real `QuickJSScriptRunner` behind `hookBodyRunnerFactory`, + * and the app's real objects and hooks. + * + * Every refusal is read twice: the error is the hook's business refusal (a + * `SandboxError` whose `innerMessage` is the sentence, which the `/data` door + * serves as `400` with that sentence), and the store is unchanged — the line + * count and the invoice's rolled-up `total`. The draft control proves the + * guards are about the header's status, not about lines in general. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { QuickJSScriptRunner, SandboxError, hookBodyRunnerFactory } from '@objectstack/runtime'; + +import { Account, Product, Invoice, InvoiceLine } from '../src/data/objects/index.js'; +import { + allHooks, + PAID_INVOICE_LINE_ADD_REFUSAL, + PAID_INVOICE_LINE_REMOVE_REFUSAL, + PAID_INVOICE_LINE_MOVE_REFUSAL, +} from '../src/data/hooks/index.js'; + +const APP_ID = 'com.objectstack.showcase'; +const PACKAGE_ID = `app:${APP_ID}`; + +const openEngines: ObjectQL[] = []; +afterEach(async () => { + while (openEngines.length) { + try { await openEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function bootShowcase(hooks: unknown[] = allHooks): Promise { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.connect(); + + const engine = new ObjectQL(); + openEngines.push(engine); + engine.registerDriver(driver as never, true); + await engine.init(); + for (const def of [Account, Product, Invoice, InvoiceLine]) { + engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase'); + } + await engine.syncSchemas(); + engine.bindHooks(hooks as never[], { + packageId: PACKAGE_ID, + bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: APP_ID }), + }); + return engine; +} + +/** The fixture loader's identity: a system write, which every guard lets through (the seed's own case). */ +const SYSTEM = { context: { userId: 'u_seed', isSystem: true } }; +/** An ordinary user's write: the identity the guards judge. */ +const USER = { context: { userId: 'u_showcase' } }; + +interface Fixture { + engine: ObjectQL; + paid: string; + draft: string; + product: string; + paidLine: string; + draftLine: string; +} + +/** + * One paid and one draft invoice, each with one line. Lines go in while the + * paid header is still a draft, then the header is marked paid, the order a + * real invoice takes — every fixture write is a system write. + */ +async function invoicesWithLines(hooks?: unknown[]): Promise { + const engine = await bootShowcase(hooks); + const account: any = await engine.insert('showcase_account', { name: 'Contoso', status: 'active' }, SYSTEM as never); + const product: any = await engine.insert('showcase_product', { name: 'Widget B', unit_price: 49.99 }, SYSTEM as never); + const header = async (name: string) => + String(((await engine.insert('showcase_invoice', { name, account: String(account.id), status: 'draft' }, SYSTEM as never)) as any).id); + const paid = await header('INV-1003'); + const draft = await header('INV-1004'); + const line = async (invoice: string) => + String(((await engine.insert( + 'showcase_invoice_line', + { invoice, product: String(product.id), quantity: 6, unit_price: 49.99, amount: 299.94 }, + SYSTEM as never, + )) as any).id); + const paidLine = await line(paid); + const draftLine = await line(draft); + await engine.update( + 'showcase_invoice', + { id: paid, status: 'paid', issued_on: '2026-09-20', paid_on: '2026-10-18' }, + SYSTEM as never, + ); + return { engine, paid, draft, product: String(product.id), paidLine, draftLine }; +} + +/** What a header holds: its status, its rolled-up `total`, and how many lines hang off it. */ +async function headerState(engine: ObjectQL, invoice: string) { + const row: any = (await engine.find('showcase_invoice', { where: { id: invoice } }, SYSTEM as never))[0]; + const lines = await engine.find('showcase_invoice_line', { where: { invoice } }, SYSTEM as never); + return { status: row?.status, total: Number(row?.total), lines: lines.length }; +} + +/** Run a write and hand back what it threw (null if it resolved). */ +const refusalOf = (write: Promise) => write.then(() => null, (e: unknown) => e); + +/** The hook's business refusal: the sentence rides `innerMessage`, the field the `/data` door serves. */ +function expectBusinessRefusal(err: unknown, sentence: string): void { + expect(err, 'expected the write to be refused, but it resolved').not.toBeNull(); + expect(err).toBeInstanceOf(SandboxError); + expect((err as SandboxError).innerMessage).toBe(sentence); +} + +const newLine = (f: Fixture, invoice: string) => ({ + invoice, product: f.product, quantity: 2, unit_price: 10, amount: 20, +}); + +describe('a paid invoice\'s lines are locked against adding, removing and moving', () => { + it('the fixture rolls the lines up: each header totals its one line', async () => { + const f = await invoicesWithLines(); + expect(await headerState(f.engine, f.paid)).toEqual({ status: 'paid', total: 299.94, lines: 1 }); + expect(await headerState(f.engine, f.draft)).toEqual({ status: 'draft', total: 299.94, lines: 1 }); + }, 30000); + + it('a new line under a paid header is refused, and the total does not move', async () => { + const f = await invoicesWithLines(); + const err = await refusalOf(f.engine.insert('showcase_invoice_line', newLine(f, f.paid), USER as never)); + expectBusinessRefusal(err, PAID_INVOICE_LINE_ADD_REFUSAL); + expect(await headerState(f.engine, f.paid)).toEqual({ status: 'paid', total: 299.94, lines: 1 }); + }, 30000); + + it('removing a line from a paid header is refused, and the total does not move', async () => { + const f = await invoicesWithLines(); + const err = await refusalOf(f.engine.delete('showcase_invoice_line', { where: { id: f.paidLine }, context: USER.context } as never)); + expectBusinessRefusal(err, PAID_INVOICE_LINE_REMOVE_REFUSAL); + expect(await headerState(f.engine, f.paid)).toEqual({ status: 'paid', total: 299.94, lines: 1 }); + }, 30000); + + it('a bulk delete reaching a paid header\'s line is refused too', async () => { + // The predicate path dispatches `beforeDelete` per row with its pre-image, + // so the guard is not a by-id-only door. + const f = await invoicesWithLines(); + const err = await refusalOf( + f.engine.delete('showcase_invoice_line', { where: { invoice: f.paid }, multi: true, context: USER.context } as never), + ); + expectBusinessRefusal(err, PAID_INVOICE_LINE_REMOVE_REFUSAL); + expect(await headerState(f.engine, f.paid)).toEqual({ status: 'paid', total: 299.94, lines: 1 }); + }, 30000); + + it('moving a line ONTO a paid header is refused; both totals stay', async () => { + const f = await invoicesWithLines(); + const err = await refusalOf(f.engine.update('showcase_invoice_line', { id: f.draftLine, invoice: f.paid }, USER as never)); + expectBusinessRefusal(err, PAID_INVOICE_LINE_MOVE_REFUSAL); + expect(await headerState(f.engine, f.paid)).toEqual({ status: 'paid', total: 299.94, lines: 1 }); + expect(await headerState(f.engine, f.draft)).toEqual({ status: 'draft', total: 299.94, lines: 1 }); + }, 30000); + + it('moving a line OFF a paid header is refused; both totals stay', async () => { + const f = await invoicesWithLines(); + const err = await refusalOf(f.engine.update('showcase_invoice_line', { id: f.paidLine, invoice: f.draft }, USER as never)); + expectBusinessRefusal(err, PAID_INVOICE_LINE_MOVE_REFUSAL); + expect(await headerState(f.engine, f.paid)).toEqual({ status: 'paid', total: 299.94, lines: 1 }); + expect(await headerState(f.engine, f.draft)).toEqual({ status: 'draft', total: 299.94, lines: 1 }); + }, 30000); + + it('deleting the paid header itself still cascades its lines (the cascade is not a line removal)', async () => { + const f = await invoicesWithLines(); + await f.engine.delete('showcase_invoice', { where: { id: f.paid }, context: USER.context } as never); + const lines = await f.engine.find('showcase_invoice_line', { where: { invoice: f.paid } }, SYSTEM as never); + expect(lines).toHaveLength(0); + }, 30000); +}); + +describe('the draft control: a draft header\'s lines add, edit, move and remove as before', () => { + it('a new line under a draft header lands and rolls up', async () => { + const f = await invoicesWithLines(); + await f.engine.insert('showcase_invoice_line', newLine(f, f.draft), USER as never); + expect(await headerState(f.engine, f.draft)).toEqual({ status: 'draft', total: 319.94, lines: 2 }); + }, 30000); + + it('removing a line from a draft header lands and rolls up', async () => { + const f = await invoicesWithLines(); + await f.engine.delete('showcase_invoice_line', { where: { id: f.draftLine }, context: USER.context } as never); + expect(await headerState(f.engine, f.draft)).toEqual({ status: 'draft', total: 0, lines: 0 }); + }, 30000); + + it('moving a line between two draft headers lands', async () => { + const f = await invoicesWithLines(); + const other = String(((await f.engine.insert( + 'showcase_invoice', + { name: 'INV-1002', account: String(((await f.engine.find('showcase_account', {}, SYSTEM as never))[0] as any).id), status: 'draft' }, + SYSTEM as never, + )) as any).id); + await f.engine.update('showcase_invoice_line', { id: f.draftLine, invoice: other }, USER as never); + expect((await headerState(f.engine, other)).lines).toBe(1); + expect((await headerState(f.engine, f.draft)).lines).toBe(0); + }, 30000); + + it('an edit of a line under a draft header that leaves `invoice` alone lands', async () => { + const f = await invoicesWithLines(); + await f.engine.update('showcase_invoice_line', { id: f.draftLine, description: 'Widget B units' }, USER as never); + const row: any = (await f.engine.find('showcase_invoice_line', { where: { id: f.draftLine } }, SYSTEM as never))[0]; + expect(row.description).toBe('Widget B units'); + }, 30000); +}); + +describe('REVERSE: with the hooks unbound the same writes land — the lock is the hooks', () => { + it('a line is added to, moved onto and removed from a paid header, and its total moves', async () => { + const f = await invoicesWithLines([]); + await f.engine.insert('showcase_invoice_line', newLine(f, f.paid), USER as never); + expect(await headerState(f.engine, f.paid)).toEqual({ status: 'paid', total: 319.94, lines: 2 }); + await f.engine.update('showcase_invoice_line', { id: f.draftLine, invoice: f.paid }, USER as never); + expect((await headerState(f.engine, f.paid)).lines).toBe(3); + await f.engine.delete('showcase_invoice_line', { where: { id: f.paidLine }, context: USER.context } as never); + expect((await headerState(f.engine, f.paid)).lines).toBe(2); + }, 30000); +}); From f853d59ae79643f9179807b7f2b57645105343fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 02:21:58 +0000 Subject: [PATCH 2/4] fix(showcase): record the ADR-0087 disposition of the paid-invoice line lock Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FmarW2qJYp6PYJJz9cpvK5 --- .changeset/24684-showcase-paid-invoice-line-lock.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/24684-showcase-paid-invoice-line-lock.md b/.changeset/24684-showcase-paid-invoice-line-lock.md index a2d2aa84d0b..49f01fe842f 100644 --- a/.changeset/24684-showcase-paid-invoice-line-lock.md +++ b/.changeset/24684-showcase-paid-invoice-line-lock.md @@ -6,6 +6,8 @@ fix(showcase): a paid invoice's lines can no longer be added, removed or moved, Clause-②: no (narrowing) (the showcase refuses two writes it accepted; if the measurement shows the platform half would relax a refusal a faulting rule makes today, the dev stops and reports before building it, and the seat re-declares) + + `readonlyWhen: parent.status == 'paid'` on a line's `product`, `quantity` and `unit_price` locks EDITS of a line under a paid invoice, but it is insert-exempt by design and judges field values, not whether a line exists. So `POST /api/v1/data/showcase_invoice_line` under the paid INV-1003 answered `201`, a `DELETE` of its line `200`, and a `PATCH` moving a line's `invoice` onto or off it `200`, and the paid `total` moved every time. Three hooks on `showcase_invoice_line` (`src/data/hooks/index.ts`) now refuse each of those writes with a sentence (`400`): `showcase_guard_paid_invoice_line_add` (beforeInsert), `showcase_guard_paid_invoice_line_move` (beforeUpdate) and `showcase_guard_paid_invoice_line_remove` (beforeDelete). Each reads the header invoice under `runAs: 'system'`. A system write passes (the seed loads INV-1003's line under a header that is already paid), and so does the cascade delete of a deleted invoice's lines. A draft or sent invoice's lines add, edit, move and remove as before. From 0aa9da1b313424b3bc667a00ccbaa08e9b93cd79 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 02:32:08 +0000 Subject: [PATCH 3/4] chore(showcase): drop a scratch curl cookie jar committed by mistake Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FmarW2qJYp6PYJJz9cpvK5 --- cj-38921 | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 cj-38921 diff --git a/cj-38921 b/cj-38921 deleted file mode 100644 index 6a4b4945d66..00000000000 --- a/cj-38921 +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / FALSE 1793067182 better-auth.session_token HO6oxN6htVNKuwawVf9awTJElUwiBuYQ.2IqTNFJ93OYTDs7BwxYoZxNI8EL7hn%2FblM3bFPGS0r4%3D From aaec01614444f74cc73b74ae6627b39ba079d907 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 03:49:02 +0000 Subject: [PATCH 4/4] docs(showcase): the hook comments say four hooks refuse, and which system writers pass the paid-line guards Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FmarW2qJYp6PYJJz9cpvK5 --- examples/app-showcase/src/data/hooks/index.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/app-showcase/src/data/hooks/index.ts b/examples/app-showcase/src/data/hooks/index.ts index eeb196a2115..05302b524ee 100644 --- a/examples/app-showcase/src/data/hooks/index.ts +++ b/examples/app-showcase/src/data/hooks/index.ts @@ -127,8 +127,9 @@ export const TASK_REOPEN_REFUSAL = 'A finished task cannot be reopened. Create a follow-up task instead.'; /** - * beforeUpdate (gated) — the showcase's one hook that REFUSES a write, with a - * sentence addressed to the user. + * beforeUpdate (gated) — the showcase's first hook that REFUSES a write, with a + * sentence addressed to the user. (The three paid-invoice line guards below + * refuse the same way.) * * `throw new Error('')` from a sandboxed body is the business-refusal * shape: the write is aborted (`onError: 'abort'`) and the sentence travels to @@ -176,8 +177,10 @@ export const PAID_INVOICE_LINE_MOVE_REFUSAL = * Two kinds of write pass every guard: * - a SYSTEM write (`ctx.session.isSystem`): the seed loads INV-1003's line * under a header that is already paid, and a migration or import restores - * history. The platform's own read-only strips exempt system writes the - * same way; + * history. Every system writer passes by design — the seed, a script + * action's `ctx.api`, a `runAs: 'system'` flow — and none writes + * `showcase_invoice_line` in the showcase today. The platform's static + * `readonly` strip exempts system writes the same way; * - a header that cannot be read: the engine's reference check answers a * dangling `invoice` with its own refusal, so the guard does not invent one. */