Skip to content

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

Merged
os-steve merged 7 commits into
mainfrom
claude/issue-15951-verify-in-process-handle
Sep 10, 2026
Merged

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
os-steve merged 7 commits into
mainfrom
claude/issue-15951-verify-in-process-handle

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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's bootStack already 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 VerifyStack now also carries an in-process handle on the same kernel. Zero re-implemented semantics: no own ctx.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

Card's sketch Delivered Why
hooks.run(object, event, input, { user }) hooks.run(object, 'insert' | 'update' | 'delete', input, { as }) The engine has no "run the hook chain for event E" door, and building one would be its own dispatch — the forbidden thing. What it has is the write door the REST ingress calls, with the chain inside it. So the parameter is the operation, not the event: you ask for the write, and the real chain runs in the real order. { as } takes a bearer token from signIn/signUp, so identity is resolved by the platform, never assembled here.
flows.run / flows.resume same, { as }; resume takes the FlowRun that run returned FlowRun is the engine's AutomationResult plus flowName, so a paused run hands straight back to resume with no destructuring.
actions.run(object, action, { record, input, user }) actions.run(object, action, { as, recordId?, params? }) recordId (not record) 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. params is the ADR-0104 spelling.
validate(object, record, { user }) validate(object, record, { as, mode? }) mode because ObjectQL.validate distinguishes insert from update defaults, and a preview that silently picks one is a false alarm generator.
seed / rows seed(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. rows is system-scoped by default and takes { as } to read under a caller's grants and RLS.
metadata metadata.object() / .objects() / .items(type) / .types() items() uses the registry's singular MetadataTypeSchema vocabulary ('permission', not 'permission_set') — the first draft invented a plural name and the registry, correctly, held nothing under it.
BootOptions.tenancy: 'single' | 'multi' not addedtenancy() reads the posture back The card's own bullet says "--multi-tenant already exists on os verify; expose the option, do not invent a second one." BootOptions.multiTenant is that option, already published, with three states (false, 'posture-only', true) that a two-value tenancy key cannot express. Adding a second spelling would have been the invented option the bullet forbids. What was missing was the read, which tenancy() now is.
bootStackOnce(config, options) same, memoised per (config, opts) identity Reference keys, never structural: the memo must not decide that two SecurityPlugin instances mean the same thing. A failed boot is evicted so the next caller retries.

Also exported: isVerifyRefusal, and the types VerifyHandle, 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's HttpDispatcher, 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 an ExecutionContext.
  • metadata → the booted SchemaRegistry. tenancy() → the tenancy service AuthPlugin registered at boot.

Acceptance 1 — per-method ablation

Each leg: mutate the kernel service in source, prove it landed (grep on the anchor + git hash-object differs from the HEAD blob), rebuild the owning package, prove the mutation reached dist/ (scripts/ablation-dist-preflight.mjs) — packages/verify's tests resolve all five of these packages through exports, i.e. dist/, so an un-rebuilt ablation would have stayed green and certified a vacuous pin — run the pins, restore with git checkout HEAD -- ABSOLUTE_PATH, then prove the restore two ways (blob hash equals the HEAD blob and whole-tree git status --porcelain is empty), rebuild, and prove the marker is absent from dist/ again. Every leg carries a trap ... EXIT INT TERM.

# Kernel service broken Package Pins that went RED Green survivors (the discrimination control) Restore
A1 ObjectQL.triggerHooks — metadata-bound hook dispatch skipped objectql 16 — every hooks.run derivation pin, the parity row, the seed's derived column, all 12 exemplar cases 19 — contextFor, validate, flows, actions, metadata, tenancy, both refusal pins 16f986a116f986a1
A2 evaluateValidationRules returns before evaluating objectql 1 — validate declared-rule verdict 22 a277ff1aa277ff1a
A3 SecurityPlugin's ObjectQL middleware bypassed plugin-security 2 — the parity refusal, and rows as a caller 21 29b0eb0429b0eb04
A4 AutomationEngine.resume returns without continuing service-automation 1 — the flow pause-then-resume pin 22 ecd3d23aecd3d23a
A5 actionBodyRunnerFactory's bound handler returns without running the body runtime 1 — the sandboxed action body pin 22 — including the ADR-0104 param refusal, which refuses before the body b15bbd1eb15bbd1e
A6 HttpDispatcher.resolveRequestScope resolves no identity runtime 13 — contextFor, and every method that carries a caller 11 — metadata, tenancy, bootStackOnce, the call-shape refusals 4aa3b0544aa3b054
A7b tenancy service's isolation probe answers false plugin-auth 1 — the walled-posture pin 23 — including the single pin b18d1851b18d1851
A8 SchemaRegistry.getRegisteredTypes returns empty objectql 1 — metadata.types() 22 47e39d4c47e39d4c

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.

⚠️ A7 failed first, and that is reported rather than tidied away. The original leg made the tenancy service's probeIsolation answer true; the suite stayed green. Cause: isolationActive() short-circuits on requestedPosture === '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 the single posture, 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 booted multiTenant: 'posture-only', where the probe is consulted. A7b then fires on the walled case and leaves the single case green.

Acceptance 2 — parity pin, both directions

  • Positive. Same input through hooks.run(...) and through apiAs(member, 'POST', '/data/hnd_deal', ...); both persisted rows compared on the hook-derived columns and created_by. Equal. (A1 reddens it, so it is measuring the chain.)
  • Negative. The member holds no grant on hnd_vault. hooks.run rejects with code: PERMISSION_DENIED, statusCode: 403; the REST write answers 403 with 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's opportunity_lifecycle block, ported to packages/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.ts is 618 lines with 38 importers, and it fakes ctx.api over arrays with a hand-written Mongo-ish matches(), 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 as makeCtx({ previous }) are a seeded row plus an update, with the engine supplying the pre-image. The permission check the stand-in never had now runs first. 12 cases, no helper, no ctx.api fake.

Acceptance 4, 5, 6

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:

Evidence

Assumptions worth naming

  • The handle is built once per bootStack and holds no state beyond the dispatcher it drives; every call resolves the engine and services off kernel at call time, so a service replaced after boot is picked up.
  • bootStackOnce shares a boot only under isolate: 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) keeps any deliberately: it is the signature bootStack(config: any) already ships, and narrowing one of the pair alone would be a new inconsistency on a published surface.

Generated by Claude Code

…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
…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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/verify, touching 67 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/verify/README.md, packages/verify/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

65 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json fd5cff209f4416a5d8bd9b08eaa8d42a0bee06d3.

9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/verify/README.md, packages/verify/src/index.ts) — pages documenting those are invisible to this run
  • 2 anchor(s) matched too much of the corpus to be a work list: sharingModel (symbol, 42 pages), /api/v1 (route, 86 pages)
  • 26 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fd5cff209f4416a5d8bd9b08eaa8d42a0bee06d3packageMentionDocs.

Which tree this was computed on

This run read content/docs from d143c87facbddb0a41dc788a70ca2fcef0f50c37 — the merge of head 372c16ec597369a413de88ff0f4e9f9e71775bfa into base fd5cff209f4416a5d8bd9b08eaa8d42a0bee06d3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fd5cff209f4416a5d8bd9b08eaa8d42a0bee06d3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants