Skip to content

fix(showcase): a paid invoice's lines can no longer be added, removed or moved, so its total stays what was paid - #24731

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24684-paid-header-line-lock
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24684-paid-header-line-lock

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

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 (Parent-scoped readonlyWhen is unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #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 fix(objectql)!: a stored field's declared expression is computed on every write, so a paid invoice's line amount and total stay frozen #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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0aa9da1b313424b3bc667a00ccbaa08e9b93cd79
Local-runs: none
Scope: examples/app-showcase/src/data/hooks/index.ts, examples/app-showcase/src/data/objects/invoice.object.ts, examples/app-showcase/test/paid-invoice-line-lock.test.ts, docs/qa/platform-checklist/areas/records-forms.json, .changeset/24684-showcase-paid-invoice-line-lock.md + packages/objectql/src/engine.ts (insert/update/delete hook dispatch, dispatchPerRowBeforeHooks, dispatchUnscopedMultiWriteHooks, triggerHooks, buildSession, claimReferentialCascadeDelete, cascadeDeleteRelations/walkReferencingRelations, assertReferencesResolve, executeWithMiddleware, ScopedContext.withRunAs), packages/objectql/src/hook-wrappers.ts, packages/objectql/src/hook-binder.ts, packages/objectql/src/hook-run-as.ts, packages/runtime/src/sandbox/body-runner.ts, packages/runtime/src/sandbox/quickjs-runner.ts, packages/runtime/src/action-execution.ts, packages/rest/src/error-response.ts, packages/rest/src/rest.test.ts, packages/rest/src/rest-data-door-code-prefix.test.ts, packages/rest/src/batch-routes.ts, packages/rest/src/import-prepare.ts, packages/rest/src/data-action-routes.ts, packages/core/src/utils/import-runner.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/spec/src/data/hook-body.zod.ts, packages/spec/src/kernel/execution-context.zod.ts, packages/metadata-protocol/src/seed-loader.ts, packages/services/service-automation/src/runtime-identity.ts, packages/lint/src/validate-readonly-action-writes.ts, packages/formula/src/validate.test.ts, examples/app-showcase/test/task-reopen-hook-refusal.test.ts, examples/app-showcase/src/security/permission-sets.ts, examples/app-showcase/src/data/seed/index.ts, examples/app-showcase/package.json, .changeset/config.json, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, .github/workflows/lint.yml, .github/workflows/pr-automation.yml, .claude/skills/pm-dispatch/SKILL.md, AGENTS.md

Read on the narrowing face, adversarially, from the card #24684 (body and all six comments: triage 6280795779, claim 6280841440, dispatch 6280866009, needs_decision 6281203874, ruling 6281609466, done 6282206376), #4889, #19853, the PR body and file list, the net diff against the merge-base d20bf9b09b (4 commits on the branch; 5 files, +381/-6, identical against current origin/main 0904d856e3), and the head's check-runs, read 2026-10-20T03:15Z: 38 runs, latest per name 31 success, 6 skipped (Build Docs, Console Pin Gate, Dogfood shards, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance), 0 failure, ONE still running at my read: Test Core (1/6). Check Changeset (the pr-automation changeset-check job, which runs check-adr-0087-registration and check-changeset-no-major), Build Core, the four Type Check lanes, the eight Lint lanes, Governed Surface Queue Guard, and Test Core (2/6) to (6/6) are green on this head.

① Derived judgments

The three locks — right on every door that reaches showcase_invoice_line.

  • beforeInsert fires per row inside engine.insert's middleware executor (engine.ts executeWithMiddleware at the insert site, then triggerHooks('beforeInsert') per row). Doors: /data POST by id and /batch create both go through p.createData with the caller's context (batch-routes.ts, the trxCtx comment: "carries the caller's context (including isSystem)"); an HTTP import writes each row through runImport → p.createData with the caller's context (data-action-routes.ts passes context; core/utils/import-runner.ts threads it); the inline grid saves through /data or /batch; a clone is an insert. Upsert: the engine has no upsert (engine.ts declares none; the sandbox facade's upsert falls back to insert), and the batch route's op set is create | update | delete — so an "upsert" is an insert or an update, both covered.
  • beforeUpdate (the move): the by-id update() binds previous to the prior record and dispatches triggerHooks('beforeUpdate'); the predicate (multi: true) path reads the matched rows' pre-images and dispatches dispatchPerRowBeforeHooks('beforeUpdate', rows, …) with previous: row and input: { id, data, options } per row (engine.ts dispatchPerRowBeforeHooks). The flagged whole-operation dispatch (dispatchUnscopedMultiWriteHooks) runs only entries declaring dispatchUnscopedMultiWrite, which these hooks do not, and the per-row loop runs whenever hasHooksFor is true — a multi: true update with no where still dispatches per row. /batch update is ql.update(op.object, { ...data, id }, { context: trxCtx }), the by-id path. The condition record.invoice != previous.invoice is evaluated per dispatch against pickRecordPayload (stored ⊕ payload) and pickPreviousPayload (hook-wrappers.ts), so every repoint shape — by-id PATCH, predicate update carrying invoice, batch update — is seen; CEL heterogeneous equality is runtime-safe (formula/src/validate.test.ts "does NOT warn on heterogeneous equality (runtime-safe, returns false)"), so a null invoice payload does not fault the gate.
  • beforeDelete (the remove): by-id delete() reads the pre-image, binds it and dispatches triggerHooks('beforeDelete'); the predicate path reads the doomed rows once and dispatches dispatchPerRowBeforeHooks('beforeDelete', doomed, …) with per-row previous; /batch delete is ql.delete(…, { where: { id }, context: trxCtx }), the by-id path. So !ctx.previous in the remove body is unreachable on these doors (it would only hold on the flagged whole-operation dispatch, which these hooks do not register for) — a harmless belt.
  • No un-refused shape found. The one engine-level bypass is session.skipAutomations, which skips every metadata-bound hook (triggerHooks: if (skipAutomations && entry.meta); the binder registers these hooks with meta). It is server-derived: buildSession projects it from the ExecutionContext, the batch route strips a body context ("keeps a body context from becoming the execution context"), and the import door refuses the runAutomations: false opt-out for every non-system caller ("An HTTP request is assembled with isSystem: false, so at the import doors every signed-in caller, administrators included, is refused", import-prepare.ts). It is therefore the same exemption as the system write, not a second door.

The exemptions — each as wide as stated, with one width the dev understates.

  • session.isSystem: buildSession sets it iff the ExecutionContext carries isSystem (engine.ts), and installRunAsApi swaps ctx.api ONLY ("runAs is applied to ctx.api ONLY … the engine's own context object is never touched", hook-wrappers.ts), so the guards' own runAs: 'system' does not make the triggering write a system write. Producers on a running showcase: the seed (SEED_WRITE_EXECUTION_CONTEXT = { isSystem: true, skipTriggers: true, seedReplay: true }, spec/src/kernel/execution-context.zod.ts; metadata-protocol/src/seed-loader.ts SEED_OPTIONS) — needed, since seed/index.ts loads INV-1003 already paid and then its line; a script ACTION body's ctx.api, which is buildActionExecutionContext(ec) = { ...ec, isSystem: true } on both REST /actions and MCP run_action (runtime/src/action-execution.ts; the lint's own table in validate-readonly-action-writes.ts says "the action body is elevated BY DESIGN"); a flow declared runAs: 'system' (service-automation/src/runtime-identity.ts); a job body (buildJobSandboxContext, body-runner.ts); another hook's runAs: 'system' api or ctx.api.sudo(). An API key resolves to a user principal (core/src/security/api-key.ts writes as system only to sys_api_key); MCP data tools mint no isSystem (none in packages/mcp/src); an HTTP request is isSystem: false. Does any open the paid lock to an ordinary user's request? Not on the showcase as shipped: no action, flow or hook under examples/app-showcase/src writes showcase_invoice_line (only the seed, the object, a print page, translations and permission sets name it). But the dev's "the shipped seed is the one system writer" describes the showcase, not the exemption: a future showcase script action that writes a line would pass the lock by design. The hook header should say so in one clause; a follow-up, not a defect. Also a nit in the PR body: "The platform's own read-only strips exempt system writes the same way" is true of the STATIC readonly strip (staticReadonlyStripRuns = !isSystem) and not of readonlyWhen, which exempts no one ([objectql] TRUE readonlyWhen strips beforeUpdate-derived values too — a conditionally-locked derived field has no server-side write path at all (isSystem included), unlike static readonly's hook-stamp protection #9107 LOCK 2, as the lint file records).
  • Cascade: claimReferentialCascadeDelete reads an engine-private AsyncLocalStorage token that only walkReferencingRelations sets, for exactly the child object/id it issues, claimed once ("Engine-private: nothing a request carries can name a record into it"); the child delete is the public this.delete(childName, { where: { id }, context }) with the CALLER's context unchanged, so the remove guard runs as the ordinary user and the marker is the only thing that lets it pass; body-runner.ts copies the marker across the sandbox only when the engine set it === true. A caller cannot set it (body context is stripped at the routes; buildSession builds field by field), and the branch is reachable only through a delete of the header, whose cascade relation issues the child delete. Exactly as wide as stated.

The header read — right, and the "fail closed" claim holds in the way the prelude states it.

  • runAs: 'system' derives ctx.api via ScopedContext.withRunAs('system'), which is this.sudo() (engine.ts), gated by the declared api.read capability (spec/src/data/hook-body.zod.ts; quickjs-runner.ts installs the read methods behind 'api.read'). So the verdict does not depend on the writer's read grants on invoices — needed, since contributors have owner RLS on showcase_invoice (permission-sets.ts invoice_own_rows).
  • Disclosure: plugin-security is engine MIDDLEWARE (ql.registerMiddleware, three sites in security-plugin.ts), and executeWithMiddleware runs the middleware chain before the executor that dispatches hooks, so on the by-id doors the ADR-0055 master-derived write check (step 2.8, which covers insert) refuses a caller who cannot write the master BEFORE the guard reads; a caller the guard answers already could read the header, which is what controlled_by_parent implies. Residual, not measured and within tolerance: on a repoint the move sentence names no header, but if the destination header's writability is not judged before hooks (the security plugin's bulk path "returns early" with no single id), a caller who owns the source line learns one bit — that the id they named is paid — ahead of the reference check's own refusal. The sentence names no header and the write is refused either way.
  • Not found: paid() returns false on a missing header, so the guard itself admits; the prelude says exactly that and defers to the engine's reference check, which runs for every non-system write on insert and on both update branches (assertReferencesResolve, three call sites) and refuses a dangling invoice. A delete or move-off of an orphan line is admitted, and no paid total exists to move. Right.

The draft control — right. The move guard's condition is false on an update that leaves invoice alone (record.invoice is stored ⊕ payload, equal to previous.invoice), so no header read is paid; the add and remove guards read once and admit a non-paid header. Pinned: draft insert, delete, move between drafts, plain edit.

The pins — teeth confirmed from the code. The five refusal pins assert instanceof SandboxError plus innerMessage === sentence, then the header's line count and total read back unchanged, on the production harness the reopen pin uses (real ObjectQL, SqlDriver better-sqlite3, QuickJSScriptRunner behind hookBodyRunnerFactory, the app's objects and hooks). With the three hooks removed from allHooks, each write resolves and expect(err).not.toBeNull() fails — five red, matching the dev's ablation count. The REVERSE case (invoicesWithLines([])) is a positive CONTROL: it binds no hooks and asserts the writes land, so it stays green under the ablation; what goes red is the five pins, which is what the PR body reports (5 failed, 7 passed). The wire: mapDataError unwraps SandboxError.innerMessage to 400 with error = the sentence and no code (pinned rest.test.ts "unwraps SandboxError.innerMessage → 400 with only the business message", and rest-data-door-code-prefix.test.ts "a sandbox refusal still answers 400 with the author's own sentence"); the handleRouteError arm in error-response.ts serves the same sentence on /batch, createMany, updateMany, deleteMany. The booted 400 + sentence in the PR body is consistent with that mapping. The showcase cannot import @objectstack/rest or @objectstack/types (not in its package.json), so pinning the envelope and reading the status on a boot is the right split.

The comments — true about where each lock lives; one stale line beside them. invoice.object.ts: "locks EDITS of a line, on the server too (the write drops the change)" (#4889; measured PATCH quantity 200 with the readonly_when drop), "insert-exempt by design" (engine.ts "INSERT stays exempt"), and "adding, removing or moving … locked … by the showcase_guard_paid_invoice_line_* hooks" — all true. The hook module's new docblocks are true. NIT, not in the diff but made false by it: GuardTaskReopenHook's docblock still reads "the showcase's one hook that REFUSES a write" (hooks/index.ts, the reopen guard's header); there are now four. Fix at the next touch.

② Semver level

  • Package and level: @objectstack/example-showcase is private: true (examples/app-showcase/package.json), and .changeset/config.json has privatePackages: { version: true, tag: false }, so a changeset is owed and patch is the precedent grade for an unpublished package; nothing on npm carries the refusal.
  • Clause-②: no (narrowing) stands at the start of a line of its own in the PR body and in the changeset. readClause2Line anchors the key at line start (CLAUSE2_KEY_LINE) and reads the arm as the first token inside the parenthesis, tolerating the trailing prose (clause2-line.mjs, the "no (narrowing — …) reads the arm" rule); check-changeset-no-major carries the axis on a no with the narrowing arm. Check Changeset is green on this head.
  • check-adr-0087-registration reads the changeset's single HTML-comment marker adr-0087: not-required (unpublished) … through its not-required (category) why regex, and unpublished is re-validated on every run as "every package the changeset bumps must be private: true" — the showcase is. It runs in pr-automation's changeset-check job, which is the green Check Changeset. The dev's reading "not-required (unpublished)" is right.
  • Widening: none. The six new exports (three hook objects, three sentence constants) sit in the private showcase; the refusals throw plain Error sentences, so no code reaches the wire and no error code is minted; no spec key, no file under packages/**. The narrowing arm is honest: three writes the showcase accepted on main (POST 201, DELETE 200, PATCH invoice 200, all measured by the dev at 8e14ce0574) are refused at this head.

③ Boundary flags

  • docs/qa/platform-checklist/areas/records-forms.json, rev 7 — answered: every changed fact is true, no clause moved. allHooks at the head has eight entries (normalize, stamp, reopen guard, the three paid guards, audit, warn); showcase_guard_paid_invoice_line_remove is the one seeded beforeDelete hook; the throwing hooks are the reopen guard and the three paid guards; condition, runAs:'system', onError:'abort', priority 60 match the module. Only fixtures.requires[0], knownGaps[1], knownGaps[3], the beforeDelete entry of variants, source[0], revision and the changelog moved; steps, acceptance and negative are untouched. Gate: check:platform-checklist is kept OUT of per-PR CI by maintainer decision (lint.yml NOTE; the daily platform-checklist-watchdog.yml on main is its reporting channel), so this file's gate verdict is the dev's local exit 0 among the 58 derived lines, not a head check-run — named here so the seat knows the watchdog is the backstop.
  • The cross-lane surface — escalated to the seat. docs/qa/** is domain:devx's (pm-dispatch/SKILL.md, the lane table), and this PR is domain:engine; the claim 6280841440 did not declare the file. The only sanctioned channel is the second of three (SKILL.md: "认领席跟到底的越界面"), so before the queue the seat declares on the card, as a claim amendment: the widened file surface (docs/qa/platform-checklist/areas/records-forms.json, roster-only edit, the rev-3 precedent) and the in-flight intersection check against domain:devx claims and open PRs touching that file (the SKILL.md rule: read the pm:dispatched claims' file surfaces and intersect locally; serialise behind or yield on an intersection). The green No other open PR may claim the same single-writer path does not answer it: that gate judges declared single-writer paths only.
  • Code + status pins at the envelope, wire status on a boot — answered: accepted. The mapping is the platform's and is pinned in packages/rest (above); the showcase pins the field that mapping classifies on (SandboxError.innerMessage). No deviation to escalate.
  • The cookie-jar file cj-38921 — answered: absent from what lands; a dead credential. Committed at the repository root in 6b269992 (5 lines) and deleted in 0aa9da1b; git diff merge-base..head --name-only lists no cj-* and git ls-tree -r pr/24731r has none. main's last eight PR merges each have exactly one parent, so the queue squashes: the landing commit carries the net tree and the file never reaches main; it survives only in the branch's own history. Content, read with the value redacted: a Netscape jar with one #HttpOnly_localhost better-auth.session_token (81 characters), a seven-day expiry — a server-side session reference for a --fresh localhost server whose temp-dir database died with the process, usable against nothing that still exists. The dev's account (a dead --fresh localhost session) is consistent with the file's shape. A history rewrite under AGENTS.md's five --force-with-lease criteria was denied by the permission classifier and not retried; the normal removal commit is the right fallback. Not blocking; delete the branch on merge as usual.
  • The stray git checkout main in the worktree — answered: nothing to escalate. Not observable in the head or the diff; the branch's four commits are the fix, the ADR-0087 marker, the origin/main merge and the cookie-jar removal, and the net diff is the five files.
  • open_questions, both A (pass, built) — right on the four axes and in code. System write: real need (the seed inserts INV-1003's line under an already-paid header, seed/index.ts); soundness (one isSystem posture across the engine's static strip, the import opt-out and this guard; buildSession is the single producer); against AI mistakes (stated in the prelude's docblock and pinned by SYSTEM fixture writes); startup focus (B reorders the seed for no consumer). Width caveat above: the exemption is every isSystem producer, action bodies included; none writes lines today. Cascade: the marker is engine-private and unforgeable, reached only by deleting the header; refusing would make a paid invoice undeletable through the API, a rule nobody asked for (the UI already hides Delete on paid rows, invoice.object.ts).
  • The out-of-scope finding (a hook refusal reaches /data with no code) — answered: the platform's declared shape, no filing gate met. rest.test.ts pins it as a decision: "No code because this producer DECLARED none — not because the unwrap door withholds it … ADR-0112's half: nothing is invented for a half-declaration"; the sandbox passes a DECLARED code/status through (quickjs-runner.ts SANDBOX_ERROR_PASSTHROUGH), and the wire vocabulary is closed (StandardErrorCode ∪ ERROR_CODE_LEDGER), so a showcase hook cannot mint one. Carrying it in Acceptance notes is right; nothing to file.

Implemented-by: claude/issue-24684-paid-header-line-lock
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS

Seat note · domain:engine seat 1 · 2026-10-20T03:21Z

The seat checked the central claims on this head:

The claims hold.

A serial constraint the record did not see. PR #24714 (#24662, domain:cli, in the merge queue ahead of this PR) edits the same checklist item, records-forms.object-hook-lifecycle: it bumps revision 6 → 7 and appends a revision-7 changelog entry, as this PR does. git merge-tree of the two heads conflicts in docs/qa/platform-checklist/areas/records-forms.json. So this PR lands second, and it is held out of the queue until PR #24714 merges.

Fix round 1 (sent to the dev when PR #24714 is on main):

  • merge origin/main; keep fix(types): an unevaluable hook condition's refusal answers 400 VALIDATION_ERROR at every data door #24714's revision-7 entry, and renumber this PR's item revision and changelog entry to 8;
  • the record's two comment nits, in the same push: GuardTaskReopenHook's docblock ("the showcase's one hook that REFUSES a write") is false now that there are four; and the paid guards' header says in one clause that any system writer passes by design (the seed, a script action's ctx.api, a runAs: 'system' flow);
  • the gates re-run on the merged head.

The seat patches the PR body's sentence on the read-only strips: it is true of the static readonly strip only, not of readonlyWhen. A delta note follows the fix round, then the ACCEPT.

Cross-lane: docs/qa/platform-checklist/** is domain:devx's. It is declared to that lane now, naming the intersection with PR #24714.


Generated by Claude Code

…id-header-line-lock

# Conflicts:
#	docs/qa/platform-checklist/areas/records-forms.json
…stem writers pass the paid-line guards

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FmarW2qJYp6PYJJz9cpvK5
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat delta note · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T05:00Z

On head aaec016144, against the record of record 6282567187 (CONTRACT_REVIEW_TIER, PASS on 0aa9da1b31). Fix round 1's report is 6283200519. This round's two commits:

Read in-seat:

  • The checklist conflict, resolved:
  • The two nits, made true:
    • GuardTaskReopenHook's docblock now says "the showcase's first hook that REFUSES a write", and that the three paid guards refuse the same way.
    • The paid guards' prelude now says every system writer passes by design (the seed, a script action's ctx.api, a runAs: 'system' flow), and none writes showcase_invoice_line today.
  • The dev's one deviation: the same prelude sentence's "the platform's own read-only strips" now reads "the platform's static readonly strip". This is the record's PR-body correction, carried into the code comment the sentence came from. Accepted.
  • No behaviour moved. The hooks' events, condition, bodies, runAs and priority are byte-identical to 0aa9da1b31, as are the pin file, the object file and the changeset. The record's ① and ② carry unchanged.
  • Merge state: git merge-tree --write-tree against today's origin/main is clean, and the PR reads mergeable: clean.

Gates and CI:

  • Dev, at aaec016144: 58 derived, 58 run, all exit 0. check:platform-checklist: 16 areas, 327 items, OK. Showcase suite 443 passed; core test:repo 82 passed.
  • ci-failure GREEN: all 39 check-runs completed, none failed.

The record carries to this head: PASS.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T05:01Z · ACCEPT

Head: aaec016144. The commits, in order:

  1. the three paid-invoice line guards, their pins, and the object's comment;
  2. the ADR-0087 disposition;
  3. a merge of main;
  4. the cookie-jar removal;
  5. fix round 1: a merge of main (with PR fix(types): an unevaluable hook condition's refusal answers 400 VALIDATION_ERROR at every data door #24714) and the comment nits.

Card #24684 (p2, bug, area:records): triage 6280795779, claim 6280841440, dispatch 6280866009. The dev reported needs_decision 6281203874; the seat ruled C, so B now (6281609466). Later reports: done 6282206376, and fix round 1 6283200519.

Item Reading
Shape Draft PR → main. The body opens Fixes #24684. 5 files, +386/−8. Not governed: check-governed-merges matched 0 of 5 paths.
Scope Three lifecycle hooks on showcase_invoice_line refuse, each with a sentence (400 on /data), when the invoice is paid: adding a line (beforeInsert), moving a line onto or off the invoice (beforeUpdate, gated on record.invoice != previous.invoice), and removing a line (beforeDelete). Each reads the header under runAs: 'system'. A system write and the header's own cascade delete pass. Measured on a booted showcase: before, an insert moved INV-1003's total 299.94 → 306.94, a delete moved it to 7, and a repoint moved it both ways. After, each is refused and the total stays 299.94. Draft lines insert, edit, move and delete as before. No platform change (ruling 6281609466).
Seat's own read Checked on the base head (seat note on 6282567187): the action envelope's isSystem elevation, the engine-private cascade marker, the three guards' gates, and the cookie jar's absence from the tree. The fix round was read in-seat (delta 6283767171): behaviour is byte-identical, the checklist is renumbered to revision 8 with #24662's revision 7 kept, and the two nits are true. The PR body's read-only-strip sentence was patched.
Review CONTRACT_REVIEW_TIER, narrowing face. Record 6282567187 is PASS on 0aa9da1b31; the seat's delta 6283767171 carries it to this head.
Declaration One changeset, @objectstack/example-showcase patch (a private package; privatePackages.version), with Clause-②: no (narrowing) at a line start and adr-0087: not-required (unpublished). No export from a published package, no error code, no spec key, and no file under packages/**.
Cross-lane domain:devx, for docs/qa/platform-checklist/areas/records-forms.json (roster-only, no clause moved): 6282575431 (#6023) and 6282580043 (#20163), naming the serial order behind PR #24714, which is now kept. No objection on the card or the seat posts.
Gates Dev at aaec016144: 58 derived, 58 run, all exit 0. check:platform-checklist: OK. Showcase suite 443 passed; core test:repo 82 passed. Ablation (base round): the five paid pins red, restored to HEAD.
CI At this read on aaec016144: ci-failure GREEN, all 39 check-runs completed, none failed.

Disposition:

  • open_questions: both taken as the dev settled them, per record 6282567187 ③: the system-write exemption passes, and the cascade delete passes. The exemption's width is now stated in the hook header.
  • deviations: all accepted (record ③ and delta 6283767171).
    • The checklist file was outside the fence, and is declared to its lane.
    • The pins assert the error envelope; the wire status was measured on a boot.
    • The cookie jar cj-38921 held a dead --fresh localhost session. It is absent from the net tree, and the queue lands a squash.
    • The static-strip wording in the code comment.
  • out_of_scope_findings:

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24684 then closes the card.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants