Skip to content

feat(verify): the handle observes the writes the engine receives, a hook's refused write included - #22781

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22301-item6-hook-observation
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22301-item6-hook-observation

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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: true hook'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) runs fn and resolves with every insert, update and delete the booted engine received meanwhile, in the order each reached it. Each entry (ObservedWrite) carries object, operation, data (copied on arrival), where, multi, the execution context the write carried, and outcome. The outcome starts pending, then becomes resolved with the engine's result, or refused with the error the engine threw (the same object) and that error's own code.
  • fn is handed the live observation (WriteObservation). observation.settled(match) resolves with the first write match accepts once the engine has answered it. That is how a test waits for an async: true hook's write. It rejects after timeoutMs (default 2000), naming every write observed, or when the observation closes first.
  • The mechanism: one global middleware on the engine's own chain (registerMiddleware, a member of the IObjectQLEngine contract), registered on the first call and kept. With no observation open it is return next(). It changes no payload, no answer and no error, and decides nothing. No engine file and no spec file is touched.
  • A fn or match that is not a function, and a timeoutMs that is not a positive number, are refused with INVALID_REQUEST / 400, the handle's existing call-shape refusal. No error code is added.
  • New exported types: ObservedWrite, ObservedWriteOutcome, WriteObservation.
  • Door rosters: handle.ts's header and harness.ts's VerifyStack docblock name the door. packages/verify/README.md adds an example and a bullet.
  • packages/qa/dogfood/test/rls-runner.test.ts: its fake VerifyStack lists every handle member as never, and gains observeWrites (the item 8 lesson).
  • .changeset/22301-verify-observe-writes-door.md: @objectstack/verify minor, 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 booted objectql after boot, which is exactly what the door does.

  1. Where a hook's writes travel. A hook's ctx.api is a ScopedContext. Its object(name).insert(...) calls engine.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:

    • The middleware chain: the seam the door sits on.
    • WriteObservabilityOptions: per-call options chosen by whoever makes the write, and a hook's ctx.api.insert passes only { context }, so a test cannot set them on a hook's write.
    • The hook metrics recorder: it sees a hook's outcome, not its writes, and it is read when hooks are bound, so it cannot be swapped after boot.
    • The kernel's objectql slot: that is how the door reaches the engine.

    The probe saw a refused hook write that leaves no row: an afterInsert hook's ctx.api insert, refused by a declared validation rule, was recorded refused VALIDATION_FAILED, and no row was found.

  2. 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 calling next() (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).

  3. How async: true hooks are scheduled. wrapDeclarativeHook (packages/objectql/src/hook-wrappers.ts) starts the handler as void runWithErrorPolicy(detached). No promise, queue or drain is kept anywhere a test could await. The probe's fire-and-forget write was absent when hooks.run returned 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).

  4. Staging a refusal through existing doors.

    • Seeded state: a seeded holder of a unique value made the hook's insert refused DUPLICATE_RECORD, and the refusal was recorded.
    • A declared validation rule: refused VALIDATION_FAILED, and recorded.
    • A permission: the write is refused, but past the observer's reach (point 2).
    • 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.

  5. The observer is global. After an observation, hasObjectMiddleware still answers false for a fixture object, so the analytics native-SQL strategy's per-object check is unaffected.

Per need: door, or decision

  • Need 1, a refused write leaves no row: a door. observeWrites records the refusal with the engine's own error, for every refusal past the write gates.
  • Need 2, an async hook's completion: a door for the hook's write (settled). A hook that writes nothing stays invisible; that is carried need C2.
  • Need 3, staging a refusal: documentation plus pins. Arrange a refusal the engine makes itself: seed the row that already holds a unique value, or set up the state a declared validation rule refuses.

Out of scope, by decision

  • A switch that refuses a write in the engine's place (the spy's mockRejectedValue) is out of scope.
  • Seeing a gate's refusal by moving the observer ahead of the gates at boot is out of scope.
    • The kernel has no boot hook between init and start, so it would take a plugin added to bootStack's composition. Ruling 6070767186 (A) has bootStack compose what serve composes, and the handle is not a second boot path.
    • The engine has no "register first" option, and pushing into its private middleware list would be a spy by another name.
    • The real home is carried need C1.

Carried needs (new engine seams, named and not built)

  • C1, observing a write before the write gates. A hook's write refused by a gate (the permission check, record-level security, the tenant wall) cannot be observed without a seam ahead of the chain. One example is a write-observer member at the engine's operation door, which would also see the two pre-chain refusals. Another is a position option on registerMiddleware. Its home is the engine contract (IObjectQLEngine in packages/spec/src/contracts/objectql-engine.ts) and packages/objectql/src/engine.ts. Until then, a gate's refusal reaches the hook that made the write, and a test can read it there.
  • C2, an engine-owned record of fire-and-forget hook runs. For example, wrapDeclarativeHook could register each detached run with the engine, which would expose an awaitable drain. This makes an async: true hook's completion observable even when it writes nothing, and its own failure (today only an error log line) assertable. Its home is packages/objectql/src/hook-wrappers.ts plus an engine member.

Pins: packages/verify/src/handle.observe-writes.test.ts (11 tests)

The fixture is neutral. Inserting an ow_order fires afterInsert hooks that write an ow_task (unique code, validation rule title_not_blocked), an ow_vault (granted to nobody but the admin), or, fire-and-forget behind a latch the test releases, an ow_task later. Each hook swallows what the engine answered into a map, so the triggering write succeeds either way.

  • T1, need 1: a hook's write the validation rule refuses is recorded refused VALIDATION_FAILED, with its payload, the triggering user's context and the engine's ValidationError. No row. Control: an accepted title is recorded resolved, and its row is there.
  • T2, the observer changes nothing: the caller's error is the very object recorded (toBe), and the same call unobserved answers the same code / status.
  • T3, need 2: the fire-and-forget write is absent when hooks.run returns. settled resolves with it resolved, and the row is there.
  • T4: settled also answers a fire-and-forget write the engine refuses, which leaves no row.
  • T5, need 3: a seeded holder makes the hook's write refused DUPLICATE_RECORD, and only the holder's row exists. Control: a fresh code is written.
  • T6, the boundary: a hook's write the permission check refuses leaves no entry, and the hook caught PERMISSION_DENIED. Control: the admin's same hook write is recorded resolved.
  • T7: a seed, a predicate update and a system delete are each recorded with their own where, multi, caller and result (the update's affected-row count, 1).
  • T8: the window closes with fn: a later write is not added, and settled after the close rejects.
  • T9: settled rejects on its timeout, and its message names the writes it saw.
  • T10: the observer is global, and hasObjectMiddleware still answers false.
  • T11: the three malformed calls are refused INVALID_REQUEST / 400.

Ablations

All four ran on the committed head 60d6583edc through node scripts/ablation-replace.mjs in wrap mode. In each, the anchor went 1 → 0 and the blob changed. Each was restored to the HEAD blob 62a14c3b71ef, with git diff HEAD empty. The predictions were written down before the first run. The subject is reached by a relative src import, so no dist/ is involved.

  • A1, a refusal is not recorded (it stays pending). Predicted red: T1, T2, T4, T5. Observed: 4 failed and 7 passed, exactly those four.
  • A2, settled accepts a write the engine has not answered. Predicted red: T3, T4. Observed: 2 failed and 9 passed, exactly those two.
  • A3, the observation is never closed. Predicted red: T8. Observed: 1 failed and 10 passed.
  • A4, the observer rethrows a copy of the engine's error. Predicted red: T2. Observed: 1 failed and 10 passed.

Verification

Each result below names the commit it ran at. The final head is 6f6793c636, a merge of origin/main e84aeb36ce, which touches no path of this PR.

  • Builds:
    • turbo run build --filter='@objectstack/verify^...' passed 47/47 at 490cb6d9fa.
    • --filter='@objectstack/dogfood^...' --filter=@objectstack/verify passed 63/63 at 6f6793c636.
  • @objectstack/verify full suite: vitest run --maxWorkers=2 passed 29 files and 234 tests, at 60d6583edc and again at 6f6793c636. The new file passed 11/11 on its own.
  • pnpm --filter @objectstack/verify typecheck (tsc --noEmit and check:test-typecheck) exited 0 at 6f6793c636. tsc -p tsconfig.test.json --listFiles reaches 29/29 of the package's test files, including the new one.
  • Consumer typecheck: pnpm --filter @objectstack/dogfood typecheck exited 0 at 6f6793c636. Verify was rebuilt as a cache miss first, and its dist/index.d.ts names observeWrites 3 times.
  • Reverse check, from dogfood: deleting the fake stack's observeWrites line turned tsc red with TS2741 ("Property 'observeWrites' is missing … required in type 'VerifyStack'"). It was restored through the tool.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack with no paths derived 68 commands at 6f6793c636, and all 68 ran there.
    • 67 exited 0.
    • check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: it reads a whole-workspace build, which this dispatch does not run). It is NOT MEASURED and left to CI.
    • --ran reconciliation: 68 derived, 67 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN.
  • Lint: pnpm lint (the repo-wide scan) is CI's run and is not claimed here. A targeted eslint --no-inline-config --format json over the 5 touched .ts files linted 5 files, with 0 errors and 0 warnings. eslint.config.mjs never enables type-aware linting, so this diff cannot move the verdict on an untouched file.

Acceptance notes

  • An outcome is the engine's answer, not row state. The probe measured this: an afterInsert hook that throws (onError: 'abort', the default) rejected the triggering insert, and both that row and an earlier hook's row stayed stored. Neither hooks.run nor the REST data ingress (createData → engine.insert) opens a transaction. The door's docs say so ("read rows back with rows"). The spec half is reported to the seat on the card as a finding, not filed from here.
  • The recorded result is 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.
  • The middleware stays registered once installed. The engine has no way to take one back, and with no observation open it is a pass-through.
  • The window is time, not cause. A sign-in's key rotation (sys_jwks) was observed in the probe's window, so the pins filter by object, and the docs say to.
  • For downstream apps (the repo:hotcrm seat): recordEngineWrites maps onto observeWrites, 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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

11 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 3 changed file(s) yielded no anchor (packages/verify/README.md, packages/verify/src/harness.ts, 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.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/verify/README.md, packages/verify/src/harness.ts, packages/verify/src/index.ts) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 3 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 e84aeb36ce14169a633670f14ce8280fc998e2b9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 112a28ed41e565399b5c43c304bfae7825fc125f — the merge of head 6f6793c636102428bc6b09bb1a05a412c6334926 into base e84aeb36ce14169a633670f14ce8280fc998e2b9, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6f6793c636102428bc6b09bb1a05a412c6334926
Local-runs: none

Item 6 of the card, first round. Head resolved from origin/claude/issue-22301-item6-hook-observation: a merge of origin/main e84aeb36ce into the one implementation commit 60d6583edc; the merge base with origin/main is e84aeb36ce, the net diff is 7 files, +798 / −9, and its digest equals 60d6583edc's own diff (the merge changed no PR path). Inputs: the card (body; ruling 6070767186; landing 6105282590; claim 6105769280; report 6106246523), PR #22596's diff and its PASS record 6093488852 as the handle-door precedent, the PR (body, file list, diff), the code at this head the door rests on (engine.ts registerMiddleware / executeWithMiddleware / the three write doors / assertWriteAllowed / enforceTransactionOrigin / ObjectRepository, contracts/objectql-engine.ts, plugin-security's write middleware, hook-wrappers.ts), #22782, and the check-runs on this head. Nothing built, run or re-run. The claim's file surface is met exactly (the 7 paths are the 6 named classes), and the stop boundary (engine.ts, data-engine.ts) is untouched.

① Derived judgments

  1. Accept set, new member: VerifyHandle.observeWrites(fn) (so VerifyStack): takes a function, resolves with the ObservedWrite list in arrival order once fn settles — RIGHT, pinned T1–T11 in handle.observe-writes.test.ts.
  2. Accept set, refusals: a non-function fn (before the observer is installed), a non-function match, and a timeoutMs that is not a finite positive number answer the handle's existing callShapeRefusal (INVALID_REQUEST / 400 / statusCode), no new code — RIGHT, T11; settled after the window closed rejects with a plain Error (a state error, not a call shape; documented) — RIGHT, T8.
  3. Public surface: src/index.ts exports the types ObservedWrite, ObservedWriteOutcome, WriteObservation; the only foreign types they name are EngineRow (already exported) and ExecutionContext from @objectstack/spec/kernel, the public type contextFor already returns; OperationContext from @objectstack/objectql is used inside createHandle only and leaks into no published type — RIGHT.
  4. verify: an in-process handle on the booted stack — run a hook, flow, action or validation rule against the REAL engine and assert, so an app never fakes ctx.api again (epic hotcrm#1579, step 5a) #15951 B′, the mechanism: one global ql.registerMiddleware(fn) (no object), pushed onto ObjectQL.middlewares on the first call; executeWithMiddleware runs the applicable list in registration order, so the observer is innermost. Idle, or on find / findOne / count / aggregate, it is return next(). Open, it shallow-copies data and where, holds context by reference, await next(), sets outcome from opCtx.result or from the caught error, and throw error — the SAME object (T2 toBe), its own code read by ownCode. It never writes opCtx.result, data, options or ast, never returns without next() while observing, and a throwing match is caught in the waiter, so nothing from the caller reaches the engine's write — RIGHT: zero re-implemented semantics, nothing answered in the engine's place.
  5. Lifetime and position: kept for the stack's life (the engine has no unregister), a pass-through when idle; appended, so every boot-registered middleware's pre-phase input and post-phase ctx.result are what they were, and the only thing another middleware can see is one more async frame in the chain; hasObjectMiddleware unchanged (global, T10) — acceptable, RIGHT.
  6. What it can see: plugin-security's write middleware refuses before the chain continues (checkObjectPermission at security-plugin.ts about 2844, the PermissionDeniedError throws at 2896 / 2911, the ordinary path's await next() only at 4281), and assertWriteAllowed / enforceTransactionOrigin throw before opCtx exists on all three verbs (engine.ts 13994 / 14003, 15169 / 15177, 18148 / 18156), so those refusals leave no entry and reach the hook that wrote (T6 pins the permission case, with the admin control). The JSDoc names the gate class AND both pre-chain refusals; the README bullet and the changeset name the gate class (permission check as the example) and say the refusal still reaches whoever wrote — true, but neither names the external-datasource and cross-driver refusals. Wording gap; the type's own doc is the complete statement. Not blocking.
  7. "An outcome is the engine's answer, not row state": stated in the observeWrites JSDoc, the README bullet and the changeset, with the after*-abort example and "read rows back with rows" — RIGHT. result is opCtx.result as the executor set it, before the security middleware's post-phase (fieldMasker.maskResults at about 10135 rewrites opCtx.result for insert / update) — the ObservedWriteOutcome doc says exactly this. One unnamed edge: an outer gate's post-phase THROW (the fail-closed unhonoured postHookWriteImageCheck guard, about 4313) would leave resolved recorded while the caller is refused; this engine honours the seam, so it is not a live case. Noted for the docs, not blocking.
  8. Concurrency and scoping: told = [...openObservations] is snapshotted at arrival, so each observation holds exactly the writes that arrived while its sink was registered; a write already in flight when an observation opens is never added; entries are shared and outcome is set in place, also for an observation that closed before the answer (stated: "set in place when it does"). An unrelated concurrent caller's write IS recorded — "the window is time, not cause" in JSDoc, README and changeset, and the pins filter by object. A rejecting fn: finally closes (sink removed, waiters failed) and the rejection is rethrown unchanged — true by source (no catch), not pinned. RIGHT.
  9. settled(match): for use inside fn, which keeps the window open while it awaits, so an async: true hook's write lands inside it (T3 resolved, T4 refused, behind a test-held latch); after the close it rejects at once (T8). It cannot hang: a validated finite positive timeout is always armed and cleared on settle; a match the engine never answers times out naming every write seen (T9); first-match (writes.find) waits on the earliest accepted write even if a later one is already answered, which is what "the first observed write match accepts" says — RIGHT.
  10. context: held by reference, the write's own ExecutionContext; it can carry accessToken (resolveAuthzContext sets it from the session token, so for a { as: token } write it is the token the test itself passed) — the same object every hook already receives and contextFor(token) already returns; the list lives in the test process of a test package, so no new exposure class, and the type is the stable public one — RIGHT.
  11. Need 3 under B′: a switch that refuses in the engine's place would answer for the engine; out of scope is RIGHT. The pins stage REAL refusals: T5 seeds a holder of a unique: 'global' code and the engine's unique-violation envelope answers DUPLICATE_RECORD (only the holder's row exists; a fresh code is written); T1 / T2 / T4 use the declared title_not_blocked rule, VALIDATION_FAILED with fields: [{ field: '_record', code: 'rule_violation' }] — RIGHT.
  12. C1 / C2 unservable without a seam: C1 — the only earlier positions are the private middlewares array (a spy), a position option registerMiddleware does not have, or a plugin ahead of plugin-security in bootStack's composition, which ruling 6070767186 (A) forbids as a second composition path — RIGHT. C2 — wrapDeclarativeHook fires void runWithErrorPolicy(detached) keeping no handle; the metrics recorder is captured at bind (merged.metrics = this._hookMetricsRecorder, engine.ts about 4509), so setHookMetricsRecorder after boot reaches no bound hook, and a recorder is a counter, not a drain — RIGHT.
  13. where: what the entry holds is the engine-normalized selection (foldEngineOptionAliases, lowerWhereFilterArray, withResolvedWhere run before opCtx is built), not the caller's literal; "the selection an update or a delete was handed" is the engine's view and true (T7). Note only.
  14. data copy is shallow ({ ...row }, per row of a batch): true at the top level, a nested object a hook mutates later would show. Note only.
  15. Consumer fake: packages/qa/dogfood/test/rls-runner.test.ts gains observeWrites: undefined as never; it is the only hand-built VerifyStack in the repo, and the dev's reverse check (TS2741 without the line) is the right shape — RIGHT.
  16. Door rosters: handle.ts header, harness.ts VerifyStack docblock and the README API list all name observeWrites — RIGHT. The README's refusal bullet still says only hooks.run and hooks.updateWhere refuse a malformed call with INVALID_REQUEST; observeWrites / settled now do too (named in the changeset and the JSDoc, not there). Wording gap, not blocking.
  17. PR-body claims against source: registerMiddleware is a member of IObjectQLEngine (contracts/objectql-engine.ts:364) TRUE; no engine or spec file touched TRUE (file list); the recorded result precedes field-level masking TRUE (7); a hook's ctx.api write is ObjectRepository.insert → engine.insert(name, row, { context }) TRUE (engine.ts:20063); the body is Part of #22301 with no closing keyword TRUE.

② Semver level

  • Packages this diff publishes in: @objectstack/verify only (packages/verify/**). @objectstack/dogfood is private: true, so its one-line fake edit owes no entry; no other published package is moved.
  • .changeset/22301-verify-observe-writes-door.md: '@objectstack/verify': minor, Clause-②: yes (widening) on its own line, and the PR body's second line carries the same — RIGHT: one new member on the published handle type plus three new exported types, nothing removed, renamed or narrowed, no migration owed. A hand-built VerifyStack outside this repo would need the member, exactly as for every door this card has landed (items 2–5 took minor on that reasoning; precedent 6093488852 ②). The level is at least minor for a yes, and no major is introduced. Check Changeset on this head is green.

③ Boundary flags

  • Dev deviations (6106246523), four, each answered. (1) The commit's trailer pair Claude-Session + Co-authored-by: Claude is the form AGENTS.md prescribes and the pre-push hook enforces; not a deviation. (2) The merge commit 6f6793c636 carries git's default message and no trailer; the queue squashes, so the landing commit will not carry it, and pushed history is not rewritten; accepted. (3) The PR body's session-URL footer is AGENTS.md's form for PR bodies; right. (4) Zero label writes; within the dispatch budget.
  • Carried needs C1, C2. Each truly needs an engine seam (① 12). Disposition: the item-6 door is delivered, and the two sub-cases (a gate's refusal of a hook's write; an async: true hook that writes nothing) are stated decisions in the PR body, each with its seam and home named — within Acceptance's "a stated decision that the door is out of scope". Not filed from this record: filing gate ① wants a measured reach, and neither sub-case has one (the dev did not read hotcrm; the card body names recordEngineWrites only). Escalated to the seat: a PR body does not survive the card's close as a record, so carry C1 and C2 into the card's close-out comment, or file them when the hotcrm seat measures a pull.
  • Out-of-scope finding → objectql: an afterInsert hook with onError: 'abort' (the default) that throws rejects the write, but the row stays stored — HookSchema.onError says abort rolls the transaction back, and a plain write opens none #22782 (open, bug, pm:queue), filed by the seat. This PR's outcome documentation stays correct under either fork: the principle (the engine's answer to that call, not a statement about stored rows; read rows with rows) is decision-independent; the illustrative clause "its row can stay" is true as measured at this head and would be the docs' to tighten under fork A. Nothing owed here.
  • open_questions: none. Wording notes for a later text round, none blocking: ① 6, ① 7, ① 13, ① 16.
  • Check-runs on this head, all concluded (last completion 2026-10-11T06:40:35Z, read at 06:42Z): 35 runs, 32 success, 3 skipped, no other conclusion. The seven required contexts are green: Lint & Repo Gates, TypeScript Type Check (and its four Type Check · legs), Test Core (and 6/6 shards; sharded by package over the affected set, so @objectstack/verify's suite with the new file ran in one of them), Dogfood Regression Gate (and 3/3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also green: Check Changeset, Check PR Size, Dogfood Verify CLI, Spec property liveness, Part-of PR must not also close its card, the two single-claim guards, Flag docs affected by code changes, Check Documentation Links, Auto Label, filter. Skipped by their own filters: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). No red to root-cause.

Implemented-by: claude/issue-22301-item6-hook-observation
Reviewed-by: session_016njDy8ozy9B9Ns5Y8kAWEK

VERDICT: PASS

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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants