From 711502ed044466284b935b4a3009a3777ac43945 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:04:52 +0000 Subject: [PATCH 1/4] feat(verify): the handle's system-context update and predicate update doors (WIP) hooks.run takes { system: true } on 'update' (the { isSystem: true } context a system job writes under), and hooks.updateWhere is the engine's predicate path (multi: true), refused when the engine's own dispatch would write by id. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/verify/src/handle.ts | 121 ++++++++++++++++++++++++++++++++-- packages/verify/src/index.ts | 2 +- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/packages/verify/src/handle.ts b/packages/verify/src/handle.ts index 25fb58bbc96..bdcd3e9b861 100644 --- a/packages/verify/src/handle.ts +++ b/packages/verify/src/handle.ts @@ -14,14 +14,18 @@ // call to the door that owns the semantics, and returns what that door // returned (or rethrows what it threw). The doors, per method: // -// hooks.run · validate · seed · rows → the ObjectQL engine (`insert` / -// `update` / `delete` / `validate` / `find`), the SAME calls -// `@objectstack/rest`'s data ingress makes (`protocol.createData` → +// hooks.run · hooks.updateWhere · validate · seed · rows → the ObjectQL +// engine (`insert` / `update` / `delete` / `validate` / `find`), the SAME +// calls `@objectstack/rest`'s data ingress makes (`protocol.createData` → // `engine.insert(object, data, { context })`, and so on). The bound hook // chain, the validation pass, the SecurityPlugin middleware (object // grants, RLS, FLS) all live INSIDE those calls, so they run here exactly // as they run for a REST write. `handle.test.ts` pins that parity on the -// same row AND the same refusal. +// same row AND the same refusal. [#22301] The two update doors also take +// the system principal by name (`{ system: true }` → `{ isSystem: true }`, +// the context a system job's write carries), and `hooks.updateWhere` is +// the engine's predicate path (`multi: true`), which no REST door reaches: +// `handle.update-doors.test.ts` pins both. // flows.run / flows.resume · actions.run → the runtime's `HttpDispatcher`, // driven in-process (no Hono, no socket, no JSON round-trip). The REST // `/automation` and `/actions` routes are the only doors that carry the @@ -51,7 +55,7 @@ import { SEED_WRITE_EXECUTION_CONTEXT, type ExecutionContext } from '@objectstac import type { ValidateDataResponse } from '@objectstack/spec/api'; import type { AutomationResult } from '@objectstack/spec/contracts'; import type { ServiceObject } from '@objectstack/spec/data'; -import type { ObjectQL } from '@objectstack/objectql'; +import { resolveEngineUpdateDispatch, type ObjectQL } from '@objectstack/objectql'; import type { TenancyService } from '@objectstack/plugin-auth'; /** Any row the engine hands back. Untyped on purpose: the engine's, not the handle's. */ @@ -88,6 +92,24 @@ export interface AsUser { as: string; } +/** + * [#22301] Identify the caller as the platform's own SYSTEM principal: no + * user, no session. The write runs under `{ isSystem: true }`, the context a + * system job, an integration or the platform's own automation writes under, + * so it passes no permission gate, and it is still a real write: the bound + * hooks see `session.isSystem` and no `userId`, declared validations run, and + * the record-change trigger fires its flows with no trigger user. (`seed` is + * the other system door, and it is not this one: it also sets `skipTriggers` + * and `seedReplay`, because a fixture is end-state data, not an event.) + * + * Accepted by the UPDATE doors only: `hooks.run(object, 'update', …)` and + * `hooks.updateWhere`. Named, never defaulted: a write that names no caller + * is refused, so a forgotten `as` can never become a system write. + */ +export interface AsSystem { + system: true; +} + /** * The engine's answer to a flow trigger or resume, plus the flow's name so the * value can be handed straight back to `flows.resume`. The `AutomationResult` @@ -141,6 +163,39 @@ export interface VerifyHandle { input: EngineRow, opts: AsUser, ): Promise; + /** + * [#22301] The same by-id `update` as the SYSTEM principal ({@link AsSystem}): + * `update(object, { ...input, id }, { where: { id }, context: { isSystem: + * true } })`. No permission gate; the hooks, the declared validations and + * the record-change trigger run as they do for any system write. + * + * Only `update` takes `{ system: true }`. A system insert of fixture rows + * is `seed`; a system insert or delete that fires record triggers has no + * door here, and asking for one is refused (`INVALID_REQUEST` / `400`). + */ + run(object: string, operation: 'update', input: EngineRow, opts: AsSystem): Promise; + /** + * [#22301] A PREDICATE update: one payload written to every row `where` + * selects. This is the engine's own predicate path, `update(object, data, + * { where, multi: true, context })`, and no REST door reaches it (`POST + * /data/:object/updateMany` writes by id, one row at a time). The engine + * dispatches the `beforeUpdate` and `afterUpdate` hooks once per matched + * row, each bound to THAT row's pre-image as `previous` (ADR-0058's bulk + * addendum), so the record-change trigger evaluates and fires per row. + * Resolves with the affected-row count the engine answers. + * + * `where` is the engine's object-form filter (`{ name: 'x' }`, `{ stage: + * { $ne: 'won' } }`); `{}` matches every row. The caller is a person + * (`{ as }`) or the system (`{ system: true }`). + * + * Refused before the engine is touched (`INVALID_REQUEST` / `400`): a + * `where` that is not an object, and a call the engine's own dispatch + * would route to its by-id path instead (an `id` in `data`, or a `where` + * that names only an `id`). One row by id is `hooks.run(object, 'update', + * { id, ...fields }, opts)`. Every other refusal (permission, validation, + * a hook's throw) is the engine's own error, rethrown unchanged. + */ + updateWhere(object: string, where: EngineRow, data: EngineRow, opts: AsUser | AsSystem): Promise; }; /** @@ -246,6 +301,24 @@ const API_PREFIX = '/api/v1'; const SEED_CONTEXT: ExecutionContext = SEED_WRITE_EXECUTION_CONTEXT; const SYSTEM_CONTEXT: ExecutionContext = { isSystem: true } as ExecutionContext; +/** + * [#22301] A call the handle refuses for its own SHAPE, before any caller is + * resolved or the engine is touched. The ADR-0112 pair is the assertion + * surface (`statusCode` mirrors `status`, as on a `VerifyRefusal`). + */ +function callShapeRefusal(message: string): Error & { code: string; status: number; statusCode: number } { + return Object.assign(new Error(message), { code: 'INVALID_REQUEST', status: 400, statusCode: 400 }); +} + +/** `true` when `opts` names the system principal; refuses a call that names two callers. */ +function namesSystem(opts: AsUser | AsSystem, door: string): boolean { + const system = (opts as Partial | undefined)?.system === true; + if (system && (opts as Partial).as !== undefined) { + throw callShapeRefusal(`verify: ${door} takes ONE caller: { as: token } or { system: true }, not both`); + } + return system; +} + function refusalFrom(status: number, body: unknown, fallback: string): VerifyRefusal { // eslint-disable-next-line @typescript-eslint/no-explicit-any const b = body as any; @@ -335,15 +408,27 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis contextFor, hooks: { - async run(object, operation, input, opts) { + async run( + object: string, + operation: 'insert' | 'update' | 'delete', + input: EngineRow, + opts: AsUser | AsSystem, + ): Promise { // The call's own shape is judged before anyone is resolved: a // malformed call is refused for its own reason, not for whatever the // identity resolver says about the token. if (operation !== 'insert' && operation !== 'update' && operation !== 'delete') { throw new Error(`verify: hooks.run operation must be 'insert' | 'update' | 'delete', got '${String(operation)}'`); } + const system = namesSystem(opts, 'hooks.run'); + if (system && operation !== 'update') { + throw callShapeRefusal( + `verify: hooks.run takes { system: true } on 'update' only, got '${operation}'. ` + + 'A system insert of fixture rows is seed(object, rows); a system insert or delete that fires record triggers has no handle door.', + ); + } const id = operation === 'insert' ? undefined : requireId(input, operation); - const context = await contextFor(opts.as); + const context = system ? { ...SYSTEM_CONTEXT } : await contextFor((opts as AsUser).as); const ql = await engine(); switch (operation) { case 'insert': @@ -356,6 +441,28 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis return ql.delete(object, { where: { id }, context }); } }, + + async updateWhere(object, where, data, opts) { + if (where === null || typeof where !== 'object' || Array.isArray(where)) { + throw callShapeRefusal( + 'verify: hooks.updateWhere(object, where, data, opts) takes `where` as an object filter; `{}` matches every row', + ); + } + // The engine's OWN dispatch ladder (the one `ObjectQL.update` resolves + // first), asked rather than re-derived: a call it would route to the + // by-id path is not a predicate update, and answering it here would + // hand back a row where this door promises a count. + if (resolveEngineUpdateDispatch(data, { where, multi: true }).kind === 'by-id') { + throw callShapeRefusal( + "verify: hooks.updateWhere is the engine's predicate path, but this call addresses one row by id " + + "(an id in `data`, or a `where` naming only an id), which the engine writes by id. " + + "Update one row through hooks.run(object, 'update', { id, ...fields }, opts).", + ); + } + const context = namesSystem(opts, 'hooks.updateWhere') ? { ...SYSTEM_CONTEXT } : await contextFor((opts as AsUser).as); + const ql = await engine(); + return (await ql.update(object, data, { where, multi: true, context })) as number; + }, }, async validate(object, record, opts) { diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index e96ce86d2a5..246f066f868 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -13,7 +13,7 @@ export type { VerifyStack, BootOptions } from './harness.js'; // The in-process handle on the booted stack (hotcrm#1579 step 5a): every // `VerifyStack` carries it; these are its types and its one predicate. export { isVerifyRefusal } from './handle.js'; -export type { VerifyHandle, VerifyRefusal, AsUser, FlowRun, FlowRunRef, EngineRow } from './handle.js'; +export type { VerifyHandle, VerifyRefusal, AsUser, AsSystem, FlowRun, FlowRunRef, EngineRow } from './handle.js'; export { deriveCrudCases, fillRelationalRefs } from './derive.js'; export type { CrudCase, DerivedAssert, AssertKind, RelationalRef } from './derive.js'; From 992de817ea43f026a8626c692e1d7d24f5b32665 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:32:11 +0000 Subject: [PATCH 2/4] test(verify): pin the system update door and the predicate update door on the real engine Each claim is read off what only the engine produces on that path: the permission gate's refusal and its absence for the system, the hook chain's caller, per-row pre-image and dispatch verdict, the declared validation's refusal, and the record-change flow's runs and writes. Docs and changeset. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .changeset/22301-verify-update-doors.md | 16 + packages/verify/README.md | 23 +- packages/verify/src/handle.ts | 25 +- .../verify/src/handle.update-doors.test.ts | 451 ++++++++++++++++++ 4 files changed, 498 insertions(+), 17 deletions(-) create mode 100644 .changeset/22301-verify-update-doors.md create mode 100644 packages/verify/src/handle.update-doors.test.ts diff --git a/.changeset/22301-verify-update-doors.md b/.changeset/22301-verify-update-doors.md new file mode 100644 index 00000000000..e991d4a1566 --- /dev/null +++ b/.changeset/22301-verify-update-doors.md @@ -0,0 +1,16 @@ +--- +'@objectstack/verify': minor +--- + +feat(verify): the handle gains a system-context update door and a predicate update door + +Clause-②: yes (widening) + +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. +- **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. + +Every other refusal from either door is the engine's own error, unchanged: assert on its `code` and `status`. diff --git a/packages/verify/README.md b/packages/verify/README.md index 59ed3bfb45a..b2ea50d67e0 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -99,6 +99,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. +await stack.hooks.run('crm_opportunity', 'update', { id: deal.id, stage: 'closed_won' }, { 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. +const n = await stack.hooks.updateWhere('crm_opportunity', { name: 'Globex' }, { description: 'Bulk note' }, { as: rep }); + // A validation rule, without writing. const verdict = await stack.validate('crm_opportunity', { amount: -1 }, { as: rep }); expect(verdict.valid).toBe(false); @@ -127,12 +135,17 @@ await stack.stop(); - `as` is always a bearer token minted by `signIn()` / `signUp()` on the same 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` and the default `rows` run - as the system principal, deliberately and by name. + 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 refusal from `flows.*` / `actions.run` is the route's ADR-0112 envelope (`VerifyRefusal`: `code`, `status`, `details`; `isVerifyRefusal(e)`); a - refusal from `hooks.run` / `validate` / `rows` is the engine's own error. - Assert on `code` (and `status` / `statusCode`), never on a message alone. + refusal from `hooks.run` / `hooks.updateWhere` / `validate` / `rows` is the + engine's own error. 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. - 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 @@ -189,7 +202,7 @@ run" must never read like "nothing to find". ## API - `bootStack(config, opts?)` → `VerifyStack` (`api` / `raw` / `signIn` / `signUp` / `apiAs` / `stop`, plus the handle: - `hooks.run` / `validate` / `flows.run` / `flows.resume` / `actions.run` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`). + `hooks.run` / `hooks.updateWhere` / `validate` / `flows.run` / `flows.resume` / `actions.run` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`). - `bootStackOnce(config, opts?)` → the same, memoised per `(config, opts)` identity for the process. - `deriveCrudCases(config)` → the auto-derived round-trip cases (write one, read one, assert) for every object. - `runCrudVerification(stack, token, config)` → `VerifyReport`; `formatReport(report)` for a log summary. diff --git a/packages/verify/src/handle.ts b/packages/verify/src/handle.ts index bdcd3e9b861..db78a676bf6 100644 --- a/packages/verify/src/handle.ts +++ b/packages/verify/src/handle.ts @@ -189,11 +189,13 @@ export interface VerifyHandle { * (`{ as }`) or the system (`{ system: true }`). * * Refused before the engine is touched (`INVALID_REQUEST` / `400`): a - * `where` that is not an object, and a call the engine's own dispatch - * would route to its by-id path instead (an `id` in `data`, or a `where` - * that names only an `id`). One row by id is `hooks.run(object, 'update', - * { id, ...fields }, opts)`. Every other refusal (permission, validation, - * a hook's throw) is the engine's own error, rethrown unchanged. + * `where` that is not an object, and a call the engine's own update + * dispatch would write by id instead: a `where` that names only an `id`, + * or an `id` in `data` beside a `where` that selects by nothing else. One + * row by id is `hooks.run(object, 'update', { id, ...fields }, opts)`. + * Every other refusal (permission, validation, a hook's throw, the + * dispatch refusing an `id` in `data` beside a real predicate) is the + * engine's own error, rethrown unchanged. */ updateWhere(object: string, where: EngineRow, data: EngineRow, opts: AsUser | AsSystem): Promise; }; @@ -408,12 +410,10 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis contextFor, hooks: { - async run( - object: string, - operation: 'insert' | 'update' | 'delete', - input: EngineRow, - opts: AsUser | AsSystem, - ): Promise { + // Parameters annotated because the member is overloaded (an overload + // set gives no contextual parameter types); the return type is left to + // inference, as it was before the overload. + async run(object: string, operation: 'insert' | 'update' | 'delete', input: EngineRow, opts: AsUser | AsSystem) { // The call's own shape is judged before anyone is resolved: a // malformed call is refused for its own reason, not for whatever the // identity resolver says about the token. @@ -455,7 +455,8 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis if (resolveEngineUpdateDispatch(data, { where, multi: true }).kind === 'by-id') { throw callShapeRefusal( "verify: hooks.updateWhere is the engine's predicate path, but this call addresses one row by id " + - "(an id in `data`, or a `where` naming only an id), which the engine writes by id. " + + "(a `where` naming only an id, or an id in `data` beside a `where` that selects by nothing else), " + + 'which the engine writes by id. ' + "Update one row through hooks.run(object, 'update', { id, ...fields }, opts).", ); } diff --git a/packages/verify/src/handle.update-doors.test.ts b/packages/verify/src/handle.update-doors.test.ts new file mode 100644 index 00000000000..97fc7a4502a --- /dev/null +++ b/packages/verify/src/handle.update-doors.test.ts @@ -0,0 +1,451 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22301 items 2 and 3 — the handle's two update doors that 17.7.0 lacked. + * + * - Item 2, the SYSTEM-context update: `hooks.run(object, 'update', input, + * { system: true })`. `seed` only inserts, and `hooks.run` ran as a person + * only, so an app's suite reached `objectql.update(…, { context: + * { isSystem: true } })` on the booted kernel by hand (hotcrm's + * `systemUpdate`). + * - Item 3, the PREDICATE update: `hooks.updateWhere(object, where, data, + * opts)`. `hooks.run` addresses one row by `input.id` and the REST bulk + * ingress (`POST /data/:object/updateMany`) writes by id, so the engine's + * predicate path (`multi: true`) had no door (hotcrm's `predicateUpdate`). + * + * Each door is the engine's own write, so every claim below is read off what + * only the engine produces on that path: the permission gate's refusal (and + * its absence for the system), the bound hook chain's view of the write (the + * caller it saw, the pre-image it was bound to, once per matched row, and the + * engine's own dispatch verdict, which tells its predicate path from a loop of + * by-id writes), the declared validation rule's refusal, and the + * record-change trigger's flow runs and their writes. + * + * The fixture is neutral. `upd_case` is readable but not editable by a fresh + * member, so a member's update of it is the refusal the system door's success + * is measured against. `upd_deal` is editable by the member, and its + * transition flow (`stage != previous.stage`) writes one ledger row per + * record whose OWN pre-image differs, so a per-row `previous` is observable + * in stored rows, not only in a hook's memory. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import type { Flow } from '@objectstack/spec/automation'; +import { PermissionSetSchema } from '@objectstack/spec/security'; + +import { bootStack, type VerifyStack } from './harness.js'; +import type { EngineRow } from './handle.js'; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; + +const DEAL = 'upd_deal'; +const CASE = 'upd_case'; +const LEDGER = 'upd_ledger'; +const DEAL_MOVED = 'upd_deal_stage_moved'; +const CASE_CHANGED = 'upd_case_changed'; + +/** One dispatch the bound hook chain received. */ +interface HookSeen { + object: string; + event: string; + id: unknown; + /** The pre-image's `stage` (deal) or `status` (case), as the engine bound it. */ + previous: unknown; + isSystem: boolean; + userId: unknown; + /** + * The engine's dispatch verdict for this call (`HookContext.dispatch.mode`): + * `'per-row'` is one of N dispatches of ONE predicate write, `'record'` is a + * write of one row by id. A loop of by-id updates would read `'record'`. + */ + mode: unknown; +} +const hookSaw: HookSeen[] = []; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type HookCtx = { event: string; input: Record; previous?: Record; session?: Record; dispatch?: { mode?: string } }; + +const capture = (object: string, prior: 'stage' | 'status') => async (ctx: HookCtx) => { + hookSaw.push({ + object, + event: ctx.event, + id: ctx.input.id, + previous: ctx.previous?.[prior], + isSystem: ctx.session?.isSystem === true, + userId: ctx.session?.userId, + mode: ctx.dispatch?.mode, + }); +}; + +const Deal = ObjectSchema.create({ + name: DEAL, + // The gate under test is the object grant; owner-sharing is kept out of the way. + sharingModel: 'public_read_write', + label: 'Deal', + pluralLabel: 'Deals', + fields: { + name: Field.text({ label: 'Name', required: true }), + batch: Field.text({ label: 'Batch' }), + stage: Field.text({ label: 'Stage' }), + note: Field.text({ label: 'Note' }), + }, +}); + +const Case = ObjectSchema.create({ + name: CASE, + sharingModel: 'public_read_write', + label: 'Case', + pluralLabel: 'Cases', + fields: { + name: Field.text({ label: 'Name', required: true }), + batch: Field.text({ label: 'Batch' }), + status: Field.text({ label: 'Status' }), + priority: Field.number({ label: 'Priority' }), + }, + validations: [ + { + name: 'priority_in_range', + type: 'script', + severity: 'error', + message: 'Priority must be 1 to 5', + condition: 'record.priority > 5', + }, + ], +}); + +/** What the record-change flows write: one row per run that reached its write. */ +const Ledger = ObjectSchema.create({ + name: LEDGER, + sharingModel: 'public_read_write', + label: 'Ledger', + pluralLabel: 'Ledgers', + fields: { + name: Field.text({ label: 'Name', required: true }), + record_id: Field.text({ label: 'Record' }), + from_value: Field.text({ label: 'From' }), + to_value: Field.text({ label: 'To' }), + }, +}); + +/** start (record-after-update, `condition`) → create_record(upd_ledger) → end. */ +function ledgerWriter(name: string, objectName: string, field: 'stage' | 'status', condition?: string): Flow { + return { + name, + label: name, + type: 'autolaunched', + status: 'active', + // The ledger is the flow's witness, written whoever (or nobody) triggered it. + runAs: 'system', + nodes: [ + { + id: 'start', + type: 'start', + label: 'On update', + config: { objectName, triggerType: 'record-after-update', ...(condition ? { condition } : {}) }, + }, + { + id: 'write', + type: 'create_record', + label: 'Ledger', + config: { + objectName: LEDGER, + fields: { + name, + record_id: { dialect: 'cel', source: 'record.id' }, + from_value: { dialect: 'cel', source: `previous.${field}` }, + to_value: { dialect: 'cel', source: `record.${field}` }, + }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'write' }, + { id: 'e2', source: 'write', target: 'end' }, + ], + } as Flow; +} + +const memberSet = PermissionSetSchema.parse({ + name: 'upd_member_default', + label: 'Update doors fixture member (default)', + isDefault: true, + objects: { + [DEAL]: { allowRead: true, allowCreate: true, allowEdit: true }, + // Readable, never editable: the member's update of a case is refused. + [CASE]: { allowRead: true }, + }, +}); + +const fixtureStack = defineStack({ + manifest: { + id: 'com.objectstack.verify.update-doors', + namespace: 'upd', + version: '0.0.0', + type: 'app', + name: 'Verify Update Doors Fixture', + description: 'An editable deal, a read-only case, a capture hook on each and a record-change flow on each.', + }, + // ADR-0097: a record-change start node needs both capabilities. + requires: ['automation', 'triggers'], + objects: [Deal, Case, Ledger], + hooks: [ + { name: 'upd_deal_capture', object: DEAL, events: ['beforeUpdate', 'afterUpdate'], handler: capture(DEAL, 'stage') }, + { name: 'upd_case_capture', object: CASE, events: ['beforeUpdate', 'afterUpdate'], handler: capture(CASE, 'status') }, + ], + flows: [ + ledgerWriter(DEAL_MOVED, DEAL, 'stage', 'stage != previous.stage'), + ledgerWriter(CASE_CHANGED, CASE, 'status'), + ], + permissions: [memberSet], +} as never); + +let stack: VerifyStack; +let admin: string; +let adminId: string; +let member: string; +let memberId: string; + +beforeAll(async () => { + stack = await bootStack(fixtureStack); + admin = await stack.signIn(); + adminId = String((await stack.contextFor(admin)).userId); + // The first user is the seeded dev admin, so this sign-up is a plain member. + member = await stack.signUp('upd-member@verify.test'); + memberId = String((await stack.contextFor(member)).userId); +}, BOOT_TIMEOUT); + +afterAll(async () => { + await stack?.stop().catch(() => undefined); +}); + +/** Unique per call, so no assertion sees another test's rows. */ +const uniq = (prefix: string): string => `${prefix}-${Math.random().toString(36).slice(2, 8)}`; + +/** The rejection `p` settles with, or a failure when it resolves. */ +async function refusalOf(p: Promise): Promise { + try { + await p; + } catch (e) { + return e; + } + throw new Error('expected a refusal, and the call resolved'); +} + +/** The ADR-0112 pair: `code`, and `status` (or the engine's `statusCode`). */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const envelope = (e: unknown) => ({ code: (e as any)?.code, status: (e as any)?.statusCode ?? (e as any)?.status }); + +const seenFor = (id: unknown, event?: string): HookSeen[] => + hookSaw.filter((h) => h.id === id && (event === undefined || h.event === event)); + +const ledgerFor = (flow: string, ids: readonly unknown[]): Promise => + stack.rows(LEDGER, { name: flow }).then((rows) => rows.filter((r) => ids.includes(r.record_id))); + +/** + * The engine's run log for `flow`, narrowed to runs `recordId` triggered: what + * the record-change trigger handed the automation service. The log is the + * synchronous witness; the durable `sys_automation_run` row is written after + * the triggering write has already returned. + */ +async function runsFor(flow: string, recordId: string): Promise }>> { + const automation = stack.kernel.getService('automation') as { + listRuns(name: string): Promise }>>; + }; + return (await automation.listRuns(flow)) + .filter((r) => r.trigger?.recordId === recordId) + .map((r) => ({ status: r.status, trigger: r.trigger ?? {} })); +} + +describe("item 2 — hooks.run(object, 'update', input, { system: true })", () => { + it('an update the member may not make succeeds as the system; the same update as the member is refused', async () => { + const [kase] = await stack.seed(CASE, [{ name: uniq('case'), status: 'open', priority: 1 }]); + + // Control first: the member holds read but not edit on upd_case. + const refused = await refusalOf(stack.hooks.run(CASE, 'update', { id: kase.id, status: 'closed' }, { as: member })); + expect(envelope(refused)).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect((await stack.rows(CASE, { id: kase.id }))[0].status, 'the refused write left the row').toBe('open'); + + const written = await stack.hooks.run(CASE, 'update', { id: kase.id, status: 'closed' }, { system: true }); + expect(written.status).toBe('closed'); + expect((await stack.rows(CASE, { id: kase.id }))[0].status).toBe('closed'); + }); + + it('it is a real system write: the hooks see the system and no user, and the record flow runs with no trigger user', async () => { + const [kase] = await stack.seed(CASE, [{ name: uniq('case'), status: 'open', priority: 1 }]); + // `seed` fires no record trigger and dispatches no update hook: nothing is + // on the books for this row before the write under test. + expect(seenFor(kase.id)).toEqual([]); + expect(await runsFor(CASE_CHANGED, kase.id)).toEqual([]); + + await stack.hooks.run(CASE, 'update', { id: kase.id, status: 'triaged' }, { system: true }); + + const system = seenFor(kase.id); + expect(system.map((h) => h.event)).toEqual(['beforeUpdate', 'afterUpdate']); + for (const h of system) { + expect(h).toMatchObject({ isSystem: true, userId: undefined, previous: 'open', mode: 'record' }); + } + const systemRuns = await runsFor(CASE_CHANGED, kase.id); + expect(systemRuns, 'the record-change trigger fired the flow on a system update').toHaveLength(1); + expect(systemRuns[0].status).toBe('completed'); + expect(systemRuns[0].trigger).toMatchObject({ type: 'record-after-update', object: CASE }); + expect(systemRuns[0].trigger.userId, 'no trigger user').toBeUndefined(); + expect(await ledgerFor(CASE_CHANGED, [kase.id])).toMatchObject([{ from_value: 'open', to_value: 'triaged' }]); + + // Control that DISCRIMINATES: the same update by a person (the admin, who + // may edit) reaches the same hooks and the same flow carrying that person. + await stack.hooks.run(CASE, 'update', { id: kase.id, status: 'closed' }, { as: admin }); + const person = seenFor(kase.id).slice(2); + expect(person.map((h) => h.event)).toEqual(['beforeUpdate', 'afterUpdate']); + for (const h of person) { + expect(h).toMatchObject({ isSystem: false, userId: adminId, previous: 'triaged' }); + } + const runs = await runsFor(CASE_CHANGED, kase.id); + expect(runs.map((r) => r.trigger.userId ?? null).sort()).toEqual([adminId, null].sort()); + }); + + it("the declared validation still runs: a system update the rule refuses is refused with the engine's envelope", async () => { + const [kase] = await stack.seed(CASE, [{ name: uniq('case'), status: 'open', priority: 1 }]); + const refused = await refusalOf(stack.hooks.run(CASE, 'update', { id: kase.id, priority: 9 }, { system: true })); + // The engine's `ValidationError`, rethrown unchanged: its `code` and the + // structured `fields` it carries. It has no status of its own (the REST + // boundary maps `VALIDATION_FAILED` to 400), so none is asserted here. + expect(refused).toMatchObject({ code: 'VALIDATION_FAILED', fields: [{ field: '_record', code: 'rule_violation' }] }); + expect((await stack.rows(CASE, { id: kase.id }))[0].priority).toBe(1); + // Control: an in-range value through the same door is written. + await stack.hooks.run(CASE, 'update', { id: kase.id, priority: 4 }, { system: true }); + expect((await stack.rows(CASE, { id: kase.id }))[0].priority).toBe(4); + }); + + it('the system is accepted on update only, and never beside a token — refused before the engine is touched', async () => { + const name = uniq('case'); + const [kase] = await stack.seed(CASE, [{ name, status: 'open', priority: 1 }]); + const asSystem = { system: true } as never; + + const insert = await refusalOf(stack.hooks.run(CASE, 'insert', { name: `${name}-x` }, asSystem)); + expect(envelope(insert)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + expect(await stack.rows(CASE, { name: `${name}-x` })).toEqual([]); + + const del = await refusalOf(stack.hooks.run(CASE, 'delete', { id: kase.id }, asSystem)); + expect(envelope(del)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + + const both = await refusalOf( + stack.hooks.run(CASE, 'update', { id: kase.id, status: 'closed' }, { as: admin, system: true } as never), + ); + expect(envelope(both)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + + expect((await stack.rows(CASE, { id: kase.id }))[0].status, 'none of the three wrote').toBe('open'); + expect(seenFor(kase.id), 'and no hook was dispatched').toEqual([]); + }); +}); + +describe('item 3 — hooks.updateWhere(object, where, data, opts): the predicate path', () => { + /** Three deals the predicate matches (one already won) and two it does not. */ + async function deals(): Promise<{ matched: EngineRow[]; other: EngineRow[]; batch: string }> { + const batch = uniq('batch'); + const matched = await stack.seed(DEAL, [ + { name: uniq('d'), batch, stage: 'open', note: 'n1' }, + { name: uniq('d'), batch, stage: 'working', note: 'n2' }, + { name: uniq('d'), batch, stage: 'won', note: 'n3' }, + ]); + const other = await stack.seed(DEAL, [ + { name: uniq('d'), batch: `${batch}-other`, stage: 'open', note: 'o1' }, + { name: uniq('d'), batch: `${batch}-other`, stage: 'working', note: 'o2' }, + ]); + return { matched, other, batch }; + } + + it('writes exactly the N rows the predicate selects, and leaves every other row as it was', async () => { + const { matched, other, batch } = await deals(); + + const count = await stack.hooks.updateWhere(DEAL, { batch }, { stage: 'won', note: 'bulk' }, { as: member }); + expect(count).toBe(3); + + const after = await stack.rows(DEAL, { batch }); + expect(after.map((r) => r.id).sort()).toEqual(matched.map((r) => r.id).sort()); + expect(after.every((r) => r.stage === 'won' && r.note === 'bulk')).toBe(true); + for (const row of other) { + const [now] = await stack.rows(DEAL, { id: row.id }); + expect({ stage: now.stage, note: now.note }).toEqual({ stage: row.stage, note: row.note }); + } + }); + + it("is the engine's predicate path: hooks once per matched row, each bound to that row's OWN pre-image, as the caller", async () => { + const { matched, other, batch } = await deals(); + + await stack.hooks.updateWhere(DEAL, { batch }, { stage: 'won' }, { as: member }); + + for (const row of matched) { + for (const event of ['beforeUpdate', 'afterUpdate']) { + const seen = seenFor(row.id, event); + expect(seen, `${event} once for ${String(row.stage)}`).toHaveLength(1); + expect(seen[0]).toMatchObject({ previous: row.stage, isSystem: false, userId: memberId, mode: 'per-row' }); + } + } + for (const row of other) expect(seenFor(row.id)).toEqual([]); + }); + + it("the record-change trigger evaluates per row: the transition flow fires for each row whose own pre-image differs", async () => { + const { matched, other, batch } = await deals(); + + await stack.hooks.updateWhere(DEAL, { batch }, { stage: 'won' }, { as: member }); + + const ledger = await ledgerFor(DEAL_MOVED, [...matched, ...other].map((r) => r.id)); + const moved = matched.filter((r) => r.stage !== 'won'); + // The already-won row was written too (it is in the count above), and its + // pre-image says it did not move: no ledger row. One shared `previous` + // would have fired for all three or for none. + expect(ledger.map((r) => ({ id: r.record_id, from: r.from_value, to: r.to_value })).sort((a, b) => + String(a.from).localeCompare(String(b.from)), + )).toEqual( + moved.map((r) => ({ id: r.id, from: r.stage, to: 'won' })).sort((a, b) => String(a.from).localeCompare(String(b.from))), + ); + }); + + it('takes the system too: a predicate update the member may not make is refused, and succeeds as the system', async () => { + const batch = uniq('batch'); + const cases = await stack.seed(CASE, [ + { name: uniq('c'), batch, status: 'open', priority: 1 }, + { name: uniq('c'), batch, status: 'open', priority: 2 }, + ]); + + const refused = await refusalOf(stack.hooks.updateWhere(CASE, { batch }, { status: 'closed' }, { as: member })); + expect(envelope(refused)).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect((await stack.rows(CASE, { batch })).map((r) => r.status)).toEqual(['open', 'open']); + + expect(await stack.hooks.updateWhere(CASE, { batch }, { status: 'closed' }, { system: true })).toBe(2); + expect((await stack.rows(CASE, { batch })).map((r) => r.status)).toEqual(['closed', 'closed']); + for (const kase of cases) { + expect(seenFor(kase.id, 'beforeUpdate')).toMatchObject([ + { isSystem: true, userId: undefined, previous: 'open', mode: 'per-row' }, + ]); + } + }); + + it("refuses, before the engine is touched, a call the engine's own dispatch would write by id", async () => { + const { matched, batch } = await deals(); + const [first] = matched; + + const whereOnlyId = await refusalOf(stack.hooks.updateWhere(DEAL, { id: first.id }, { note: 'x' }, { as: member })); + expect(envelope(whereOnlyId)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + // A payload id beside a predicate that selects nothing else: the engine + // binds it as the row address. (Beside a REAL predicate the engine refuses + // the call itself, its own way, which this door leaves unchanged.) + const idInData = await refusalOf(stack.hooks.updateWhere(DEAL, {}, { id: first.id, note: 'x' }, { as: member })); + expect(envelope(idInData)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + const noPredicate = await refusalOf(stack.hooks.updateWhere(DEAL, undefined as never, { note: 'x' }, { as: member })); + expect(envelope(noPredicate)).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + + expect((await stack.rows(DEAL, { batch })).every((r) => r.note !== 'x'), 'none of the three wrote').toBe(true); + expect(seenFor(first.id), 'and no hook was dispatched').toEqual([]); + + // Control: an id INSIDE a real predicate (the compare-and-set spelling, + // `where: { id, …more }`) is the engine's predicate path, and goes through. + expect(await stack.hooks.updateWhere(DEAL, { id: first.id, batch }, { note: 'x' }, { as: member })).toBe(1); + expect((await stack.rows(DEAL, { id: first.id }))[0].note).toBe('x'); + }); +}); From ca122badb2055d034842962b2f8938ebe2fe6aff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:23:16 +0000 Subject: [PATCH 3/4] fix(spec): register @objectstack/verify as an emitter of INVALID_REQUEST in the error-code ledger The verify handle's two update doors refuse a malformed call in-process with a thrown Error carrying code / status / statusCode, and the provenance guard requires the stamping package's own owner key to list the code. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/spec/src/api/error-code-ledger.zod.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index dfd7b0ee218..0f8fd90f3ed 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1473,6 +1473,10 @@ export const ERROR_CODE_LEDGER = { // record. 'INVALID_ARTIFACT_PACKAGES', ], + '@objectstack/verify': [ + // [#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. + 'INVALID_REQUEST', + ], } as const satisfies Record; /** A code registered by at least one package (deduped union of the ledger). */ From 257aeb788b4b1ce919cc392fdaf77b3592f6e895 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:08:43 +0000 Subject: [PATCH 4/4] chore(changeset): @objectstack/spec minor for the verify owner row in the error-code ledger Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .changeset/22301-spec-ledger-verify-provenance.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/22301-spec-ledger-verify-provenance.md diff --git a/.changeset/22301-spec-ledger-verify-provenance.md b/.changeset/22301-spec-ledger-verify-provenance.md new file mode 100644 index 00000000000..54541486a17 --- /dev/null +++ b/.changeset/22301-spec-ledger-verify-provenance.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): the error-code ledger lists `INVALID_REQUEST` under `@objectstack/verify` + +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.