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
Conversation
…ate, 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
… rows pin to the whole batch Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj
…; 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
…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
…rify-in-process-handle
…stant 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
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
📓 Docs Drift CheckThis PR changes 1 package(s): 65 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d143c87facbddb0a41dc788a70ca2fcef0f50c37 && git checkout d143c87facbddb0a41dc788a70ca2fcef0f50c37
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fd5cff209f4416a5d8bd9b08eaa8d42a0bee06d3 372c16ec597369a413de88ff0f4e9f9e71775bfa && git checkout -B drift-repro fd5cff209f4416a5d8bd9b08eaa8d42a0bee06d3 && git merge --no-ff 372c16ec597369a413de88ff0f4e9f9e71775bfa
node scripts/docs-audit/affected-docs.mjs --json fd5cff209f4416a5d8bd9b08eaa8d42a0bee06d3
|
Part of #15951 (epic hotcrm#1579, step 5a). ⛔ Deliberately not
Fixes— the card's clause-② contract review moves to the director (maintainer, 2026-09-09: 「等总监复审」), so merging this must not close the card while that review is outstanding. Close it by hand after the review lands.⛔ DRAFT on purpose. Do not mark ready, do not arm auto-merge — 「等总监复审」.
What this adds
@objectstack/verify'sbootStackalready boots the real kernel — ObjectQL, hooks, validation,SecurityPlugin, sharing, automation, the REST/dispatcher surfaces — in memory. Until now the only way to drive that stack was HTTP request injection, so an app that wanted to assert on what a hook, flow, action or validation rule did either read it off a JSON response or rebuilt the engine's semantics in a stand-in. hotcrm grew 4,069 lines of the second kind.Every
VerifyStacknow also carries an in-process handle on the same kernel. Zero re-implemented semantics: no ownctx.api, no own hook ordering, no own state machine, no own permission model. Each method is a thin facade over a door the kernel wired at boot, and returns what that door returned.The exported surface, and where it differs from the card's sketch
hooks.run(object, event, input, { user })hooks.run(object, 'insert' | 'update' | 'delete', input, { as }){ as }takes a bearer token fromsignIn/signUp, so identity is resolved by the platform, never assembled here.flows.run/flows.resume{ as };resumetakes theFlowRunthatrunreturnedFlowRunis the engine'sAutomationResultplusflowName, so a paused run hands straight back toresumewith no destructuring.actions.run(object, action, { record, input, user })actions.run(object, action, { as, recordId?, params? })recordId(notrecord) because the door loads the subject row under the caller's scope — passing a row would bypass the load, which is part of the contract.paramsis the ADR-0104 spelling.validate(object, record, { user })validate(object, record, { as, mode? })modebecauseObjectQL.validatedistinguishesinsertfromupdatedefaults, and a preview that silently picks one is a false alarm generator.seed/rowsseed(object, rows),rows(object, where?, { as? })seed's{ asSystem }is dropped: seeding is always the platform's own seed-replay context, so the flag had exactly one useful value.rowsis system-scoped by default and takes{ as }to read under a caller's grants and RLS.metadatametadata.object()/.objects()/.items(type)/.types()items()uses the registry's singularMetadataTypeSchemavocabulary ('permission', not'permission_set') — the first draft invented a plural name and the registry, correctly, held nothing under it.BootOptions.tenancy: 'single' | 'multi'tenancy()reads the posture back--multi-tenantalready exists onos verify; expose the option, do not invent a second one."BootOptions.multiTenantis that option, already published, with three states (false,'posture-only',true) that a two-valuetenancykey cannot express. Adding a second spelling would have been the invented option the bullet forbids. What was missing was the read, whichtenancy()now is.bootStackOnce(config, options)(config, opts)identitySecurityPlugininstances mean the same thing. A failed boot is evicted so the next caller retries.Also exported:
isVerifyRefusal, and the typesVerifyHandle,VerifyRefusal,AsUser,FlowRun,FlowRunRef,EngineRow.Which door each method is a facade over, stated because it is the whole design:
hooks.run·validate·seed·rows→ the ObjectQL engine, the same calls@objectstack/rest's data ingress makes. The hook chain, the validation pass and the SecurityPlugin middleware all live inside those calls.flows.*·actions.run→ the runtime'sHttpDispatcher, driven in-process (no Hono, no socket, no JSON round-trip). Those two routes are the only doors carrying the full contract — the ADR-0066 D4 gate, the ADR-0104 param contract, the subject-record load, the trusted-body context, the ADR-0112 envelopes — and the runtime exposes no lower in-process door with the same contract. That is a kernel gap, and it is filed (below), not worked around.contextFor(token)→ the dispatcher's own request-identity resolution. The handle never assembles anExecutionContext.metadata→ the bootedSchemaRegistry.tenancy()→ thetenancyservice AuthPlugin registered at boot.Acceptance 1 — per-method ablation
Each leg: mutate the kernel service in source, prove it landed (
grepon the anchor +git hash-objectdiffers from theHEADblob), rebuild the owning package, prove the mutation reacheddist/(scripts/ablation-dist-preflight.mjs) —packages/verify's tests resolve all five of these packages throughexports, i.e.dist/, so an un-rebuilt ablation would have stayed green and certified a vacuous pin — run the pins, restore withgit checkout HEAD -- ABSOLUTE_PATH, then prove the restore two ways (blob hash equals theHEADblob and whole-treegit status --porcelainis empty), rebuild, and prove the marker is absent fromdist/again. Every leg carries atrap ... EXIT INT TERM.ObjectQL.triggerHooks— metadata-bound hook dispatch skippedhooks.runderivation pin, the parity row, the seed's derived column, all 12 exemplar casescontextFor,validate,flows,actions,metadata,tenancy, both refusal pins16f986a1→16f986a1✓evaluateValidationRulesreturns before evaluatingvalidatedeclared-rule verdicta277ff1a→a277ff1a✓SecurityPlugin's ObjectQL middleware bypassedrowsas a caller29b0eb04→29b0eb04✓AutomationEngine.resumereturns without continuingecd3d23a→ecd3d23a✓actionBodyRunnerFactory's bound handler returns without running the bodyb15bbd1e→b15bbd1e✓HttpDispatcher.resolveRequestScoperesolves no identitycontextFor, and every method that carries a callermetadata,tenancy,bootStackOnce, the call-shape refusals4aa3b054→4aa3b054✓falsesinglepinb18d1851→b18d1851✓SchemaRegistry.getRegisteredTypesreturns emptymetadata.types()47e39d4c→47e39d4c✓A6 reddening 13 pins is the intended reading, not collateral noise: if the handle assembled its own execution context — the defect this card exists to prevent — breaking the platform's identity resolver would have left it green.
probeIsolationanswertrue; the suite stayed green. Cause:isolationActive()short-circuits onrequestedPosture === 'single'before consulting the probe, so on a plain boot the mutation was unreachable. But the diagnosis also exposed a real weakness — the only tenancy pin asserted thesingleposture, which any constant satisfies, and a constant is exactly what the hand-written tenancy probe this method retires was. So the same reader is now also pointed at a stack bootedmultiTenant: 'posture-only', where the probe is consulted. A7b then fires on the walled case and leaves thesinglecase green.Acceptance 2 — parity pin, both directions
hooks.run(...)and throughapiAs(member, 'POST', '/data/hnd_deal', ...); both persisted rows compared on the hook-derived columns andcreated_by. Equal. (A1 reddens it, so it is measuring the chain.)hnd_vault.hooks.runrejects withcode: PERMISSION_DENIED,statusCode: 403; the REST write answers403with the same code. With a control that fires and discriminates: the identical call as the platform admin is admitted by both doors (201/ a row with an id), so the refusal is the grant being evaluated, not the object being broken. (A3 reddens it.)Acceptance 3 — one hotcrm exemplar ported
test/hooks-runtime-sales.test.ts'sopportunity_lifecycleblock, ported topackages/verify/src/handle.exemplar-deal-lifecycle.test.ts. ⛔ The hotcrm repo is not touched.Chosen because it is the one whose stand-in was most of the 4,069 lines and whose fake was the most load-bearing:
hook-harness.tsis 618 lines with 38 importers, and it fakesctx.apiover arrays with a hand-written Mongo-ishmatches(), with no permission check anywhere. The other four candidates each exercise a narrower seam.What the port cost: every assertion is the original's, unchanged. What changed is the instrument — each case is now a real write through the booted engine as a real member, the L2 body runs in the QuickJS runner the runtime bound at boot, and the
previous-driven cases that were hand-constructed asmakeCtx({ previous })are a seeded row plus anupdate, with the engine supplying the pre-image. The permission check the stand-in never had now runs first. 12 cases, no helper, noctx.apifake.Acceptance 4, 5, 6
os verifybehaviour unchanged;runCrudVerification/runRlsProofsand their tests untouched — the diff adds files and extendsVerifyStack, and the package's full suite is green at 103 passed / 14 files.@objectstack/verifyminor, carryingClause-②: yesin the fixed spellingscripts/pm/check-clause2-carriers.mjsreads.packages/verify/README.mddocuments the handle, each method's door, the refusal shapes andbootStackOnce. ⛔ Theplugin-spec.mdxghost is step 5b's (docs + scaffold:plugin-spec.mdxstops promising the non-existent@objectstack/testingand points at@objectstack/verify; thecreate-objectstackblank template ships a test story (epic hotcrm#1579, step 5b) #15952), untouched here.Kernel gaps found and filed — ⛔ neither is worked around here
The card's binding rule is that a semantic the kernel does not expose gets filed, never re-implemented in
verify. Two were hit, both filed:isSystem/skipTriggers/seedReplay) is a private constant in two places (SeedLoaderService.SEED_OPTIONS, andapp-plugin.ts'sSEED_WRITE_OPTIONS, whose own docblock says it mirrors the first).seed()needs it and therefore spells a third copy, once, with a comment naming the owner. A three-flag invariant whose divergence re-opens The #1888 user-less fail-open is wider than the lint that guards it — record-change flows fired by a system write run UNSCOPED, unlinted #3760's boot-wedging self-trigger loop should have one exported spelling.flows.*andactions.rundriveHttpDispatcherrather than the service beneath it: reaching the service directly would get the engine without the D4 gate, the param contract or the identity envelope, and re-creating those here is the forbidden thing.Evidence
pnpm --filter @objectstack/verify test— 14 files / 103 tests, all pass.pnpm --filter @objectstack/verify typecheck— clean, source layer and test layer (check:test-typecheck: 0 files / 0 errors).scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(62 commands, derived from the change set, not guessed): 60 green. The two non-green arecheck:dual-build-cjs-loadsandcheck:type-check-debt, both exiting 3 =PREREQUISITE NOT MET— they read built output and this worktree has no full-workspacedist/. ⛔ That is not a pass and not a finding: nothing was measured. CI builds the closure first and measures them there.eslint . --no-inline-config— the whole repo, not a narrowed set: 6,437 files, 0 errors, 0 warnings (--format json, counted from the output). Run on the final commit,372c16ec.packages/qa/dogfoodtypecheck: the one error attributable to this diff's shape (TS2353onBootOptions) was a stale-artifact reading — it disappeared once@objectstack/verifyitself was built, which is the point of the widened fake inrls-runner.test.ts. The 31 remainingTS2307s are unbuilt sibling packages; control: building@objectstack/plugin-auditalone dropped the count to 24 and cleared its own entry. Zero errors name the edited file.packages/cli's unit tier: one failure,optional-package.test.ts, on a probe that loads@objectstack/cloud-connection— not in this diff's path, and green in CI onorigin/main5d12b16e. Measured mechanism and a second instance of [finding] core's service-resolution-discriminator pin awaits a dynamic import of the whole package index under vitest's default 5000ms timeout, and timed out once in two full-suite runs on a shared box #16497's class; filed as [finding] packages/cli's optional-package probe pays a whole-package vitest transform inside a 5000ms budget — 707ms in node, >5000ms under vitest (second instance of #16497's class) #17180.Assumptions worth naming
bootStackand holds no state beyond the dispatcher it drives; every call resolves the engine and services offkernelat call time, so a service replaced after boot is picked up.bootStackOnceshares a boot only underisolate: false. Under vitest's default isolation each file still boots its own — documented on the function, with dogfood's eligibility rules restated verbatim.bootStackOnce(config: any)keepsanydeliberately: it is the signaturebootStack(config: any)already ships, and narrowing one of the pair alone would be a new inconsistency on a published surface.Generated by Claude Code