Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/22301-spec-ledger-verify-provenance.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': minor
---
Expand All @@ -6,4 +6,4 @@

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

`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.
`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.
14 changes: 14 additions & 0 deletions .changeset/22301-verify-system-insert-delete-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/verify': minor
---

feat(verify): `hooks.run` takes the system principal on an insert and a delete, so a user-less record trigger can be driven

Clause-②: yes (widening)

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.

- **`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.
- **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.
- **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.
- 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`.
2 changes: 1 addition & 1 deletion .changeset/22301-verify-update-doors.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/verify': minor
---
Expand All @@ -9,7 +9,7 @@
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.

- **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`.
- 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.
- `{ 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.)
- **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 }`.
- 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.

Expand Down
25 changes: 16 additions & 9 deletions packages/verify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,14 @@ expect(deal.expected_revenue).toBe(6_000); // the hook derived it
await expect(stack.hooks.run('crm_vault', 'insert', { name: 'x' }, { as: rep }))
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });

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

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