Repository navigation
feat(verify): the handle observes the writes the engine receives, a hook's refused write included - #22781
Conversation
…the engine receives Item 6 of the in-process handle card: observe what a hook handed the engine, a refused write included; wait for an async hook's write; stage a refusal through the app's own rules rather than a spy. One global middleware on the engine's own chain records each insert, update and delete while an observation is open, with the engine's answer. It passes every operation on unchanged. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…em6-hook-observation
📓 Docs Drift Check11 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 3 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 112a28ed41e565399b5c43c304bfae7825fc125f && git checkout 112a28ed41e565399b5c43c304bfae7825fc125f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e84aeb36ce14169a633670f14ce8280fc998e2b9 6f6793c636102428bc6b09bb1a05a412c6334926 && git checkout -B drift-repro e84aeb36ce14169a633670f14ce8280fc998e2b9 && git merge --no-ff 6f6793c636102428bc6b09bb1a05a412c6334926
node scripts/docs-audit/affected-docs.mjs --json e84aeb36ce14169a633670f14ce8280fc998e2b9 |
Contract reviewServed-tier: Item 6 of the card, first round. Head resolved from ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Part of #22301
Clause-②: yes (widening)
Item 6 of the card: there was no observation of what a hook handed the engine. A refused write left no row, an
async: truehook's completion was invisible, and a refusal could not be staged without a spy on the engine. Item 7 and item 1's remaining divergence are not in this PR, and the card stays open for them.What changes
stack.observeWrites(fn)(packages/verify/src/handle.ts) runsfnand resolves with every insert, update and delete the booted engine received meanwhile, in the order each reached it. Each entry (ObservedWrite) carriesobject,operation,data(copied on arrival),where,multi, the executioncontextthe write carried, andoutcome. The outcome startspending, then becomesresolvedwith the engine's result, orrefusedwith the error the engine threw (the same object) and that error's owncode.fnis handed the live observation (WriteObservation).observation.settled(match)resolves with the first writematchaccepts once the engine has answered it. That is how a test waits for anasync: truehook's write. It rejects aftertimeoutMs(default 2000), naming every write observed, or when the observation closes first.registerMiddleware, a member of theIObjectQLEnginecontract), registered on the first call and kept. With no observation open it isreturn next(). It changes no payload, no answer and no error, and decides nothing. No engine file and no spec file is touched.fnormatchthat is not a function, and atimeoutMsthat is not a positive number, are refused withINVALID_REQUEST/400, the handle's existing call-shape refusal. No error code is added.ObservedWrite,ObservedWriteOutcome,WriteObservation.handle.ts's header andharness.ts'sVerifyStackdocblock name the door.packages/verify/README.mdadds an example and a bullet.packages/qa/dogfood/test/rls-runner.test.ts: its fakeVerifyStacklists every handle member asnever, and gainsobserveWrites(the item 8 lesson)..changeset/22301-verify-observe-writes-door.md:@objectstack/verifyminor,Clause-②: yes (widening).What was measured before the door was built
These were measured with a scratch probe (since deleted) on a booted stack at
490cb6d9fa. The probe registered a global middleware on the bootedobjectqlafter boot, which is exactly what the door does.Where a hook's writes travel. A hook's
ctx.apiis aScopedContext. Itsobject(name).insert(...)callsengine.insert(name, row, { context })(ObjectRepository,packages/objectql/src/engine.ts), so every write a hook makes passes the engine's middleware chain like any other. The candidates:WriteObservabilityOptions: per-call options chosen by whoever makes the write, and a hook'sctx.api.insertpasses only{ context }, so a test cannot set them on a hook's write.objectqlslot: that is how the door reaches the engine.The probe saw a refused hook write that leaves no row: an
afterInserthook'sctx.apiinsert, refused by a declared validation rule, was recordedrefusedVALIDATION_FAILED, and no row was found.The boundary the position fixes. A middleware registered after boot sits after the write gates the boot registered. A hook's write that the permission check refused was not recorded at all, and the hook caught
PERMISSION_DENIED. That gate throws before callingnext()(plugin-security's write middleware). Two refusals happen before the chain on all three verbs, so no middleware sees them: an external datasource (assertWriteAllowed) and a cross-driver write in a transaction (enforceTransactionOrigin).How
async: truehooks are scheduled.wrapDeclarativeHook(packages/objectql/src/hook-wrappers.ts) starts the handler asvoid runWithErrorPolicy(detached). No promise, queue or drain is kept anywhere a test could await. The probe's fire-and-forget write was absent whenhooks.runreturned and present 300 ms later. So the honest await is the hook's write reaching the engine (settled), and awaiting the hook's completion itself needs a new seam (carried need C2 below).Staging a refusal through existing doors.
uniquevalue made the hook's insert refusedDUPLICATE_RECORD, and the refusal was recorded.VALIDATION_FAILED, and recorded.strictReadonlyWrites: a per-call option the writer chooses, which a test cannot set on a hook's write.So need 3 is documentation plus pins, not new code.
The observer is global. After an observation,
hasObjectMiddlewarestill answersfalsefor a fixture object, so the analytics native-SQL strategy's per-object check is unaffected.Per need: door, or decision
observeWritesrecords the refusal with the engine's own error, for every refusal past the write gates.settled). A hook that writes nothing stays invisible; that is carried need C2.Out of scope, by decision
mockRejectedValue) is out of scope.ctx.apiagain (epic hotcrm#1579, step 5a) #15951 B′ rules out a door that answers in place of the engine.initandstart, so it would take a plugin added tobootStack's composition. Ruling6070767186(A) hasbootStackcompose whatservecomposes, and the handle is not a second boot path.Carried needs (new engine seams, named and not built)
registerMiddleware. Its home is the engine contract (IObjectQLEngineinpackages/spec/src/contracts/objectql-engine.ts) andpackages/objectql/src/engine.ts. Until then, a gate's refusal reaches the hook that made the write, and a test can read it there.wrapDeclarativeHookcould register each detached run with the engine, which would expose an awaitable drain. This makes anasync: truehook's completion observable even when it writes nothing, and its own failure (today only anerrorlog line) assertable. Its home ispackages/objectql/src/hook-wrappers.tsplus an engine member.Pins:
packages/verify/src/handle.observe-writes.test.ts(11 tests)The fixture is neutral. Inserting an
ow_orderfiresafterInserthooks that write anow_task(uniquecode, validation ruletitle_not_blocked), anow_vault(granted to nobody but the admin), or, fire-and-forget behind a latch the test releases, anow_tasklater. Each hook swallows what the engine answered into a map, so the triggering write succeeds either way.refusedVALIDATION_FAILED, with its payload, the triggering user's context and the engine'sValidationError. No row. Control: an accepted title is recordedresolved, and its row is there.toBe), and the same call unobserved answers the samecode/status.hooks.runreturns.settledresolves with itresolved, and the row is there.settledalso answers a fire-and-forget write the engine refuses, which leaves no row.DUPLICATE_RECORD, and only the holder's row exists. Control: a fresh code is written.PERMISSION_DENIED. Control: the admin's same hook write is recordedresolved.where,multi, caller and result (the update's affected-row count, 1).fn: a later write is not added, andsettledafter the close rejects.settledrejects on its timeout, and its message names the writes it saw.hasObjectMiddlewarestill answersfalse.INVALID_REQUEST/400.Ablations
All four ran on the committed head
60d6583edcthroughnode scripts/ablation-replace.mjsin wrap mode. In each, the anchor went 1 → 0 and the blob changed. Each was restored to theHEADblob62a14c3b71ef, withgit diff HEADempty. The predictions were written down before the first run. The subject is reached by a relativesrcimport, so nodist/is involved.pending). Predicted red: T1, T2, T4, T5. Observed: 4 failed and 7 passed, exactly those four.settledaccepts a write the engine has not answered. Predicted red: T3, T4. Observed: 2 failed and 9 passed, exactly those two.Verification
Each result below names the commit it ran at. The final head is
6f6793c636, a merge oforigin/maine84aeb36ce, which touches no path of this PR.turbo run build --filter='@objectstack/verify^...'passed 47/47 at490cb6d9fa.--filter='@objectstack/dogfood^...' --filter=@objectstack/verifypassed 63/63 at6f6793c636.@objectstack/verifyfull suite:vitest run --maxWorkers=2passed 29 files and 234 tests, at60d6583edcand again at6f6793c636. The new file passed 11/11 on its own.pnpm --filter @objectstack/verify typecheck(tsc --noEmitandcheck:test-typecheck) exited 0 at6f6793c636.tsc -p tsconfig.test.json --listFilesreaches 29/29 of the package's test files, including the new one.pnpm --filter @objectstack/dogfood typecheckexited 0 at6f6793c636. Verify was rebuilt as a cache miss first, and itsdist/index.d.tsnamesobserveWrites3 times.observeWritesline turnedtscred with TS2741 ("Property 'observeWrites' is missing … required in type 'VerifyStack'"). It was restored through the tool.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackwith no paths derived 68 commands at6f6793c636, and all 68 ran there.check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: it reads a whole-workspace build, which this dispatch does not run). It is NOT MEASURED and left to CI.--ranreconciliation: 68 derived, 67 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN.pnpm lint(the repo-wide scan) is CI's run and is not claimed here. A targetedeslint --no-inline-config --format jsonover the 5 touched.tsfiles linted 5 files, with 0 errors and 0 warnings.eslint.config.mjsnever enables type-aware linting, so this diff cannot move the verdict on an untouched file.Acceptance notes
afterInserthook that throws (onError: 'abort', the default) rejected the triggering insert, and both that row and an earlier hook's row stayed stored. Neitherhooks.runnor the REST data ingress (createData→engine.insert) opens a transaction. The door's docs say so ("read rows back withrows"). The spec half is reported to the seat on the card as a finding, not filed from here.resultis the executor's answer before the outer gates' post-phase (field-level masking). It is the system-side view, stated in the type's docblock.sys_jwks) was observed in the probe's window, so the pins filter by object, and the docs say to.repo:hotcrmseat):recordEngineWritesmaps ontoobserveWrites, refused writes and fire-and-forget writes included. A staged refusal moves to a real one (seeded state, or the app's validation rule). A refusal by a permission gate and a hook that writes nothing are the two cases still out of reach (C1, C2).Generated by Claude Code