Skip to content
26 changes: 26 additions & 0 deletions .changeset/verify-in-process-handle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/verify": minor
---

**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.

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.

New members on `VerifyStack` (the same object `bootStack` returns; `api` / `apiAs` / `signIn` / `signUp` / `stop` are unchanged):

- `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`).
- `validate(object, record, { as, mode? })` — the engine's dry-run validation pass (`ObjectQL.validate`), nothing written.
- `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.
- `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.
- `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).
- `metadata.object(name)` / `objects()` / `items(type)` / `types()` — the booted `SchemaRegistry`, by its own singular type vocabulary.
- `tenancy()` — the `tenancy` service AuthPlugin registered (`posture`, `requestedPosture`, `isolationActive`, `degraded`).
- `contextFor(token)` — the dispatcher's own request-identity resolution, exposed so a test can drive any kernel service as a real caller.

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`.

Exported types: `VerifyHandle`, `VerifyRefusal` (with the `isVerifyRefusal` predicate), `AsUser`, `FlowRun`, `FlowRunRef`, `EngineRow`.

**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.

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.
14 changes: 14 additions & 0 deletions packages/qa/dogfood/test/rls-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,20 @@ function fakeStack(opts: FakeOpts): VerifyStack {
signIn: async () => 'admin',
signUp: async () => 'member',
stop: async () => {},
// [#15951] The in-process handle every real `VerifyStack` carries is NOT
// modelled here: the runner under test drives the HTTP half only. Typed
// `never`, like `kernel` / `api` / `raw` above, so a runner that starts
// reaching for the handle fails to compile in this test rather than
// finding an `undefined` at run time.
contextFor: undefined as never,
hooks: undefined as never,
validate: undefined as never,
flows: undefined as never,
actions: undefined as never,
seed: undefined as never,
rows: undefined as never,
metadata: undefined as never,
tenancy: undefined as never,
};
}

Expand Down
79 changes: 77 additions & 2 deletions packages/verify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ derived from your own metadata:
- **Authorization** — the cross-owner RLS invariant: *a user who cannot READ a
record must not be able to WRITE it.*

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

## Why

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

## The in-process handle (drive the real engine from a test)

Every `VerifyStack` carries it; nothing extra to boot. Each method is a thin
facade over a door the kernel wired at boot — the ObjectQL engine's own write,
dry-run and read calls, the runtime's `/automation` and `/actions` routes
driven in-process, the `SchemaRegistry`, the `tenancy` service — with **zero
re-implemented semantics**: the handle assembles no execution context, orders
no hooks, evaluates no permission. What the engine does is what you assert on.

```ts
import { bootStack } from '@objectstack/verify';
import myApp from './objectstack.config.js';

const stack = await bootStack(myApp, { automation: true });
await stack.signIn(); // seeds the dev admin
const rep = await stack.signUp('rep@example.com'); // a plain member

// A hook: one real write as `rep` — before* hooks, validation, the driver,
// after* hooks, and the permission check the caller is subject to.
const deal = await stack.hooks.run('crm_opportunity', 'insert',
{ name: 'Globex', amount: 10_000, stage: 'proposal' }, { as: rep });
expect(deal.expected_revenue).toBe(6_000); // the hook derived it

// The same write a member may NOT make rejects with the engine's own error.
await expect(stack.hooks.run('crm_vault', 'insert', { name: 'x' }, { as: rep }))
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });

// A validation rule, without writing.
const verdict = await stack.validate('crm_opportunity', { amount: -1 }, { as: rep });
expect(verdict.valid).toBe(false);

// A screen flow: trigger, then resume with the screen's input.
const run = await stack.flows.run('quote_generation', { recordId: deal.id }, { as: rep });
expect(run.status).toBe('paused');
await stack.flows.resume(run, { quoteName: 'Q-1', discount: 10 }, { as: rep });

// An action body, through the route that carries its param contract.
const out = await stack.actions.run('crm_opportunity', 'apply_discount',
{ as: rep, recordId: deal.id, params: { discount: 10 } });

// Fixtures and reads through the real engine.
const [acc] = await stack.seed('crm_account', [{ name: 'Globex' }]);
const mine = await stack.rows('crm_opportunity', { crm_account: acc.id }, { as: rep });

// What the boot actually holds.
stack.metadata.object('crm_opportunity')?.fields; // system columns injected
stack.metadata.items('permission'); // the registry's singular names
stack.tenancy().posture; // 'single' | 'group' | 'isolated'

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.
- 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.
- 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
files still use.

## Verdicts

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

## API

- `bootStack(config, opts?)` → `VerifyStack` (`api` / `raw` / `signIn` / `signUp` / `apiAs` / `stop`).
- `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`).
- `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.
- `runRlsProofs(stack, adminToken, memberToken, config)` → `RlsReport`; `formatRlsReport(report)`.

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

## Known limitations

Expand Down
123 changes: 123 additions & 0 deletions packages/verify/src/handle.exemplar-deal-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// hotcrm#1579 step 5a — ONE hotcrm exemplar, ported onto the handle.
//
// hotcrm's `test/hooks-runtime-sales.test.ts` (`describe('opportunity_lifecycle')`)
// drives the hook body through a hand-written `ctx.api` over arrays
// (`test/helpers/hook-harness.ts`, 618 lines): `hook.handler(makeCtx({ event,
// input, previous, user }))`, then asserts on the mutated `input`. Every
// assertion below is that block's assertion; what changed is the instrument.
// Each case is one real write through the booted engine as a real member —
// the L2 body runs in the QuickJS runner the runtime bound at boot, `ctx.input`
// is the engine's flat-input proxy, `ctx.previous` is the pre-image the engine
// loaded, and the permission check the stand-in never had runs first.
//
// The fixture (`./handle.fixture.ts`) carries the derivation half of
// hotcrm's hook as the L2 body hotcrm ships. The `previous`-driven cases that
// used to be constructed by hand (`makeCtx({ previous })`) are now a seeded
// row plus an `update` — the engine supplies the pre-image.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';

import { bootStack, type VerifyStack } from './harness.js';
import { handleFixtureStack, STAGES, STAGE_PROBABILITY, STAGE_FORECAST, today } from './handle.fixture.js';

const BOOT_TIMEOUT = 120_000;

let stack: VerifyStack;
let user: string; // hotcrm's `USER = { id: 'user_1' }` — an authenticated human edit

beforeAll(async () => {
stack = await bootStack(handleFixtureStack);
await stack.signIn();
user = await stack.signUp('sales-rep@verify.test');
}, BOOT_TIMEOUT);

afterAll(async () => {
await stack?.stop().catch(() => undefined);
});

const uniq = (prefix: string): string => `${prefix}-${Math.random().toString(36).slice(2, 8)}`;

describe('opportunity_lifecycle (ported: hotcrm test/hooks-runtime-sales.test.ts)', () => {
it('derives probability, expected_revenue and forecast_category from stage on insert', async () => {
const input = await stack.hooks.run(
'hnd_deal',
'insert',
{ name: uniq('Deal'), amount: 10_000, stage: 'proposal' },
{ as: user },
);
expect(input.probability).toBe(60);
expect(input.expected_revenue).toBe(6_000);
expect(input.forecast_category).toBe('commit');
});

it.each(
STAGES.map((stage) => [stage, STAGE_PROBABILITY[stage], STAGE_FORECAST[stage]] as const),
)('stage %s ⇒ probability %i, forecast %s', async (stage, probability, forecast) => {
const input = await stack.hooks.run(
'hnd_deal',
'insert',
{ name: uniq('Deal'), amount: 1_000, stage },
{ as: user },
);
expect(input.probability).toBe(probability);
expect(input.expected_revenue).toBe((1_000 * probability) / 100);
expect(input.forecast_category).toBe(forecast);
});

it('recomputes expected_revenue when only the amount changes', async () => {
// hotcrm: previous = { stage: 'proposal', amount: 10_000, probability: 60 }
const previous = await stack.hooks.run(
'hnd_deal',
'insert',
{ name: uniq('Deal'), amount: 10_000, stage: 'proposal' },
{ as: user },
);
await stack.hooks.run('hnd_deal', 'update', { id: previous.id, amount: 50_000 }, { as: user });
const [input] = await stack.rows('hnd_deal', { id: previous.id });
expect(input.expected_revenue).toBe(30_000); // 50k × 60%
});

it('stamps probability and expected_revenue on the closed_won transition', async () => {
// hotcrm: previous = { stage: 'negotiation', amount: 25_000 }
const previous = await stack.hooks.run(
'hnd_deal',
'insert',
{ name: uniq('Deal'), amount: 25_000, stage: 'negotiation' },
{ as: user },
);
await stack.hooks.run('hnd_deal', 'update', { id: previous.id, stage: 'closed_won' }, { as: user });
const [input] = await stack.rows('hnd_deal', { id: previous.id });
expect(input.probability).toBe(100);
expect(input.expected_revenue).toBe(25_000);
// `days_in_stage` is a formula over `stage_entry_date`; re-stamping IS the reset.
expect(input.stage_entry_date).toBe(today());
});

it('starts the stage clock on insert so a never-moved deal is visible to the sweep', async () => {
const input = await stack.hooks.run(
'hnd_deal',
'insert',
{ name: uniq('New Deal'), amount: 1_000, stage: 'prospecting' },
{ as: user },
);
expect(input.stage_entry_date).toBe(today());
});

it('leaves the stage clock alone when the stage did not change', async () => {
const previous = await stack.hooks.run(
'hnd_deal',
'insert',
{ name: uniq('Deal'), amount: 1_000, stage: 'proposal' },
{ as: user },
);
// Age the clock through the engine as the system (a seed/backfill write),
// then make a USER edit that does not touch the stage.
await stack.hooks.run('hnd_deal', 'update', { id: previous.id, stage_entry_date: '2026-01-01' }, { as: user });
await stack.hooks.run('hnd_deal', 'update', { id: previous.id, amount: 2_000 }, { as: user });
const [input] = await stack.rows('hnd_deal', { id: previous.id });
expect(input.stage_entry_date).toBe('2026-01-01');
expect(input.expected_revenue).toBe(1_200); // 2k × 60%
});
});
Loading
Loading