Skip to content

Commit 5910b5e

Browse files
feat(verify): the handle gains a system-context update door and a predicate update door (#22517)
Part of #22301: items 2 and 3, as one stage (PM claim `6083271651`). Items 4 to 8, and item 1's remaining composition gap, stay open on the card. Clause-②: yes (widening) ## What this adds The in-process handle of `@objectstack/verify` gains the two update doors that hotcrm still reaches through local helpers (`systemUpdate` and `predicateUpdate` in hotcrm `test/helpers/verify-stack.ts`). Each door is the engine's own `ObjectQL.update`, the call those helpers make by hand on the booted kernel. Nothing is re-implemented and nothing writes to a driver directly. - **The system-context update (item 2).** `hooks.run(object, 'update', { id, ...fields }, { system: true })` runs `update(object, { ...input, id }, { where: { id }, context: { isSystem: true } })`. That is the by-id write `hooks.run` already makes for a person, under the context a system job writes under. No permission gate applies. The bound hooks still run (they see `session.isSystem` and no `userId`), declared validations still refuse, and the record-change trigger fires its flows with no trigger user. `seed` remains the fixture door; it also sets `skipTriggers` and `seedReplay`. - **The predicate update (item 3).** `hooks.updateWhere(object, where, data, opts)` runs `update(object, data, { where, multi: true, context })`, the engine's predicate path. No REST door reaches that path: `POST /data/:object/updateMany` writes by id. The engine dispatches `beforeUpdate` and `afterUpdate` once per matched row (`dispatch.mode: 'per-row'`), each bound to that row's own pre-image as `previous`, so record-change flows evaluate per row. The door resolves the affected-row count. The caller is `{ as: token }` or `{ system: true }`. ### Public API (`@objectstack/verify` `minor`, `@objectstack/spec` `minor`) - New exported type `AsSystem`: `{ system: true }`, beside `AsUser`. - `VerifyHandle.hooks.run` gains a second overload, `run(object, 'update', input, opts: AsSystem)`, which resolves an `EngineRow`. The existing signature is unchanged. - New member `VerifyHandle.hooks.updateWhere(object, where: EngineRow, data: EngineRow, opts: AsUser | AsSystem)`, which resolves a `number`. - No new value export. Nothing in `packages/rest` or `packages/objectql` changes. `@objectstack/spec` (`minor`, `.changeset/22301-spec-ledger-verify-provenance.md`, `Clause-②: yes (widening: a new owner provenance row in the published error-code ledger)`): `ERROR_CODE_LEDGER["@objectstack/verify"]` lists `INVALID_REQUEST`. No code is added, and `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged. ### What the doors refuse themselves Both doors answer `INVALID_REQUEST` / `400` (an existing ADR-0112 ledger code) before a caller is resolved or the engine is touched: - `{ system: true }` on `insert` or `delete`. The system context is the update doors' only. A system insert of fixture rows is `seed`. A system insert or delete that fires record triggers has no door: it belongs to item 5's stage, not this one. - `{ as, system: true }` together: a caller named twice. - `hooks.updateWhere` with a `where` that is not an object. - `hooks.updateWhere` called in a way the engine's own update dispatch (`resolveEngineUpdateDispatch`, exported by `@objectstack/objectql` for exactly this kind of caller) would write by id: a `where` naming only an `id`, or an `id` in `data` beside a `where` that selects by nothing else. The door is the predicate path, so it does not hand back a by-id row where it promises a count. An `id` inside a real predicate (`{ id, batch }`, the compare-and-set spelling) stays a predicate update. An `id` in `data` beside a real predicate is the engine's own refusal, unchanged. Every other refusal (permission, validation, a hook's throw) is the engine's own error, rethrown unchanged. ## Premises, re-read on `origin/main` `e148ca9842` 1. **`seed` only inserts:** `handle.ts` `seed` calls `ql.insert(object, rows, { context: SEED_CONTEXT })`. Holds. 2. **`hooks.run` always runs as a person:** it resolved `contextFor(opts.as)`, which refuses a token that resolves to no `userId`. It addresses one row by `input.id`. Holds. 3. **REST `updateMany` iterates by id:** `metadata-protocol/src/protocol.ts` `runUpdateManyLoop` refuses a row without `id`, then updates each one by id. Holds. 4. **The engine's predicate path binds each row's pre-image:** `ObjectQL.update` takes the predicate branch on the dispatch verdict `multi`, then `dispatchPerRowBeforeHooks` / `buildPerRowAfterContexts` (ADR-0058's bulk addendum) dispatch once per matched row with that row's `previous`. It resolves the driver's affected-row count (`engine.ts`, the `driver.updateMany` exit). Holds. 5. **The system context (zone 2, assumption 2):** `{ isSystem: true }` is the spec's named system opt-in (`ExecutionContext` docs). `isSystem` alone does not suppress trigger dispatch; only `skipTriggers` does (`SEED_WRITE_EXECUTION_CONTEXT` docs, and `seed-ownership-claim-dispatch.dogfood.test.ts` measured a bare `{ isSystem: true }` write firing app hooks and record flows). This PR re-measures it on the handle (pins below). 6. **"hotcrm is not reachable from this container" (zone 2, assumption 3): falsified.** `gh api` is refused for that repository in this session, but a plain `git clone --depth 1 --sparse` over the proxy works. The helpers were read at hotcrm `ac162c9`, read-only. hotcrm's suite was not run. ## hotcrm mapping (hotcrm `ac162c9`, read-only) | hotcrm local helper | handle door | engine call (the same in both) | |---|---|---| | `systemUpdate(stack, object, doc)`: 61 calls in 26 files, all by id | `stack.hooks.run(object, 'update', doc, { system: true })` | `update(object, doc, { where: { id: doc.id }, context: { isSystem: true } })` | | `predicateUpdate(stack, object, doc, where, as)`: 1 call (`flow-billing-handoff.test.ts:232`, `where: { name }`) | `stack.hooks.updateWhere(object, where, doc, { as })` | `update(object, doc, { where, multi: true, context: contextFor(as) })` | `escalation-task-subject.test.ts:143` expects `systemUpdate` to be refused by the app's own hook. Through the door that refusal is still the engine's own error. ## Tests (at `992de817`) `packages/verify/src/handle.update-doors.test.ts`: 9 tests on one `bootStack` of a neutral fixture. The fixture has an editable `upd_deal`, an `upd_case` a fresh member may read but not edit, a capture hook on each (`beforeUpdate` / `afterUpdate`: caller, `previous`, `dispatch.mode`), and a record-change flow on each that writes a ledger row (`from` = `previous`, `to` = `record`). - **Item 2:** a member's `hooks.run` update of `upd_case` is refused `PERMISSION_DENIED` / `403` and the row is unchanged (control). The same update with `{ system: true }` is written. - **Item 2, a real system write:** after a system update, both hooks saw `isSystem: true`, no `userId`, `previous` = the pre-image, `mode: 'record'`. The flow ran once, `completed`, with no `trigger.userId`, and wrote its ledger row. Control: the same update as the admin reaches the same hooks and the same flow, carrying the admin's id. - **Item 2, validation still runs:** a system update the declared rule refuses rejects with the engine's `ValidationError` (`code: 'VALIDATION_FAILED'`, `fields: [{ field: '_record', code: 'rule_violation' }]`) and the row is unchanged. Control: an in-range value is written. - **Item 2, call shape:** `{ system: true }` on `insert` and `delete`, and `{ as, system }`, each answer `INVALID_REQUEST` / `400`. Nothing is written and no hook is dispatched. - **Item 3, exactly N:** three rows match, one of them already `won`, and two do not. `updateWhere` resolves `3`, all three carry the payload, and the two non-matching rows are unchanged. - **Item 3, the predicate path:** each matched row got exactly one `beforeUpdate` and one `afterUpdate`, each with `previous` = that row's own seeded stage, `userId` = the member, and `mode: 'per-row'`. Non-matching rows got none. - **Item 3, record flows per row:** the transition flow (`stage != previous.stage`) wrote one ledger row for each matched row whose own pre-image differed (2 rows, each `from` its own stage), and none for the row already `won` (it is in the count of 3). One shared `previous` would fire for all three rows or for none. - **Item 3 with the system:** a member's predicate update of `upd_case` is refused `PERMISSION_DENIED` / `403` and the rows are unchanged. The same call with `{ system: true }` resolves `2`, and the hooks saw `isSystem`, no user and `mode: 'per-row'`. - **Item 3, call shape:** `where: { id }`, `{}` with an `id` in `data`, and no `where` each answer `INVALID_REQUEST` / `400`, with nothing written and no hook dispatched. Control: `where: { id, batch }` goes through and resolves `1`. The package: `pnpm --filter @objectstack/verify test` gave 24 files / 196 tests passed, and `pnpm --filter @objectstack/verify typecheck` exited 0. The test layer is compiled: `tsconfig.test.json` lists the new file. ## Ablations Each ablation went through `node scripts/ablation-replace.mjs` (WRAP mode, restore armed on EXIT/INT/TERM) on `packages/verify/src/handle.ts` at `992de817`, under one lock acquisition. The test imports the handle from source (`./harness.js`), so the subject has no `dist/` leg. The direction was predicted before the run. Each mutation landed (anchor 1 → 0, blob changed) and was restored to the HEAD blob `db78a676` with `git diff HEAD` empty. The tree ended with 0 porcelain lines. | | mutation | predicted red | observed | |---|---|---|---| | A1 | the system door's context becomes the seed posture (`SEED_CONTEXT`, which adds `skipTriggers` and `seedReplay`) | only "a real system write" (no flow run) | 1 failed / 8 passed: exactly that pin | | A2 | the predicate door becomes a find-then-by-id loop, the REST `updateMany` shape | only the two `mode: 'per-row'` pins; count, untouched rows, per-row `previous` and per-row flows stay green | 2 failed / 7 passed: exactly those two | | A3 | the by-id dispatch refusal is disabled | only the by-id refusal pin | 1 failed / 8 passed | | A4 | the refusal of `{ system: true }` on insert/delete is disabled | only the call-shape pin | 1 failed / 8 passed | A2 is why the dispatch-verdict pin exists. Per-row hooks with their own `previous`, an exact count and per-row flows also hold for a loop of by-id writes. Only the engine's `dispatch.mode` tells the two apart. ## Gates At `ca122badb2`, the final commit after the patch round (contract review FAIL `6085768920`, seat order `6085806321`), `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 88 commands. The spec path added 25, `check:error-code-provenance` among them. Each was run with its exit code captured before any pipe: - 85 exited 0, among them `check:error-code-provenance` ("OK — every registered-code stamp site is listed under its own owner key or carries a recorded waiver"). - 3 are NOT MEASURED: - `dual-build-cjs-loads`: PREREQUISITE NOT MET (whole-tree dist), left to CI on the seat's instruction. - `doc-formula-expressions` and `lean-entry-closure`: a dist prerequisite that the shared verify lock could not serve, left to CI. - `--ran` verdict: `✓ dispatch-gates --ran: 88 derived famil(ies) accounted for — 85 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3).` - The 49 artifact-roster commands all exited 0. The 3 PR-context guards were run wired to this PR. Round 3 at `257aeb788b` (one changeset file, per review `6087465393`): `check-changeset-no-major`, `check-adr-0087-registration`, `check-empty-changeset`, `check:changeset-gate-self-tests`, `check-changeset-fixed` and `check:nul-bytes` each exit 0. Round 1 at `992de817` derived 63 commands, of which 62 ran and 1 was NOT MEASURED (`dual-build-cjs-loads`). CI owns the jobs that no local command covers: Test Core, Dogfood, Build Core, the workspace type-check and `pnpm lint`. ## Acceptance notes - **The README is outside the claim's file surface.** `packages/verify/README.md` enumerates every handle door and said "there is no way to run as nobody; `seed` and the default `rows` run as the system principal". Both lines describe the doors this PR adds, so they are updated here, and the deviation is declared in the report. - **The engine's `ValidationError` carries no status.** It has `code` and `fields`; the REST boundary maps `VALIDATION_FAILED` to 400. The validation pin therefore asserts `code` and `fields`, not a status. Not filed: no public door answers wrong. Carrier: none. - **The durable `sys_automation_run` row lands after the triggering write returns.** It was absent right after the system update and present 500 ms later, while the engine's run log (`automation.listRuns`) and the flow's own writes are there synchronously. The pins read the run log. A suite that reads `sys_automation_run` immediately after a write can race it; hotcrm's `flowRuns` helper reads that table. Root cause NOT MEASURED. Not filed (an observation). Carrier: none. - **Overlap with item 5.** Item 5 asks for a user-less record trigger. For an update, the system door now produces one (measured: the flow runs with no `trigger.userId`). A user-less insert or delete trigger, and a record the engine no longer holds, are still item 5's. - **Pre-existing call-shape refusals.** `hooks.run`'s two older call-shape refusals (an unknown operation, a missing `input.id`) are still plain `Error`s with no `code`. The new ones carry `INVALID_REQUEST` / `400`. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c76edeb commit 5910b5e

7 files changed

Lines changed: 614 additions & 13 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): the error-code ledger lists `INVALID_REQUEST` under `@objectstack/verify`
6+
7+
Clause-②: yes (widening: a new owner provenance row in the published error-code ledger)
8+
9+
`ERROR_CODE_LEDGER['@objectstack/verify']` now lists `INVALID_REQUEST`. The verify handle's two update doors answer it for a malformed call, such as `{ system: true }` on an insert or delete, or a `hooks.updateWhere` the engine would write by id. They answer in-process, with a thrown `Error` carrying `code`, `status` and `statusCode`; it is a test door with no HTTP path. No code is added: `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, so `ApiErrorSchema` accepts exactly what it accepted before.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/verify': minor
3+
---
4+
5+
feat(verify): the handle gains a system-context update door and a predicate update door
6+
7+
Clause-②: yes (widening)
8+
9+
An app's tests reach two more engine write paths through the in-process handle, so a suite no longer calls the booted kernel's `objectql` service by hand for them.
10+
11+
- **The system principal on an update.** `hooks.run(object, 'update', { id, ...fields }, { system: true })` writes one row by id under `{ isSystem: true }`, the context a system job or an integration writes under. No permission gate applies. The bound hooks still run (they see `session.isSystem` and no `userId`), declared validations still refuse, and the record-change trigger fires its flows with no trigger user. The new type is `AsSystem` (`{ system: true }`), exported beside `AsUser`.
12+
- Only `update` takes it. `{ system: true }` on `insert` or `delete`, or beside an `as` token, is refused with `code: 'INVALID_REQUEST'`, `status: 400` before anything is written. Fixture rows are still `seed(object, rows)`, which also skips record triggers.
13+
- **The predicate update.** `hooks.updateWhere(object, where, data, opts)` is the engine's own predicate path, `update(object, data, { where, multi: true, context })`. It writes one payload to every row `where` selects and resolves with the affected-row count. The engine dispatches `beforeUpdate` and `afterUpdate` once per matched row, each bound to that row's own pre-image as `previous`, so record-change flows evaluate and fire per row. No REST door reaches this path: `POST /data/:object/updateMany` writes by id. The caller is `{ as: token }` or `{ system: true }`.
14+
- It refuses with `code: 'INVALID_REQUEST'`, `status: 400`, before the engine is touched, a `where` that is not an object, and a call the engine's own update dispatch would write by id: a `where` that names only an `id`, or an `id` in `data` beside a `where` that selects by nothing else. Write one row by id through `hooks.run(object, 'update', { id, ...fields }, opts)`. An `id` inside a larger predicate (`{ id, status: 'open' }`) stays a predicate update. An `id` in `data` beside a real predicate is refused by the engine itself, as it is on every caller.
15+
16+
Every other refusal from either door is the engine's own error, unchanged: assert on its `code` and `status`.

‎packages/spec/src/api/error-code-ledger.zod.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,6 +1473,10 @@ export const ERROR_CODE_LEDGER = {
14731473
// record.
14741474
'INVALID_ARTIFACT_PACKAGES',
14751475
],
1476+
'@objectstack/verify': [
1477+
// [#22301] An in-process test door, no HTTP path: a malformed call to the handle's update doors throws an Error carrying code / status / statusCode.
1478+
'INVALID_REQUEST',
1479+
],
14761480
} as const satisfies Record<string, readonly string[]>;
14771481

14781482
/** A code registered by at least one package (deduped union of the ledger). */

‎packages/verify/README.md‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ expect(deal.expected_revenue).toBe(6_000); // the hook derived it
9999
await expect(stack.hooks.run('crm_vault', 'insert', { name: 'x' }, { as: rep }))
100100
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
101101

102+
// An update as the system principal (no user): no permission gate, but the
103+
// hooks, the validations and the record-change flows all run.
104+
await stack.hooks.run('crm_opportunity', 'update', { id: deal.id, stage: 'closed_won' }, { system: true });
105+
106+
// A predicate update: one payload for every matched row, hooks and record
107+
// flows once per row with that row's own `previous`. Resolves the count.
108+
const n = await stack.hooks.updateWhere('crm_opportunity', { name: 'Globex' }, { description: 'Bulk note' }, { as: rep });
109+
102110
// A validation rule, without writing.
103111
const verdict = await stack.validate('crm_opportunity', { amount: -1 }, { as: rep });
104112
expect(verdict.valid).toBe(false);
@@ -127,12 +135,17 @@ await stack.stop();
127135
- `as` is always a bearer token minted by `signIn()` / `signUp()` on the same
128136
stack — the handle resolves it through the dispatcher's own identity resolver
129137
(`contextFor(token)` exposes that context for services the handle does not
130-
cover). There is no way to run as "nobody"; `seed` and the default `rows` run
131-
as the system principal, deliberately and by name.
138+
cover). There is no way to run as "nobody"; `seed`, the default `rows` and
139+
an update passed `{ system: true }` (`hooks.run(…, 'update', …)`,
140+
`hooks.updateWhere`) run as the system principal, deliberately and by name.
132141
- A refusal from `flows.*` / `actions.run` is the route's ADR-0112 envelope
133142
(`VerifyRefusal`: `code`, `status`, `details`; `isVerifyRefusal(e)`); a
134-
refusal from `hooks.run` / `validate` / `rows` is the engine's own error.
135-
Assert on `code` (and `status` / `statusCode`), never on a message alone.
143+
refusal from `hooks.run` / `hooks.updateWhere` / `validate` / `rows` is the
144+
engine's own error. The two update doors refuse a malformed call themselves,
145+
before the engine is touched, with `INVALID_REQUEST` / `400` (`{ system: true }`
146+
on an insert or delete, a caller named twice, or a `hooks.updateWhere` the
147+
engine would write by id). Assert on `code` (and `status` / `statusCode`),
148+
never on a message alone.
136149
- Many files, one boot: `bootStackOnce(config, opts?)` memoises `bootStack` per
137150
`(config, opts)` object identity for the life of the process. Share it from
138151
one module, under vitest `isolate: false`, and never `stop()` a stack other
@@ -189,7 +202,7 @@ run" must never read like "nothing to find".
189202
## API
190203

191204
- `bootStack(config, opts?)` → `VerifyStack` (`api` / `raw` / `signIn` / `signUp` / `apiAs` / `stop`, plus the handle:
192-
`hooks.run` / `validate` / `flows.run` / `flows.resume` / `actions.run` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`).
205+
`hooks.run` / `hooks.updateWhere` / `validate` / `flows.run` / `flows.resume` / `actions.run` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`).
193206
- `bootStackOnce(config, opts?)` → the same, memoised per `(config, opts)` identity for the process.
194207
- `deriveCrudCases(config)` → the auto-derived round-trip cases (write one, read one, assert) for every object.
195208
- `runCrudVerification(stack, token, config)` → `VerifyReport`; `formatReport(report)` for a log summary.

‎packages/verify/src/handle.ts‎

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@
1414
// call to the door that owns the semantics, and returns what that door
1515
// returned (or rethrows what it threw). The doors, per method:
1616
//
17-
// hooks.run · validate · seed · rows → the ObjectQL engine (`insert` /
18-
// `update` / `delete` / `validate` / `find`), the SAME calls
19-
// `@objectstack/rest`'s data ingress makes (`protocol.createData` →
17+
// hooks.run · hooks.updateWhere · validate · seed · rows → the ObjectQL
18+
// engine (`insert` / `update` / `delete` / `validate` / `find`), the SAME
19+
// calls `@objectstack/rest`'s data ingress makes (`protocol.createData` →
2020
// `engine.insert(object, data, { context })`, and so on). The bound hook
2121
// chain, the validation pass, the SecurityPlugin middleware (object
2222
// grants, RLS, FLS) all live INSIDE those calls, so they run here exactly
2323
// as they run for a REST write. `handle.test.ts` pins that parity on the
24-
// same row AND the same refusal.
24+
// same row AND the same refusal. [#22301] The two update doors also take
25+
// the system principal by name (`{ system: true }` → `{ isSystem: true }`,
26+
// the context a system job's write carries), and `hooks.updateWhere` is
27+
// the engine's predicate path (`multi: true`), which no REST door reaches:
28+
// `handle.update-doors.test.ts` pins both.
2529
// flows.run / flows.resume · actions.run → the runtime's `HttpDispatcher`,
2630
// driven in-process (no Hono, no socket, no JSON round-trip). The REST
2731
// `/automation` and `/actions` routes are the only doors that carry the
@@ -51,7 +55,7 @@ import { SEED_WRITE_EXECUTION_CONTEXT, type ExecutionContext } from '@objectstac
5155
import type { ValidateDataResponse } from '@objectstack/spec/api';
5256
import type { AutomationResult } from '@objectstack/spec/contracts';
5357
import type { ServiceObject } from '@objectstack/spec/data';
54-
import type { ObjectQL } from '@objectstack/objectql';
58+
import { resolveEngineUpdateDispatch, type ObjectQL } from '@objectstack/objectql';
5559
import type { TenancyService } from '@objectstack/plugin-auth';
5660

5761
/** Any row the engine hands back. Untyped on purpose: the engine's, not the handle's. */
@@ -88,6 +92,24 @@ export interface AsUser {
8892
as: string;
8993
}
9094

95+
/**
96+
* [#22301] Identify the caller as the platform's own SYSTEM principal: no
97+
* user, no session. The write runs under `{ isSystem: true }`, the context a
98+
* system job, an integration or the platform's own automation writes under,
99+
* so it passes no permission gate, and it is still a real write: the bound
100+
* hooks see `session.isSystem` and no `userId`, declared validations run, and
101+
* the record-change trigger fires its flows with no trigger user. (`seed` is
102+
* the other system door, and it is not this one: it also sets `skipTriggers`
103+
* and `seedReplay`, because a fixture is end-state data, not an event.)
104+
*
105+
* Accepted by the UPDATE doors only: `hooks.run(object, 'update', …)` and
106+
* `hooks.updateWhere`. Named, never defaulted: a write that names no caller
107+
* is refused, so a forgotten `as` can never become a system write.
108+
*/
109+
export interface AsSystem {
110+
system: true;
111+
}
112+
91113
/**
92114
* The engine's answer to a flow trigger or resume, plus the flow's name so the
93115
* value can be handed straight back to `flows.resume`. The `AutomationResult`
@@ -141,6 +163,41 @@ export interface VerifyHandle {
141163
input: EngineRow,
142164
opts: AsUser,
143165
): Promise<EngineRow>;
166+
/**
167+
* [#22301] The same by-id `update` as the SYSTEM principal ({@link AsSystem}):
168+
* `update(object, { ...input, id }, { where: { id }, context: { isSystem:
169+
* true } })`. No permission gate; the hooks, the declared validations and
170+
* the record-change trigger run as they do for any system write.
171+
*
172+
* Only `update` takes `{ system: true }`. A system insert of fixture rows
173+
* is `seed`; a system insert or delete that fires record triggers has no
174+
* door here, and asking for one is refused (`INVALID_REQUEST` / `400`).
175+
*/
176+
run(object: string, operation: 'update', input: EngineRow, opts: AsSystem): Promise<EngineRow>;
177+
/**
178+
* [#22301] A PREDICATE update: one payload written to every row `where`
179+
* selects. This is the engine's own predicate path, `update(object, data,
180+
* { where, multi: true, context })`, and no REST door reaches it (`POST
181+
* /data/:object/updateMany` writes by id, one row at a time). The engine
182+
* dispatches the `beforeUpdate` and `afterUpdate` hooks once per matched
183+
* row, each bound to THAT row's pre-image as `previous` (ADR-0058's bulk
184+
* addendum), so the record-change trigger evaluates and fires per row.
185+
* Resolves with the affected-row count the engine answers.
186+
*
187+
* `where` is the engine's object-form filter (`{ name: 'x' }`, `{ stage:
188+
* { $ne: 'won' } }`); `{}` matches every row. The caller is a person
189+
* (`{ as }`) or the system (`{ system: true }`).
190+
*
191+
* Refused before the engine is touched (`INVALID_REQUEST` / `400`): a
192+
* `where` that is not an object, and a call the engine's own update
193+
* dispatch would write by id instead: a `where` that names only an `id`,
194+
* or an `id` in `data` beside a `where` that selects by nothing else. One
195+
* row by id is `hooks.run(object, 'update', { id, ...fields }, opts)`.
196+
* Every other refusal (permission, validation, a hook's throw, the
197+
* dispatch refusing an `id` in `data` beside a real predicate) is the
198+
* engine's own error, rethrown unchanged.
199+
*/
200+
updateWhere(object: string, where: EngineRow, data: EngineRow, opts: AsUser | AsSystem): Promise<number>;
144201
};
145202

146203
/**
@@ -246,6 +303,24 @@ const API_PREFIX = '/api/v1';
246303
const SEED_CONTEXT: ExecutionContext = SEED_WRITE_EXECUTION_CONTEXT;
247304
const SYSTEM_CONTEXT: ExecutionContext = { isSystem: true } as ExecutionContext;
248305

306+
/**
307+
* [#22301] A call the handle refuses for its own SHAPE, before any caller is
308+
* resolved or the engine is touched. The ADR-0112 pair is the assertion
309+
* surface (`statusCode` mirrors `status`, as on a `VerifyRefusal`).
310+
*/
311+
function callShapeRefusal(message: string): Error & { code: string; status: number; statusCode: number } {
312+
return Object.assign(new Error(message), { code: 'INVALID_REQUEST', status: 400, statusCode: 400 });
313+
}
314+
315+
/** `true` when `opts` names the system principal; refuses a call that names two callers. */
316+
function namesSystem(opts: AsUser | AsSystem, door: string): boolean {
317+
const system = (opts as Partial<AsSystem> | undefined)?.system === true;
318+
if (system && (opts as Partial<AsUser>).as !== undefined) {
319+
throw callShapeRefusal(`verify: ${door} takes ONE caller: { as: token } or { system: true }, not both`);
320+
}
321+
return system;
322+
}
323+
249324
function refusalFrom(status: number, body: unknown, fallback: string): VerifyRefusal {
250325
// eslint-disable-next-line @typescript-eslint/no-explicit-any
251326
const b = body as any;
@@ -335,15 +410,25 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis
335410
contextFor,
336411

337412
hooks: {
338-
async run(object, operation, input, opts) {
413+
// Parameters annotated because the member is overloaded (an overload
414+
// set gives no contextual parameter types); the return type is left to
415+
// inference, as it was before the overload.
416+
async run(object: string, operation: 'insert' | 'update' | 'delete', input: EngineRow, opts: AsUser | AsSystem) {
339417
// The call's own shape is judged before anyone is resolved: a
340418
// malformed call is refused for its own reason, not for whatever the
341419
// identity resolver says about the token.
342420
if (operation !== 'insert' && operation !== 'update' && operation !== 'delete') {
343421
throw new Error(`verify: hooks.run operation must be 'insert' | 'update' | 'delete', got '${String(operation)}'`);
344422
}
423+
const system = namesSystem(opts, 'hooks.run');
424+
if (system && operation !== 'update') {
425+
throw callShapeRefusal(
426+
`verify: hooks.run takes { system: true } on 'update' only, got '${operation}'. ` +
427+
'A system insert of fixture rows is seed(object, rows); a system insert or delete that fires record triggers has no handle door.',
428+
);
429+
}
345430
const id = operation === 'insert' ? undefined : requireId(input, operation);
346-
const context = await contextFor(opts.as);
431+
const context = system ? { ...SYSTEM_CONTEXT } : await contextFor((opts as AsUser).as);
347432
const ql = await engine();
348433
switch (operation) {
349434
case 'insert':
@@ -356,6 +441,29 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis
356441
return ql.delete(object, { where: { id }, context });
357442
}
358443
},
444+
445+
async updateWhere(object, where, data, opts) {
446+
if (where === null || typeof where !== 'object' || Array.isArray(where)) {
447+
throw callShapeRefusal(
448+
'verify: hooks.updateWhere(object, where, data, opts) takes `where` as an object filter; `{}` matches every row',
449+
);
450+
}
451+
// The engine's OWN dispatch ladder (the one `ObjectQL.update` resolves
452+
// first), asked rather than re-derived: a call it would route to the
453+
// by-id path is not a predicate update, and answering it here would
454+
// hand back a row where this door promises a count.
455+
if (resolveEngineUpdateDispatch(data, { where, multi: true }).kind === 'by-id') {
456+
throw callShapeRefusal(
457+
"verify: hooks.updateWhere is the engine's predicate path, but this call addresses one row by id " +
458+
"(a `where` naming only an id, or an id in `data` beside a `where` that selects by nothing else), " +
459+
'which the engine writes by id. ' +
460+
"Update one row through hooks.run(object, 'update', { id, ...fields }, opts).",
461+
);
462+
}
463+
const context = namesSystem(opts, 'hooks.updateWhere') ? { ...SYSTEM_CONTEXT } : await contextFor((opts as AsUser).as);
464+
const ql = await engine();
465+
return (await ql.update(object, data, { where, multi: true, context })) as number;
466+
},
359467
},
360468

361469
async validate(object, record, opts) {

0 commit comments

Comments
 (0)