Repository navigation
Commit 576d0f3
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
- .changeset
- docs/qa/platform-checklist/areas
- examples/app-showcase
- src/data
- hooks
- objects
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3482 | 3482 | | |
3483 | 3483 | | |
3484 | 3484 | | |
3485 | | - | |
| 3485 | + | |
3486 | 3486 | | |
3487 | 3487 | | |
3488 | 3488 | | |
| |||
3491 | 3491 | | |
3492 | 3492 | | |
3493 | 3493 | | |
3494 | | - | |
| 3494 | + | |
3495 | 3495 | | |
3496 | 3496 | | |
3497 | 3497 | | |
3498 | 3498 | | |
3499 | 3499 | | |
3500 | 3500 | | |
3501 | 3501 | | |
3502 | | - | |
| 3502 | + | |
3503 | 3503 | | |
3504 | | - | |
| 3504 | + | |
3505 | 3505 | | |
3506 | 3506 | | |
3507 | 3507 | | |
| |||
3510 | 3510 | | |
3511 | 3511 | | |
3512 | 3512 | | |
3513 | | - | |
| 3513 | + | |
3514 | 3514 | | |
3515 | 3515 | | |
3516 | 3516 | | |
| |||
3615 | 3615 | | |
3616 | 3616 | | |
3617 | 3617 | | |
3618 | | - | |
| 3618 | + | |
3619 | 3619 | | |
3620 | 3620 | | |
3621 | 3621 | | |
| |||
3667 | 3667 | | |
3668 | 3668 | | |
3669 | 3669 | | |
| 3670 | + | |
| 3671 | + | |
| 3672 | + | |
| 3673 | + | |
| 3674 | + | |
| 3675 | + | |
3670 | 3676 | | |
3671 | 3677 | | |
3672 | 3678 | | |
| |||
0 commit comments