Skip to content

Commit 576d0f3

Browse files
fix(showcase): a paid invoice's lines can no longer be added, removed or moved, so its total stays what was paid (#24731)
Fixes #24684 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) Built under the seat ruling 6281609466 on #24684 (route C, so B now), after the dev's `needs_decision` 6281203874. No `objectql`, lint or spec change: the lint trap measured on the way is #24720. ## What changes A paid invoice's lines are now locked against being **added, removed and moved**, not only edited, so its `total` stays what was paid. - `readonlyWhen: parent.status == 'paid'` on a line's `product`, `quantity` and `unit_price` still locks **edits**. It is insert-exempt by design (#4889) and judges field values, not whether a line exists. - Three lifecycle hooks on `showcase_invoice_line` (`examples/app-showcase/src/data/hooks/index.ts`) hold the rest. Each reads the header invoice under `runAs: 'system'` and refuses with a sentence: - `showcase_guard_paid_invoice_line_add` (beforeInsert): a new line under a paid invoice. - `showcase_guard_paid_invoice_line_move` (beforeUpdate, gated by `record.invoice != previous.invoice`): a line moved onto or off a paid invoice. The repoint was measured first and is admitted on `main` (below), so the ruling's guard is owed. - `showcase_guard_paid_invoice_line_remove` (beforeDelete): a line removed from a paid invoice, by id or by a bulk predicate. - Two kinds of write pass every guard: - a **system write** (`session.isSystem`), because the seed loads INV-1003's line under a header that is already paid. The platform's static `readonly` strip exempts system writes the same way (`readonlyWhen` exempts no one). The exemption is every system writer, not only the seed: a script action's `ctx.api` and a `runAs: 'system'` flow pass too. None of them writes `showcase_invoice_line` in the showcase today. - a **cascade delete** (`ctx.referentialCascadeDelete`), because that is the header itself being deleted, not a line removed from a header that stays. - `invoice.object.ts`'s `product` comment now says where each lock lives. The `amount` comment PR #24673 edited is untouched; that PR landed first and is merged in. - `docs/qa/platform-checklist/areas/records-forms.json` `records-forms.object-hook-lifecycle`, rev 7: fixture roster only, no clause moved. Three stated facts the new hooks made false ("the five seeded showcase hooks", "beforeDelete has NO seeded showcase hook", "the one seeded hook that throws") now name them. This file is outside the claim's file surface; it is here because the change made its text false. Why hooks, not a `validations[]` rule: measured on `main`, no `validations[]` rule binds `parent` on any write. A rule reading `parent.status` faulted `Unknown variable: parent` and refused every insert and update, the draft header's and the seed's included. And `validations[]` never runs on delete. `ValidationRuleSchema.events` prescribes lifecycle hooks for that case. ## Verification record **Measured on a booted showcase (`pnpm dev -- --fresh -p PORT`, seeded admin).** Before, at `main` 8e14ce0 and 54159fd: | write | under paid INV-1003 | total | |---|---|---| | POST a line | 201 | 299.94 to 306.94 | | DELETE its line | 200 | 306.94 to 7 | | PATCH a draft line's `invoice` to INV-1003 | 200 | 299.94 to 749.94 | | PATCH INV-1003's line `invoice` to a draft | 200 | 749.94 to 450 | After, at this head `0aa9da1b3`: | write | answer | total | |---|---|---| | POST a line under INV-1003 | 400, `error`: "This invoice is paid, so its lines are locked: a line cannot be added to it." | 299.94, unchanged | | DELETE INV-1003's line | 400, "...a line cannot be removed from it." | 299.94, unchanged | | PATCH a line onto INV-1003 | 400, "A paid invoice's lines are locked: a line cannot be moved onto or off a paid invoice." | both totals unchanged | | PATCH INV-1003's line off it | 400, the same sentence | both totals unchanged | | PATCH `quantity` on INV-1003's line | 200, dropped by `readonlyWhen` as before | 299.94, unchanged | | draft INV-1004: POST, PATCH, DELETE | 201, 200, 200 | rolls up as before | The boot logs no ERROR line: the seed's paid line still loads. **Pins.** `examples/app-showcase/test/paid-invoice-line-lock.test.ts`, 12 tests, on the production harness the reopen-guard pin uses (real `ObjectQL`, `SqlDriver`, `QuickJSScriptRunner`, the app's real objects and hooks). - Each refusal asserts a `SandboxError` whose `innerMessage` is the exact sentence (the field the `/data` door serves as the 400 above), plus the invoice's line count and `total` read back unchanged. - Covered: insert, by-id delete, bulk delete, move onto and move off a paid header, and a paid header's own cascade delete. - The draft control covers insert, delete, a move between drafts and a plain edit. - A REVERSE case shows that with the hooks unbound the same writes land. **Ablation.** `node scripts/ablation-replace.mjs` deleted the three hooks from `allHooks` (anchor 1 to 0, blob `eeb196a2115d` to `fb951071b9d5`). - Mutated: 5 failed, the five paid-header pins; 7 passed, the draft controls, the fixture, the cascade and the REVERSE case. - Restored: blob `eeb196a2115d`, equal to `HEAD`, and `git diff HEAD` empty. **Local runs.** - After merging `origin/main` d20bf9b: rebuilt with `turbo run build --filter=!@objectstack/docs --concurrency=2`, 76/76. - `pnpm --filter @objectstack/example-showcase typecheck`: exit 0. `tsc --listFiles` includes the new test and the hook module. - `pnpm --filter @objectstack/example-showcase test`: 37 files, 443 tests passed. - `pnpm --filter @objectstack/core test:repo`: 6 files, 82 tests passed. - `os validate` (showcase): exit 0. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths, at `0aa9da1b3`: 58 derived lines, each run with its exit code captured before any pipe, all exit 0. - `--ran` answers: "58 derived famil(ies) accounted for — 58 run, 0 NOT-MEASURED (a DERIVED zero — all 58 recorded an exit code and none of them is 3)". - Families CI owns (Test Core, Build Core, the type-check lanes) are read from this PR's check runs. **Changeset.** `.changeset/24684-showcase-paid-invoice-line-lock.md`: `'@objectstack/example-showcase': patch`. - Examples do not publish: the package is `private: true`. It is still versioned (`privatePackages.version: true`), and showcase-only fixes carry a `patch` changeset by precedent. - `check-changeset-no-major` passes the level axis on the declared `no (narrowing)`. - `check-adr-0087-registration` passes with `not-required (unpublished)`. ## Acceptance notes - A sandboxed hook's business refusal reaches `/data` as `400` with the sentence in `error` and no `code` field. That is the platform's mapping for every hook refusal, the reopen guard's too. These hooks throw a plain sentence, the showcase's established shape, so no error code is invented here. - A scratch curl cookie jar from my own measurement (a session cookie for an already-killed `--fresh` localhost server, so a dead credential) was committed in 6b26999 and removed in 0aa9da1. A history rewrite was not done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FmarW2qJYp6PYJJz9cpvK5)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4e0f5f5 commit 576d0f3

5 files changed

Lines changed: 386 additions & 8 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/example-showcase': patch
3+
---
4+
5+
fix(showcase): a paid invoice's lines can no longer be added, removed or moved, so its total stays what was paid
6+
7+
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)
8+
9+
<!-- adr-0087: not-required (unpublished) @objectstack/example-showcase is private: no npm release carries the refused writes, and no author-written key was removed or renamed -->
10+
11+
`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.
12+
13+
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.

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3482,7 +3482,7 @@
34823482
"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",
34833483
"since": "v15",
34843484
"status": "active",
3485-
"revision": 7,
3485+
"revision": 8,
34863486
"priority": "P1",
34873487
"surface": "mixed",
34883488
"personas": [
@@ -3491,17 +3491,17 @@
34913491
"fixtures": {
34923492
"app": "showcase",
34933493
"requires": [
3494-
"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')",
3494+
"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')",
34953495
"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)",
34963496
"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)",
34973497
"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",
34983498
"a real running server: hooks fire in the ObjectQL engine on the live /api/v1/data/* route, never on a simulated dispatch"
34993499
],
35003500
"knownGaps": [
35013501
"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.",
3502-
"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.",
3502+
"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.",
35033503
"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.",
3504-
"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).",
3504+
"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).",
35053505
"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."
35063506
]
35073507
},
@@ -3510,7 +3510,7 @@
35103510
"beforeUpdate — FIXTURE: showcase_normalize_task_title (trims title on every task update)",
35113511
"afterUpdate — FIXTURE: showcase_audit_task_completion (task done-transition audit) and showcase_warn_over_budget (project over-budget warn)",
35123512
"afterInsert — SCRATCH/knownGap: no showcase hook targets afterInsert; add a scratch afterInsert log hook or record knownGap",
3513-
"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)",
3513+
"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)",
35143514
"afterDelete — knownGap: no showcase hook and no dogfood pin; add a scratch afterDelete log hook to exercise",
35153515
"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)",
35163516
"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",
@@ -3615,7 +3615,7 @@
36153615
"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."
36163616
},
36173617
"source": [
3618-
"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)",
3618+
"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)",
36193619
"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)",
36203620
"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)",
36213621
"packages/objectql/src/hook-binder.ts#bindHooksToEngine (bindHooksToEngine: per-event × per-object engine.registerHook with priority; unresolved-body / empty-target skips)",
@@ -3667,6 +3667,12 @@
36673667
"date": "2026-10-19",
36683668
"change": "acceptance[8] (verify, evidence) follow the shipped envelope. An unevaluable hook condition's refusal is now a coded 400 VALIDATION_ERROR whose error text names the hook and the CEL fault, and no '[REST] Unhandled error' line is logged for it (#24662, split from #24619 row (b)). The 500 INTERNAL_ERROR that revision 6 recorded is retired and is now a FAIL; the server-log evidence goes with it. The clause is unchanged: the write is still refused and the row still absent",
36693669
"ref": "#24662"
3670+
},
3671+
{
3672+
"revision": 8,
3673+
"date": "2026-10-20",
3674+
"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.",
3675+
"ref": "#24684"
36703676
}
36713677
]
36723678
},

0 commit comments

Comments
 (0)