diff --git a/content/docs/protocol/kernel/plugin-spec.mdx b/content/docs/protocol/kernel/plugin-spec.mdx
index 8d4f735af0..56fc06aa1a 100644
--- a/content/docs/protocol/kernel/plugin-spec.mdx
+++ b/content/docs/protocol/kernel/plugin-spec.mdx
@@ -784,81 +784,103 @@ os package install ./dist/objectstack.json
## Plugin Testing
-
-The `@objectstack/testing` package and its `createTestContext()` /
-`createTestEnvironment()` helpers shown below are **proposed** — there is no
-such published package today. The snippets illustrate the intended test
-ergonomics for the plugin spec, not a shipping API.
-
+Test a plugin the way it runs: boot a real stack that loads it, drive the
+engine, and assert on what the engine did. `@objectstack/verify` ships the
+harness for this, and unlike the manifest snippets above, the code in this
+section is the shipped API.
+
+```bash
+pnpm add -D @objectstack/verify vitest
+```
-### Unit Tests
+`bootStack(config, options?)` boots a configuration in-process: the real kernel
+with ObjectQL, the bound hook chain, validation, the security middleware,
+sharing and the REST dispatcher, on an in-memory database, with no server
+process and no port. The stack it returns carries an **in-process handle**.
+Each handle method passes the call to the door the kernel wired at boot (the
+engine's own write, dry-run and read calls, or the runtime's `/automation` and
+`/actions` routes driven without HTTP) and returns what that door returned. A
+test never builds its own `ctx`, never orders hooks by hand and never copies a
+permission check, so it keeps telling the truth when the engine changes.
+
+### Booting the stack
```typescript
-// tests/triggers/account_validation.test.ts
-import { createTestContext } from '@objectstack/testing';
-import accountValidation from '../../src/triggers/account_validation';
-
-describe('Account Validation Trigger', () => {
- it('should reject negative revenue', async () => {
- const context = createTestContext();
- const record = { annual_revenue: -1000 };
-
+// test/crm.test.ts
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { bootStack, type VerifyStack } from '@objectstack/verify';
+import config from '../objectstack.config';
+
+describe('CRM plugin', () => {
+ let stack: VerifyStack;
+ let admin: string;
+ let rep: string;
+
+ beforeAll(async () => {
+ stack = await bootStack(config);
+ admin = await stack.signIn(); // the dev admin the stack seeds
+ rep = await stack.signUp('rep@example.com'); // a fresh member: the default profile only
+ });
+
+ afterAll(async () => {
+ await stack.stop();
+ });
+
+ it('refuses negative revenue', async () => {
+ // One real write: before* hooks, validation, the driver, after* hooks,
+ // and the permission check the caller is subject to.
await expect(
- accountValidation.execute({ record, context })
- ).rejects.toThrow('Annual revenue cannot be negative');
+ stack.hooks.run('crm_account', 'insert', { name: 'Acme Corp', annual_revenue: -1000 }, { as: admin }),
+ ).rejects.toMatchObject({ code: 'VALIDATION_FAILED' });
});
-
- it('should generate account number', async () => {
- const context = createTestContext();
- const record = { name: 'Acme Corp' };
-
- const result = await accountValidation.execute({ record, context });
-
- expect(result.account_number).toMatch(/^ACC-\d{6}$/);
+
+ it('numbers a new account', async () => {
+ const account = await stack.hooks.run('crm_account', 'insert', { name: 'Acme Corp' }, { as: admin });
+ expect(account.account_number).toMatch(/^ACC-\d{6}$/);
+ });
+
+ it('keeps accounts away from a member without a grant', async () => {
+ await expect(stack.rows('crm_account', {}, { as: rep }))
+ .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
});
});
```
-### Integration Tests
+Assert on a refusal's `code` (and its status), never on its message alone.
+A refusal from `hooks.run`, `validate` or `rows` is the engine's own error,
+rethrown unchanged; one from `flows.*` or `actions.run` is the route's error
+envelope (`code`, `status`, `details`), which `isVerifyRefusal(e)` recognises.
-```typescript
-// tests/integration/crm_workflow.test.ts
-import { createTestEnvironment } from '@objectstack/testing';
+### What the handle drives
-describe('CRM Workflow', () => {
- let env;
-
- beforeAll(async () => {
- env = await createTestEnvironment({
- plugins: ['@mycompany/crm'],
- });
- });
-
- it('should create account with contacts', async () => {
- // Create account
- const account = await env.objectQL.create('account', {
- name: 'Test Corp',
- industry: 'technology',
- });
-
- // Create contact
- const contact = await env.objectQL.create('contact', {
- first_name: 'John',
- last_name: 'Doe',
- account: account.id,
- });
-
- // Verify relationship
- const accountWithContacts = await env.objectQL.findById('account', account.id, {
- include: ['contacts'],
- });
-
- expect(accountWithContacts.contacts).toHaveLength(1);
- expect(accountWithContacts.contacts[0].id).toBe(contact.id);
- });
-});
+| Method | Door it drives |
+|:---|:---|
+| `hooks.run(object, operation, input, { as })` | One `'insert'`, `'update'` or `'delete'` through the engine, hooks, validation and permission checks included. `update` and `delete` address the row by `input.id`. |
+| `validate(object, record, { as, mode? })` | The engine's validation pass without writing; resolves `{ valid, results }`. Hooks do not run. |
+| `flows.run(name, params, { as })` / `flows.resume(run, input, { as })` | A flow through the runtime's automation route. Boot with `{ automation: true }` so the automation service is there to drive. |
+| `actions.run(object, action, { as, recordId?, params? })` | A declared action through its route, param contract and permission gate included. |
+| `seed(object, rows)` | Fixture rows written as the system, the way a stack's `data` is seeded: hooks and validation run, record-change flows do not. |
+| `rows(object, where?, { as? })` | A read through the engine, as the system by default or as `as` under that caller's grants and row-level security. |
+| `metadata` · `tenancy()` · `contextFor(token)` | The booted registry, the tenancy posture, and the execution context the platform resolves for a caller. |
+
+`as` is always a bearer token from `signIn()` or `signUp()` on the same stack.
+
+### A runtime plugin
+
+A plugin class (see [Runtime Contract](#runtime-contract)) boots with the stack
+through `extraPlugins`, so its `init` and `start` run against the real kernel,
+and the services it registers resolve from `stack.kernel`:
+
+```typescript
+const stack = await bootStack(config, { extraPlugins: [new CRMPlugin()] });
+const crm = stack.kernel.getService('crm');
```
+A pure helper with no engine behind it needs no harness: test it with plain
+vitest. Many test files can share one boot through `bootStackOnce(config)`,
+under vitest's `isolate: false`. The `@objectstack/verify` README documents
+every method and boot option.
+
## Plugin Development Workflow
### 1. Scaffold Plugin