Skip to content

Commit 6058cb2

Browse files
claude[bot]claude
andauthored
feat(verify): an in-process handle on the booted stack — run a hook, flow, action or validation rule against the real engine and assert (#17181)
* feat(verify): an in-process handle on the booted stack — hooks, validate, flows, actions, seed/rows, metadata, tenancy, shared boot Every VerifyStack now carries a handle over the same kernel bootStack boots: hooks.run / validate / seed / rows are the ObjectQL engine's own write, dry-run and read doors with a dispatcher-resolved ExecutionContext; flows.run / flows.resume / actions.run drive the runtime HttpDispatcher in-process (the REST route minus HTTP); metadata reads the SchemaRegistry; tenancy reads the service AuthPlugin registered; contextFor exposes the resolver; bootStackOnce promotes dogfood's worker-scoped shared boot. Zero re-implemented semantics. Tests: one pin per method against the real engine, the hooks.run vs REST parity pin on the same row AND the same refusal (with the admin control), and hotcrm's opportunity_lifecycle block ported onto hooks.run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj * docs(verify): changeset + README for the in-process handle; widen the rows pin to the whole batch Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj * fix(verify): judge the hooks.run call shape before resolving identity; type the objectql lookups with the engine contract `check:slot-lookup` refused four `getService<any>('objectql')` erasures in the handle; the slot's honest contract for what the handle reaches (registry, validate, the write/read doors) is `ObjectQL` itself. Also: a malformed `hooks.run` call (missing `input.id` on update/delete, an unknown operation) is refused for its own reason before the caller's token is resolved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj * test(qa): widen the dogfood rls-runner fake stack to the VerifyStack handle members `VerifyStack` now extends `VerifyHandle`, so the hand-built fake in `rls-runner.test.ts` no longer satisfies the interface. The runner under test drives the HTTP half only, so the handle members are typed `never` like `kernel` / `api` / `raw` already are: a runner that starts reaching for the handle fails to compile here rather than finding `undefined` at run time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj * test(verify): point the tenancy reader at a walled boot too, so a constant cannot pass it `tenancy()`'s only pin asserted the `single` posture of a plain boot — which any stand-in answering the constant `'single'` satisfies, and a constant is exactly what the hand-written tenancy probe this method retires was. Measured: an ablation breaking the tenancy service's isolation probe left the pin GREEN, because `isolationActive()` short-circuits on `requestedPosture === 'single'` before it ever consults the probe. The same reader is now pointed at a stack booted `multiTenant: 'posture-only'`, where the probe IS consulted: `isolated` / active / not degraded. The probe ablation now reddens this case and leaves the `single` case green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj * docs(changeset): declare Clause-② yes on the verify handle changeset The card's acceptance requires the changeset itself to carry the reading, in the fixed spelling `Clause-②: yes` (the only two values scripts/pm/check-clause2-carriers.mjs reads). It was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ae19f5e commit 6058cb2

9 files changed

Lines changed: 1348 additions & 5 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/verify": minor
3+
---
4+
5+
**Clause-②: yes** — new exported symbols on a published package (`bootStackOnce`, `isVerifyRefusal`, and ten new members on the `VerifyStack` every `bootStack` caller already holds), so the accept set a consumer writes against widens. Contract-review tier.
6+
7+
Every `VerifyStack` now carries an **in-process handle** on the stack `bootStack` boots — a way to run a hook, a validation rule, a flow, an action, a seed or a read against the REAL engine and assert on what the engine did, instead of writing through HTTP and inferring from persisted rows, or rebuilding the engine's semantics in a test stand-in.
8+
9+
New members on `VerifyStack` (the same object `bootStack` returns; `api` / `apiAs` / `signIn` / `signUp` / `stop` are unchanged):
10+
11+
- `hooks.run(object, 'insert' | 'update' | 'delete', input, { as })` — one write through the engine's own door as the caller `as` (a bearer token from `signIn` / `signUp`). The bound hook chain, field defaults, declared validations and the SecurityPlugin middleware run inside it, in the engine's order, because this is the very call the REST data ingress makes. Returns what the engine returned; a refusal rejects with the engine's own error (`code`, `statusCode`).
12+
- `validate(object, record, { as, mode? })` — the engine's dry-run validation pass (`ObjectQL.validate`), nothing written.
13+
- `flows.run(name, params, { as })` / `flows.resume(run, input, { as })` — the runtime's `/automation` trigger and resume routes driven in-process (no Hono, no socket): the caller's resolved identity is forwarded exactly as the route forwards it, and the engine's `AutomationResult` comes back (plus `flowName`, so the value hands straight to `resume`). A never-dispatched refusal or a failed run rejects with the route's ADR-0112 envelope.
14+
- `actions.run(object, action, { as, recordId?, params? })` — the `/actions/:object/:action` route driven in-process, the one door carrying the whole action contract (ADR-0066 D4 gate, ADR-0104 param contract, subject-record load, trusted body context). Returns the handler's value.
15+
- `seed(object, rows)` / `rows(object, where?, { as? })` — real ObjectQL writes (the platform's own seed-replay context) and reads (system-scoped, or as a caller under that caller's grants and RLS).
16+
- `metadata.object(name)` / `objects()` / `items(type)` / `types()` — the booted `SchemaRegistry`, by its own singular type vocabulary.
17+
- `tenancy()` — the `tenancy` service AuthPlugin registered (`posture`, `requestedPosture`, `isolationActive`, `degraded`).
18+
- `contextFor(token)` — the dispatcher's own request-identity resolution, exposed so a test can drive any kernel service as a real caller.
19+
20+
Also new: `bootStackOnce(config, opts?)`, a per-process memo of `bootStack` keyed on the `config` and `opts` object identities — the worker-scoped shared boot `packages/qa/dogfood` kept privately, promoted for suites that run many files under `isolate: false`.
21+
22+
Exported types: `VerifyHandle`, `VerifyRefusal` (with the `isVerifyRefusal` predicate), `AsUser`, `FlowRun`, `FlowRunRef`, `EngineRow`.
23+
24+
**Zero re-implemented semantics.** Every method is a thin facade over a door the kernel wired at boot; the handle assembles no `ExecutionContext`, orders no hooks, evaluates no permission. The package's own tests pin each method against the real service behind it (the PR's ablation record breaks each service in turn and shows only that method's pin going red), pin `hooks.run` against the REST write on the same row **and** the same refusal, and port one hotcrm exemplar (`opportunity_lifecycle`) onto `hooks.run` as the proof of ergonomics.
25+
26+
No boot option was added: the tenancy posture a stack runs under is still chosen by `multiTenant` (the `--multi-tenant` option `os verify` already has) and read back through `tenancy()`. `os verify`, `runCrudVerification` and `runRlsProofs` are unchanged.

‎packages/qa/dogfood/test/rls-runner.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,20 @@ function fakeStack(opts: FakeOpts): VerifyStack {
123123
signIn: async () => 'admin',
124124
signUp: async () => 'member',
125125
stop: async () => {},
126+
// [#15951] The in-process handle every real `VerifyStack` carries is NOT
127+
// modelled here: the runner under test drives the HTTP half only. Typed
128+
// `never`, like `kernel` / `api` / `raw` above, so a runner that starts
129+
// reaching for the handle fails to compile in this test rather than
130+
// finding an `undefined` at run time.
131+
contextFor: undefined as never,
132+
hooks: undefined as never,
133+
validate: undefined as never,
134+
flows: undefined as never,
135+
actions: undefined as never,
136+
seed: undefined as never,
137+
rows: undefined as never,
138+
metadata: undefined as never,
139+
tenancy: undefined as never,
126140
};
127141
}
128142

‎packages/verify/README.md‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ derived from your own metadata:
1010
- **Authorization** — the cross-owner RLS invariant: *a user who cannot READ a
1111
record must not be able to WRITE it.*
1212

13+
And, on the same booted stack, an **in-process handle** so an app's own tests
14+
can run a hook, a validation rule, a flow, an action, a seed or a read against
15+
the **real** engine and assert on what it did — no HTTP round-trip, no
16+
hand-rolled `ctx.api`, no copied permission check.
17+
1318
## Why
1419

1520
Static gates — type-check, unit tests, schema validation — verify each layer in
@@ -67,6 +72,72 @@ expect(rls.summary.holes).toBe(0);
6772
await stack.stop();
6873
```
6974

75+
## The in-process handle (drive the real engine from a test)
76+
77+
Every `VerifyStack` carries it; nothing extra to boot. Each method is a thin
78+
facade over a door the kernel wired at boot — the ObjectQL engine's own write,
79+
dry-run and read calls, the runtime's `/automation` and `/actions` routes
80+
driven in-process, the `SchemaRegistry`, the `tenancy` service — with **zero
81+
re-implemented semantics**: the handle assembles no execution context, orders
82+
no hooks, evaluates no permission. What the engine does is what you assert on.
83+
84+
```ts
85+
import { bootStack } from '@objectstack/verify';
86+
import myApp from './objectstack.config.js';
87+
88+
const stack = await bootStack(myApp, { automation: true });
89+
await stack.signIn(); // seeds the dev admin
90+
const rep = await stack.signUp('rep@example.com'); // a plain member
91+
92+
// A hook: one real write as `rep` — before* hooks, validation, the driver,
93+
// after* hooks, and the permission check the caller is subject to.
94+
const deal = await stack.hooks.run('crm_opportunity', 'insert',
95+
{ name: 'Globex', amount: 10_000, stage: 'proposal' }, { as: rep });
96+
expect(deal.expected_revenue).toBe(6_000); // the hook derived it
97+
98+
// The same write a member may NOT make rejects with the engine's own error.
99+
await expect(stack.hooks.run('crm_vault', 'insert', { name: 'x' }, { as: rep }))
100+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
101+
102+
// A validation rule, without writing.
103+
const verdict = await stack.validate('crm_opportunity', { amount: -1 }, { as: rep });
104+
expect(verdict.valid).toBe(false);
105+
106+
// A screen flow: trigger, then resume with the screen's input.
107+
const run = await stack.flows.run('quote_generation', { recordId: deal.id }, { as: rep });
108+
expect(run.status).toBe('paused');
109+
await stack.flows.resume(run, { quoteName: 'Q-1', discount: 10 }, { as: rep });
110+
111+
// An action body, through the route that carries its param contract.
112+
const out = await stack.actions.run('crm_opportunity', 'apply_discount',
113+
{ as: rep, recordId: deal.id, params: { discount: 10 } });
114+
115+
// Fixtures and reads through the real engine.
116+
const [acc] = await stack.seed('crm_account', [{ name: 'Globex' }]);
117+
const mine = await stack.rows('crm_opportunity', { crm_account: acc.id }, { as: rep });
118+
119+
// What the boot actually holds.
120+
stack.metadata.object('crm_opportunity')?.fields; // system columns injected
121+
stack.metadata.items('permission'); // the registry's singular names
122+
stack.tenancy().posture; // 'single' | 'group' | 'isolated'
123+
124+
await stack.stop();
125+
```
126+
127+
- `as` is always a bearer token minted by `signIn()` / `signUp()` on the same
128+
stack — the handle resolves it through the dispatcher's own identity resolver
129+
(`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.
132+
- A refusal from `flows.*` / `actions.run` is the route's ADR-0112 envelope
133+
(`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.
136+
- Many files, one boot: `bootStackOnce(config, opts?)` memoises `bootStack` per
137+
`(config, opts)` object identity for the life of the process. Share it from
138+
one module, under vitest `isolate: false`, and never `stop()` a stack other
139+
files still use.
140+
70141
## Verdicts
71142

72143
**Data fidelity** (`runCrudVerification`):
@@ -117,13 +188,17 @@ run" must never read like "nothing to find".
117188

118189
## API
119190

120-
- `bootStack(config, opts?)` → `VerifyStack` (`api` / `raw` / `signIn` / `signUp` / `apiAs` / `stop`).
191+
- `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`).
193+
- `bootStackOnce(config, opts?)` → the same, memoised per `(config, opts)` identity for the process.
121194
- `deriveCrudCases(config)` → the auto-derived round-trip cases (write one, read one, assert) for every object.
122195
- `runCrudVerification(stack, token, config)` → `VerifyReport`; `formatReport(report)` for a log summary.
123196
- `runRlsProofs(stack, adminToken, memberToken, config)` → `RlsReport`; `formatRlsReport(report)`.
124197

125198
`bootStack` options: `admin`, `authSecret`, `security` (a custom `SecurityPlugin`
126-
for owner-scoped fixtures), `multiTenant`.
199+
for owner-scoped fixtures), `multiTenant` (also what decides the posture
200+
`tenancy()` reports), `automation` (register the automation service so
201+
`flows.*` has something to drive), `orgContext`, `databaseFile`, `extraPlugins`.
127202

128203
## Known limitations
129204

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// hotcrm#1579 step 5a — ONE hotcrm exemplar, ported onto the handle.
4+
//
5+
// hotcrm's `test/hooks-runtime-sales.test.ts` (`describe('opportunity_lifecycle')`)
6+
// drives the hook body through a hand-written `ctx.api` over arrays
7+
// (`test/helpers/hook-harness.ts`, 618 lines): `hook.handler(makeCtx({ event,
8+
// input, previous, user }))`, then asserts on the mutated `input`. Every
9+
// assertion below is that block's assertion; what changed is the instrument.
10+
// Each case is one real write through the booted engine as a real member —
11+
// the L2 body runs in the QuickJS runner the runtime bound at boot, `ctx.input`
12+
// is the engine's flat-input proxy, `ctx.previous` is the pre-image the engine
13+
// loaded, and the permission check the stand-in never had runs first.
14+
//
15+
// The fixture (`./handle.fixture.ts`) carries the derivation half of
16+
// hotcrm's hook as the L2 body hotcrm ships. The `previous`-driven cases that
17+
// used to be constructed by hand (`makeCtx({ previous })`) are now a seeded
18+
// row plus an `update` — the engine supplies the pre-image.
19+
20+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
21+
22+
import { bootStack, type VerifyStack } from './harness.js';
23+
import { handleFixtureStack, STAGES, STAGE_PROBABILITY, STAGE_FORECAST, today } from './handle.fixture.js';
24+
25+
const BOOT_TIMEOUT = 120_000;
26+
27+
let stack: VerifyStack;
28+
let user: string; // hotcrm's `USER = { id: 'user_1' }` — an authenticated human edit
29+
30+
beforeAll(async () => {
31+
stack = await bootStack(handleFixtureStack);
32+
await stack.signIn();
33+
user = await stack.signUp('sales-rep@verify.test');
34+
}, BOOT_TIMEOUT);
35+
36+
afterAll(async () => {
37+
await stack?.stop().catch(() => undefined);
38+
});
39+
40+
const uniq = (prefix: string): string => `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
41+
42+
describe('opportunity_lifecycle (ported: hotcrm test/hooks-runtime-sales.test.ts)', () => {
43+
it('derives probability, expected_revenue and forecast_category from stage on insert', async () => {
44+
const input = await stack.hooks.run(
45+
'hnd_deal',
46+
'insert',
47+
{ name: uniq('Deal'), amount: 10_000, stage: 'proposal' },
48+
{ as: user },
49+
);
50+
expect(input.probability).toBe(60);
51+
expect(input.expected_revenue).toBe(6_000);
52+
expect(input.forecast_category).toBe('commit');
53+
});
54+
55+
it.each(
56+
STAGES.map((stage) => [stage, STAGE_PROBABILITY[stage], STAGE_FORECAST[stage]] as const),
57+
)('stage %s ⇒ probability %i, forecast %s', async (stage, probability, forecast) => {
58+
const input = await stack.hooks.run(
59+
'hnd_deal',
60+
'insert',
61+
{ name: uniq('Deal'), amount: 1_000, stage },
62+
{ as: user },
63+
);
64+
expect(input.probability).toBe(probability);
65+
expect(input.expected_revenue).toBe((1_000 * probability) / 100);
66+
expect(input.forecast_category).toBe(forecast);
67+
});
68+
69+
it('recomputes expected_revenue when only the amount changes', async () => {
70+
// hotcrm: previous = { stage: 'proposal', amount: 10_000, probability: 60 }
71+
const previous = await stack.hooks.run(
72+
'hnd_deal',
73+
'insert',
74+
{ name: uniq('Deal'), amount: 10_000, stage: 'proposal' },
75+
{ as: user },
76+
);
77+
await stack.hooks.run('hnd_deal', 'update', { id: previous.id, amount: 50_000 }, { as: user });
78+
const [input] = await stack.rows('hnd_deal', { id: previous.id });
79+
expect(input.expected_revenue).toBe(30_000); // 50k × 60%
80+
});
81+
82+
it('stamps probability and expected_revenue on the closed_won transition', async () => {
83+
// hotcrm: previous = { stage: 'negotiation', amount: 25_000 }
84+
const previous = await stack.hooks.run(
85+
'hnd_deal',
86+
'insert',
87+
{ name: uniq('Deal'), amount: 25_000, stage: 'negotiation' },
88+
{ as: user },
89+
);
90+
await stack.hooks.run('hnd_deal', 'update', { id: previous.id, stage: 'closed_won' }, { as: user });
91+
const [input] = await stack.rows('hnd_deal', { id: previous.id });
92+
expect(input.probability).toBe(100);
93+
expect(input.expected_revenue).toBe(25_000);
94+
// `days_in_stage` is a formula over `stage_entry_date`; re-stamping IS the reset.
95+
expect(input.stage_entry_date).toBe(today());
96+
});
97+
98+
it('starts the stage clock on insert so a never-moved deal is visible to the sweep', async () => {
99+
const input = await stack.hooks.run(
100+
'hnd_deal',
101+
'insert',
102+
{ name: uniq('New Deal'), amount: 1_000, stage: 'prospecting' },
103+
{ as: user },
104+
);
105+
expect(input.stage_entry_date).toBe(today());
106+
});
107+
108+
it('leaves the stage clock alone when the stage did not change', async () => {
109+
const previous = await stack.hooks.run(
110+
'hnd_deal',
111+
'insert',
112+
{ name: uniq('Deal'), amount: 1_000, stage: 'proposal' },
113+
{ as: user },
114+
);
115+
// Age the clock through the engine as the system (a seed/backfill write),
116+
// then make a USER edit that does not touch the stage.
117+
await stack.hooks.run('hnd_deal', 'update', { id: previous.id, stage_entry_date: '2026-01-01' }, { as: user });
118+
await stack.hooks.run('hnd_deal', 'update', { id: previous.id, amount: 2_000 }, { as: user });
119+
const [input] = await stack.rows('hnd_deal', { id: previous.id });
120+
expect(input.stage_entry_date).toBe('2026-01-01');
121+
expect(input.expected_revenue).toBe(1_200); // 2k × 60%
122+
});
123+
});

0 commit comments

Comments
 (0)