Skip to content

Commit b4ce5e2

Browse files
feat(verify): hooks.run takes the system principal on an insert and a delete (#22596)
Part of #22301 Clause-②: yes (widening) Item 5 of the card: a user-less record trigger on an insert or a delete, and a flow over a record the engine no longer holds. Items 6 and 7 and item 1's remaining composition gap are not in this PR, and the card stays open for them. ## What changes - `hooks.run(object, 'insert' | 'delete', input, { system: true })` now runs. It is the same engine call a person's write makes (`ql.insert(object, input, { context })`, `ql.delete(object, { where: { id }, context })`), with `context` set to `{ isSystem: true }` (the handle's existing `SYSTEM_CONTEXT`). This is the write an integration or a system job makes. Until now `hooks.run` refused `{ system: true }` on anything but `update`, with `INVALID_REQUEST` / `400`, and that refusal is removed. - The `AsSystem` overload of `hooks.run` widens from `'update'` to `'insert' | 'update' | 'delete'`. Its JSDoc says what runs: the hooks (they see `session.isSystem` and no `userId`), the declared validations, and the record-change trigger with no trigger user. It also says what does not run: the permission gate. It says what a delete-triggered flow is handed, and how this door differs from `seed`, which sets `skipTriggers`. - A call naming two callers (`{ as, system: true }`) is still refused with `INVALID_REQUEST` / `400` before the engine is touched, on every operation. - `packages/verify/README.md`: the handle example and the caller and refusal bullets. - `.changeset/22301-verify-system-insert-delete-door.md`: `@objectstack/verify` `minor`, `Clause-②: yes (widening)`. - **Declared deviation:** two pending release notes from PR #22517 (items 2 and 3, not yet released) said this PR's door was refused, and both are corrected in this change. `.changeset/22301-verify-update-doors.md` said "Only `update` takes it. `{ system: true }` on `insert` or `delete` … is refused"; it now keeps only the two-caller refusal and points at this entry. `.changeset/22301-spec-ledger-verify-provenance.md` gave "`{ system: true }` on an insert or delete" as an `INVALID_REQUEST` example; it now names a call naming two callers (`{ as: token, system: true }`) and calls `hooks.run` and `hooks.updateWhere` the two write doors (patch round, after the contract review FAIL `6093381495`). `check-empty-changeset` and `Check Changeset` are RED on these two `M` rows by design (the "DELIBERATE CORRECTION" class). The same-head contract review PASS `6093488852` names both notes and judges each rewritten sentence, which is the confirmation the landing rule takes. ## What was measured before the door was opened These were measured on a booted stack (`requires: ['automation', 'triggers']`) at `d6c37919c7`. A scratch probe (since deleted) called the booted `objectql` with `{ isSystem: true }`, which is exactly the call the door would make. 1. On `origin/main`, the refusal sat at `handle.ts:486`–`:491`, and the JSDoc said so at `:201`–`:203`. The `insert` and `delete` arms already made the engine calls above, so lifting the refusal hands those calls `SYSTEM_CONTEXT`. Confirmed. 2. **A system insert and a system delete each fire the record-change trigger.** No dispatch was invented: the engine's own hook chain fired it. - On a system insert, `beforeInsert` and `afterInsert` see `isSystem: true` and no `userId`. A `record-after-create` flow runs (`completed`), with trigger `{ type: 'record-after-create', object, recordId }` and no `userId`. - On a system delete, `beforeDelete` and `afterDelete` see `isSystem: true`, no `userId`, and the pre-image as `previous`. A `record-after-delete` flow runs (`completed`) with no trigger `userId`. - Only `skipTriggers` suppresses the trigger (`trigger-record-change/src/record-change-trigger.ts:291`); `isSystem` does not. 3. **A record the engine no longer holds.** A `record-after-delete` flow is handed the pre-image as `record` and as `previous` (both read `status: 'closing'`), and its `get_record` of `{record.id}` answers no row. On the same delete, a `record-before-delete` flow's identical `get_record` finds the row. The system delete door reaches this with no further door. 4. `seed` is unchanged and still skips record-change flows (pinned below). Other readings from the same probe: - `ql.delete` answers `true` for a system delete and for a person's delete alike. See the Acceptance notes. - A `readonly: true` field is KEPT by a system insert and stripped from an admin's insert. ## Pins: `packages/verify/src/handle.system-insert-delete.test.ts` (7 tests) The fixture is neutral. `sid_case` can be read, but not created or deleted, by a fresh member. It has one capture hook and three record-change flows: on create, before a delete, and after a delete. The two delete flows read the case back with `get_record` and write `held` or `gone` to a ledger. - **System insert:** - The member's insert is refused `PERMISSION_DENIED` / `403` and leaves no row; the system insert writes the row. - The hooks see the system and no user. The `record-after-create` flow runs with no trigger user and writes its ledger row. - Controls: an admin's insert carries `adminId` at the hook and on the run, and `seed` of the same shape fires no run. - **Validation:** a declared validation rule still refuses a system insert with `VALIDATION_FAILED` and leaves no row. Control: an in-range value is written. - **System delete:** - The member's delete is refused `PERMISSION_DENIED` / `403` and leaves the row; the system delete removes it. - The hooks see the system, no user and the pre-image. The `record-after-delete` flow runs with no trigger user. - Control: an admin's delete carries `adminId` on the run. - **Deleted-record read:** the after-delete flow's ledger reads `record_status: 'closing'`, `previous_status: 'closing'`, `held: 'gone'`. The before-delete flow on the same delete reads `held: 'held'`, the control showing `gone` is the engine's answer about a deleted row. - **Two callers** (`{ as, system: true }`) on an insert and on a delete: refused `INVALID_REQUEST` / `400`, with no row written or deleted, no hook dispatched and no flow run. `handle.update-doors.test.ts`: the old pin "the system is accepted on update only" asserted the refusal this PR removes. Its insert and delete legs are dropped, since they are now doors pinned above. Its two-caller leg on `update` stays. ## Ablations Both ran on the committed head `915f88e526`, through `node scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob changed). Each was restored to `HEAD` blob `a6e175d1c0d3` with `git diff HEAD` empty. The direction of each was predicted before the run. - **A1, the update-only refusal put back** (`throw callShapeRefusal(…)` when `system && operation !== 'update'`). Predicted: the 6 system insert and delete pins red, the two-caller pin and all 9 update-door pins green. Observed: 6 failed, 10 passed, and each failure is the ablation's own refusal (6 hits of its message in the log). - The first attempt was refused by the tool before anything ran: its replacement contained the anchor. It was re-anchored and re-run, and that first attempt is not counted. - A second attempt never acquired the verify lock (`queue-timeout`) and is NOT MEASURED. It was restored by the tool. - **A2, the seed context handed instead of the system context** in `hooks.run`. Predicted: the 3 flow-run pins red here, plus the item 2 flow pin in `handle.update-doors.test.ts`; the row-level pins green. Observed: 4 failed, 12 passed, exactly those four. ## Verification All at `84a4a1e07f`, the final head (a merge of `origin/main` `25be87612d`, which touches none of this PR's paths), unless named otherwise. - **Dependency closure:** `pnpm exec turbo run build --filter='@objectstack/verify^...' --concurrency=2` rebuilt after the merge, 46/46 tasks. - **`@objectstack/verify` full suite:** `vitest run --maxWorkers=2` passed, 27 files / 216 tests. - **`pnpm --filter @objectstack/verify typecheck`** (`tsc --noEmit` and `check:test-typecheck`) exited 0, at `915f88e526` and again at `84a4a1e07f`. `tsc -p tsconfig.test.json --listFiles` lists 27/27 of the package's test files, the new one included. - **Consumer typecheck (`packages/qa/dogfood`):** the `@objectstack/dogfood^...` closure was built (63/63; `@objectstack/verify` rebuilt as a cache miss, and its `dist/index.d.ts` carries the widened overload 1 time and the old one 0 times), then `pnpm --filter @objectstack/dogfood typecheck` exited 0 at `915f88e526`. The dogfood fake stack types `hooks` as `never`, so an overload change cannot break it. - **Reverse check against the rebuilt `.d.ts`, from dogfood:** a scratch file calling `hooks.run('o', 'insert' | 'delete', …, { system: true })` compiled (exit 0). Adding `'upsert'` turned `tsc` red with TS2769 "No overload matches this call". The file was removed and `git status` is clean. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths gives 63 commands; the 4 extra families from the dispatch-time list (spec `check:empty-state`, `check:liveness`, `check:strictness-ledger`, `check:variant-docs`) also ran. - 61 exited 0. One is red: `check-empty-changeset`, the deliberate correction above. - `check:dual-build-cjs-loads` is NOT MEASURED: it needs a whole-workspace build, which this dispatch excludes, so it is left to CI. - `--ran` reconciliation over the exit-coded record: 63 derived, 62 run, 1 NOT-MEASURED, 0 UNRUN. - **The artifact-roster block:** see the report on the card. ## Acceptance notes - **The `delete` arm's return type.** `hooks.run(…, 'delete', …)` is typed as a promise of `EngineRow`, and the engine answers `true`, measured for a person's delete and a system delete. The type is older than this PR on the person overload, and the system overload repeats it. Nothing reads a field of it: in hotcrm `f0afcbd`, no `hooks.run(…, 'delete', …)` call site reads the answer beyond asserting that it is defined. Noted, not filed. - **Carried from items 2 and 3, unchanged here:** - the engine's `ValidationError` has no `status` at the engine door; - `requireId`'s and the bad-operation call-shape errors carry no `code`. - **Not served by this door:** an UPDATE-triggered flow over a row that vanished between the trigger and the flow's `get_record`. The engine runs a record-change flow inside the write, while it still holds the row (the synchronous run log), so no write produces that state. The card's report carries this as an open question with options. ## For hotcrm (`runRecordFlow`, `test/helpers/verify-stack.ts:255`; read at hotcrm `f0afcbd`) The helper has 8 call sites in 2 files: - **Served by the system insert door:** `test/flow-record-change.test.ts:633`, `:647`, `:684` and `:689`. Each is an integration-written lead or contact whose record-after-create flow must run with no trigger user. - **Likely served, not measured on hotcrm:** - `:249`, an ownerless contact. The owner stamp is the person's; a system insert leaves an unset owner null in the fixture. - `:500` and `:501`, a deal born `pending`. If `approval_status` is held by `readonly: true`, the fixture reading above says a system insert keeps it. - **Not served:** `test/flow-escalation-ownerless-case.test.ts:233`, the ghost case: an update-triggered flow over a row the engine does not hold. See the note above. --- _Generated by [Claude Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 73d7001 commit b4ce5e2

7 files changed

Lines changed: 456 additions & 46 deletions

‎.changeset/22301-spec-ledger-verify-provenance.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@ feat(spec): the error-code ledger lists `INVALID_REQUEST` under `@objectstack/ve
66

77
Clause-②: yes (widening: a new owner provenance row in the published error-code ledger)
88

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.
9+
`ERROR_CODE_LEDGER['@objectstack/verify']` now lists `INVALID_REQUEST`. The verify handle's two write doors, `hooks.run` and `hooks.updateWhere`, answer it for a malformed call, such as a call naming two callers (`{ as: token, system: true }`), 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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/verify': minor
3+
---
4+
5+
feat(verify): `hooks.run` takes the system principal on an insert and a delete, so a user-less record trigger can be driven
6+
7+
Clause-②: yes (widening)
8+
9+
An app's tests can drive a record-change flow fired by a write with no user (an integration's or a system job's insert or delete) through the in-process handle, so a suite no longer hands a flow a record through the booted kernel's `automation` service by hand.
10+
11+
- **`hooks.run(object, 'insert' | 'delete', input, { system: true })`** is the engine's own `insert` / `delete` under `{ isSystem: true }`, the same call the person's write makes with only the context changed. `{ system: true }` on `update` is unchanged. 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 the object's flows with no trigger user.
12+
- **A record the engine no longer holds.** A system (or a person's) delete hands a `record-after-delete` flow the row's pre-image as `record` and as `previous`, and by then the engine no longer holds the row: the flow's `get_record` of that id finds nothing. A `record-before-delete` flow on the same delete still finds it.
13+
- **Not `seed`.** `seed(object, rows)` also runs as the system, and it sets `skipTriggers`, so it fires no record-change flow: use it for end-state fixture data, and `{ system: true }` for a write the app should react to.
14+
- A call naming both callers (`{ as: token, system: true }`) is still refused with `code: 'INVALID_REQUEST'`, `status: 400`, before anything is written. Every other refusal is the engine's own error, unchanged: assert on its `code` and `status`.

‎.changeset/22301-verify-update-doors.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Clause-②: yes (widening)
99
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.
1010

1111
- **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.
12+
- `{ system: true }` 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. (`insert` and `delete` take `{ system: true }` too: see the system insert and delete entry.)
1313
- **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 }`.
1414
- 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.
1515

‎packages/verify/README.md‎

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

103-
// An update as the system principal (no user): no permission gate, but the
104-
// hooks, the validations and the record-change flows all run.
103+
// A write as the system principal (no user), the write an integration or a
104+
// system job makes: no permission gate, but the hooks, the validations and the
105+
// record-change flows all run, with no trigger user. Insert, update or delete.
105106
await stack.hooks.run('crm_opportunity', 'update', { id: deal.id, stage: 'closed_won' }, { system: true });
107+
const lead = await stack.hooks.run('crm_lead', 'insert', { last_name: 'Ada', company: 'Globex' }, { system: true });
108+
// A delete-triggered flow is handed the pre-image, and the engine no longer
109+
// holds the row: its `get_record` of that id finds nothing.
110+
await stack.hooks.run('crm_lead', 'delete', { id: lead.id }, { system: true });
106111

107112
// A predicate update: one payload for every matched row, hooks and record
108113
// flows once per row with that row's own `previous`. Resolves the count.
@@ -144,19 +149,21 @@ await stack.stop();
144149
stack — the handle resolves it through the dispatcher's own identity resolver
145150
(`contextFor(token)` exposes that context for services the handle does not
146151
cover). There is no way to run as "nobody"; `seed`, the default `rows` and
147-
an update passed `{ system: true }` (`hooks.run(…, 'update', …)`,
148-
`hooks.updateWhere`) run as the system principal, deliberately and by name.
152+
a write passed `{ system: true }` (`hooks.run` on `insert`, `update` or
153+
`delete`, and `hooks.updateWhere`) run as the system principal, deliberately
154+
and by name. `seed` fires no record-change flow (it is end-state fixture
155+
data); a `{ system: true }` write does.
149156
- A refusal from `flows.*` / `actions.run` is the route's ADR-0112 envelope
150157
(`VerifyRefusal`: `code`, `status`, `details`; `isVerifyRefusal(e)`); a
151158
refusal from `hooks.run` / `hooks.updateWhere` / `validate` / `rows` /
152159
`automation.evaluateCondition` is the engine's own error (a condition the
153160
engine cannot evaluate rejects, never answers `false`; on a stack with no
154161
automation service, `automation.evaluateCondition` rejects with the kernel's
155-
own `SERVICE_NOT_REGISTERED` error naming `automation`). The two update doors refuse a malformed call themselves,
156-
before the engine is touched, with `INVALID_REQUEST` / `400` (`{ system: true }`
157-
on an insert or delete, a caller named twice, or a `hooks.updateWhere` the
158-
engine would write by id). Assert on `code` (and `status` / `statusCode`),
159-
never on a message alone.
162+
own `SERVICE_NOT_REGISTERED` error naming `automation`). `hooks.run` and
163+
`hooks.updateWhere` refuse a malformed call themselves, before the engine is
164+
touched, with `INVALID_REQUEST` / `400` (a caller named twice, or a
165+
`hooks.updateWhere` the engine would write by id). Assert on `code` (and
166+
`status` / `statusCode`), never on a message alone.
160167
- Many files, one boot: `bootStackOnce(config, opts?)` memoises `bootStack` per
161168
`(config, opts)` object identity for the life of the process. Share it from
162169
one module, under vitest `isolate: false`, and never `stop()` a stack other

0 commit comments

Comments
 (0)