You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 5910b5e
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
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`.
0 commit comments