Repository navigation
fix(objectql)!: an after* hook carrying a condition rolls its write back when the condition cannot be evaluated - #22906
Conversation
…d against its write's rollback Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…or's unit, so an unevaluable condition rolls its row back The condition gate throws HookConditionError outside onError and before an async hook detaches, so such a hook can abort its write whatever onError says. opensAfterAbortUnit now counts it, read through the wrapper's own declaredHookCondition. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…e condition cannot be evaluated; changeset Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…g-hook-condition-unit
…ying a condition Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 17 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 cf5f72c416cef64a4a1ea4256c251fcd0ec8840b && git checkout cf5f72c416cef64a4a1ea4256c251fcd0ec8840b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eb20c33c456f4b9fdeafd0e7a5b5de274888848e 9eb65e6a449c2135a7968283e961c5157de51fa5 && git checkout -B drift-repro eb20c33c456f4b9fdeafd0e7a5b5de274888848e && git merge --no-ff 9eb65e6a449c2135a7968283e961c5157de51fa5
node scripts/docs-audit/affected-docs.mjs --json eb20c33c456f4b9fdeafd0e7a5b5de274888848e |
…s a log or async hook carrying a condition opens no unit Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…ch now says it itself Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
|
DELIBERATE CORRECTION —
|
Contract reviewServed-tier: Read-only shape held: the card #22856 (body and all five comments: triage 6110316957, claim 6114412794, os-dev-report 6115299755, file-surface addendum 6115328926, os-dev-report 6115564493), PR #22906 (body, both comments, file list, commit list), the 42 check-runs on the head, the net diff of the branch against Check-runs at the final read: 42, all completed, none pending. 36 success (Build Core, Build Docs, Test Core 1/6 to 6/6 and the aggregate, Dogfood Regression Gate 1/3 to 3/3 and the aggregate, Dogfood Verify CLI, Temporal Conformance, Lint and Repo Gates, the four Type Check lanes, Governed Surface Queue Guard, Spec property liveness, the four PR-claim guards, Check Documentation Links, Flag docs affected), 4 skipped (Console Pin Gate, Packed-tarball smoke opt-in, and Auto Label plus Check PR Size on the second ① Derived judgments
② Semver level
③ Boundary flagsThe DELIBERATE CORRECTION (PR comment 6115345828, the seat's note; addendum 6115328926 on the card). Named and judged:
Dev flags, round 0 (report 6115299755). Deviations: (1) Dev flags, round 1 (report 6115564493). Deviations: (1) Escalated to the owning seat (not FAIL grounds). (E1) The Clause-② arm against the family precedent, ② above. (E2) Three untouched restatements of the door's rule that this PR widens: What a PASS on the next head needs: F1 (one clause in the 22782 note's remedy) and F2 (one sentence in the 22856 note naming the inside-the-unit consequences for this population and the remedy), the seat's decision on E1 reflected in both the PR body line 2 and the changeset, and a new same-head record. No source, test or doc-table change is owed by this review. Implemented-by: VERDICT: FAIL Adopted by |
…s only for a log or async hook with no condition Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
… new unit refuses for a log or async hook carrying a condition Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read-only shape held. Inputs: card #22856 (body and all seven comments: triage 6110316957, claim 6114412794, os-dev-reports 6115299755, 6115564493 and 6116082195, file-surface addendum 6115328926, the seat's ruling 6115782204), PR #22906 (title, body, its three comments including the DELIBERATE CORRECTION note 6115345828 and the earlier record 6115773915, the seven-file list), the 42 check-runs on the head with the two Since the earlier head Check-runs at the final read: 42, all completed, none pending. 36 success, among them every one of the seven required contexts ( ① Derived judgments
② Semver level
③ Boundary flagsThe DELIBERATE CORRECTION (PR comment 6115345828; addendum 6115328926 and ruling 6115782204 on the card). Named and judged on this head:
Dev flags, round 2 (report 6116082195). Deviations: (1) the remedy adds "and test it in the hook's body instead": right, dropping the key alone makes the hook fire on every write, which an upgrading reader should not do by accident; accepted. (2) The lane title form ( Dev flags, round 1 (report 6115564493). Deviations: (1) Dev flags, round 0 (report 6115299755). Deviations: (1) the Escalations from record 6115773915, as the seat ruled them (6115782204). E1: reflected on this head in all four declarations (②). E2: What this record confirms for the seat: F1 and F2 are fixed on this head; the DELIBERATE CORRECTION's two clauses and the rest of the 22782 note are true; each Implemented-by: VERDICT: PASS Adopted by |
Fixes #22856
Clause-②: no (narrowing)
What the caller sees
A hook
conditionthat cannot be evaluated aborts the operation whatever the hook'sonErrorsays. The gate throwsHookConditionErroroutsideonError, and before anasync: truehook detaches. Until now the write door opened its unit of work only for a blockingonError: 'abort'hook. So anafter*hook withonError: 'log'orasync: truewhose condition could not be evaluated refused the caller while the row stayed stored. The card measured this on RESTcreateData:500, and the row was stored.Now the refused write leaves no row behind. The row and the hook chain's engine writes roll back together. The refusal itself is unchanged: the same
500.This is declared a narrowing (
Clause-②: no (narrowing),minor, BREAKING). Inside the new unit the rules of any unit apply. On an object carrying such a hook, a blockingonError: 'log'hook's write to an object on another datasource is refused withCrossDatasourceTransactionWriteErrorand logged where it used to land. So is such a write made by the part of anasync: truebody that runs before the unit's callback settles. A MongoDB default store without a replica set refuses writes to those objects. The changeset gives the fix: drop thecondition, keep the objects on one datasource, or run MongoDB as a replica set.What changed
packages/objectql/src/engine.ts,opensAfterAbortUnit. The predicate also counts a metadata-boundafter*hook that carries acondition, whatever itsonErrorandasync. This is triage 6110316957's direction. The first line (the closed-handle reading that PR fix(objectql): work a ctx.api.transaction unit starts and does not await no longer keeps its finished handle #22894 landed) stays as it is, and so do theskipAutomationsexit and the off-default-driver and no-transactions exits. TheDISPATCHABLE_HOOK_EVENTSdocblock now lists the new case.packages/objectql/src/hook-wrappers.ts,declaredHookCondition(meta). This is the one reading of "this hook has a condition gate". BothwrapDeclarativeHook(which installs the gate) and the predicate use it, so the door cannot count a gate that never exists or miss one that does. It is not exported from the package index, so the public surface does not change. The compile and evaluate block inwrapDeclarativeHookis the same code, one nesting level shallower.content/docs/automation/hooks.mdx. The "After hooks run inside the unit of work" table row now names the conditional hook too. Without this, the row would be wrong after this change. The claimed file surface did not include this file; the report says so.minorfor@objectstack/objectql, declared BREAKING (Clause-②: no (narrowing), ADR-0087 not-required, no migration). It names what the new unit refuses for alogorasynchook carrying acondition, and the fix. Plus two one-clause corrections of the pending objectql: anafterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782 changeset (see "Deliberate correction").Mechanism readings (dispatch section 2)
hook-binder.tspasses the same metadataHookobject towrapDeclarativeHookand, asmeta, toregisterHook. Soentry.meta.conditionis exactly what the gate reads, and no new field on the entry is needed. Hooks registered in code have nometaand declare no condition. The builtin audit stamps thatplugin.tswraps arebefore*only and carry no condition.async: true. The condition gate is step 1 of the wrapper and runs synchronously, before step 2 (fire-and-forget). So anasync: truehook whose condition cannot be evaluated refuses the write. Before the fix, the async unit pin was red with the row stored. So async hooks are in the predicate.63739521d8matched the dispatch's reading.Pins
Unit tests in
packages/objectql/src/engine-after-hook-abort-unit.test.ts, in a new describe block beside the #22782 one:onError: 'log'with a condition that cannot be evaluated (previouson an insert): refused withHookConditionError(reason: 'unevaluable', the hook and event named). The row and the chain'sctx.apinote are absent, and one unit was begun and rolled back.onError: 'log'onafterUpdateandafterDelete(a key the object does not declare): the stored row is what it was before each refused write.async: truewith a condition that cannot be evaluated: refused, the row absent, the handler never ran.logwith a condition that evaluates: the handler throws,[hook] handler failed (onError=log; suppressing)is logged, both rows are stored and nothing is rolled back. Afalsecondition skips the hook.aborthook with the same unbindable condition: refused, the row absent, as objectql: anafterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782 leaves it.skipAutomations: no unit, the row stored.The first draft of the first pin kept
noteWriter, anaborthook, which opens the unit by itself. So that pin passed before the fix. The pre-fix run caught this, and the chain writer in this block is nowonError: 'log'.Real door:
packages/qa/dogfood/test/hook-after-condition-rollback.dogfood.test.ts. This is a test-only file, becauseobjectqlhas no real driver in its dev dependencies. It drives RESTPOST /data/:objecton the verify stack and reads back through the engine:logwith a condition that cannot be evaluated:500 INTERNAL_ERROR, the row absent.async: truewith a condition that cannot be evaluated:500, the row absent.logwith a condition that evaluates and a handler that throws:201, the row stored.abortwith the same unbindable condition:500, the row absent.async: truewith a condition that evaluates: the latectx.apiwrite after 40 ms still lands. A conditional async hook now starts inside the door's unit, so this measures the closed-handle reading from PR fix(objectql): work a ctx.api.transaction unit starts and does not await no longer keeps its finished handle #22894 on the new population.Ablation (committed fix, then the guard reverted)
The fix was committed before the ablation.
scripts/ablation-replace.mjsreverted the predicate to the pre-fix expression. The tool reported anchor 1 to 0, blob669d4e7c0dd0to8f11826b4054. Then@objectstack/objectqlwas rebuilt, because dogfood readsdist/(KNOWN_UNALIASED_TEST_IMPORTS).ablation-dist-preflight.mjs(present mode) found the markerdeclaredHookCondition(entry.meta)in 4 built files, with the tree clean.--absentwith--source-markerpassed, with the marker absent from all 14 built files.loginsert,logupdate and delete,async), with the row stored or deleted. Every control stayed green.logandasyncpins,expected 1 to be +0(row stored). All three controls stayed green.git checkout HEAD, blob back to669d4e7c0dd0(equal to HEAD), andgit diff HEADempty. After the rebuild, present-mode preflight found the marker in 4 built files with the tree clean, and dogfood passed 5 of 5.Verification
pnpm --filter @objectstack/objectql test: 401 files and 7823 tests passed, ona20ce84865(this diff without the merge and the doc row).pnpm --filter @objectstack/objectql typecheck(tsc andcheck:test-typecheck): exit 0.hook-after-abort-rollback.dogfood.test.tspassed 11 of 11. A narrowedtsc --noEmitover those two files alone, using the package's tsconfig, exited 0 with both files listed (--listFiles). The package's fulltscreadsdist/types of packages not built in this worktree, so it was not run.dispatch-gates --commands --repo objectstack-ai/objectstackderived 97 commands on this diff. All 97 were run on head9eb65e6a44. 96 exited 0.check-empty-changesetexited 1 by design: it refuses exactly the deliberately corrected.changeset/22782-objectql-after-hook-abort-rolls-back.md(see "Deliberate correction").--ranreconciliation: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN.check-adr-0087-registrationreads the 22856 note as[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription), andcheck-changeset-no-majorreports nomajorbump. Earlier, the same 97 ran on9ba561108f(all exit 0) and on48b64091df(96 exit 0,check-empty-changesetexit 1 by design). An earlier run of the 72 dispatch-time commands ona20ce84865hadcheck:dual-build-cjs-loadsatPREREQUISITE NOT MET(exit 3, packages not built). On the later heads it measured: 107 entry points in 66 packages load.eslint --no-inline-config --format jsonover the 4 changed TS files gave 4 files, 0 errors and 0 warnings. 0 warnings also means none of the 4 is ignored by the config.eslint.config.mjsenables no type-aware linting (noparserOptions.project, noprojectService), so this diff cannot change the verdict on any untouched file. The repo-widepnpm lintis CI's.a20ce84865and the final head. Since then the branch gained only theorigin/mainmerge (3f694715ee, a spec migration text and its changeset), the docs row, and four changeset commits from the patch rounds (4a3e842580,48b64091df,b2eefbd375,9eb65e6a44).Reach and cost
One transaction per write on an object that carries such a hook, the same cost as #22782's unit. Measured over
examples/and non-testpackages/**/src, the real configurations in this population are two showcaseafterUpdatehooks:showcase_audit_task_completion(showcase_task) andshowcase_warn_over_budget(showcase_project). Both areasync: true, onError: 'log'with a condition, and both only log. Updates to those two objects now run in a unit. The over-budget hook's own comment notes thatnull > nullwould abort its expression, which is this card's case.Deliberate correction
This PR changes
.changeset/22782-objectql-after-hook-abort-rolls-back.md, a pending release note it did not add, in two commits. Each touches only that file and one line of it. After this PR the write door opens its unit for anonError: 'log'orasync: truehook that carries acondition, because a condition that cannot be evaluated aborts the write. Two clauses of that note said otherwise:4a3e842580, the "Unchanged" bullet. It said that no unit is opened for anonError: 'log'hook or anasync: truehook. It now reads "No unit is opened for anonError: 'log'orasync: truehook that carries nocondition(objectql: anafter*hook withonError: 'log'whoseconditioncannot be evaluated refuses the write (HookConditionError) while the row stays stored #22856), …".b2eefbd375, the remedy at the end of the "Inside the new unit, the rules of any unit apply" bullet. It said "mark the hookonError: 'log'orasync: true, or keep the objects on one datasource". It now reads "mark the hookonError: 'log'orasync: trueand give it nocondition(objectql: anafter*hook withonError: 'log'whoseconditioncannot be evaluated refuses the write (HookConditionError) while the row stays stored #22856), or keep the objects on one datasource".Every other byte of the file is unchanged. Restoring the file from the base would ship two sentences that are false after this PR.
check-empty-changesetrefuses this change by design (its DELIBERATE CORRECTION class), and that red is expected until the correction is confirmed. In this PR's own changeset, commit48b64091dfdrops the sentence that pointed at the old 22782 line, and9eb65e6a44declares the change a narrowing and names what the new unit refuses.Acceptance notes
examples/, non-testpackages/**/srcandskills/carry a condition that can never bind on one of their events. The showcase transition conditions readpreviouson update events only. So nothing to file.afterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782 changeset said no unit opens for anonError: 'log'hook or anasync: truehook, and gave "mark the hookonError: 'log'orasync: true" as the remedy for the rules inside the unit. After this PR both hold only for such a hook with nocondition, so this PR corrects both clauses (see "Deliberate correction" above).500(rather than a4xxnaming the hook and key) is unchanged. Triage put it in the class of [finding] rest: the 20 write routes #22719 repaired still answer a crashed hook body with its native fault text, and a declared-5xx refusal unwithheld, where the data doors answerInternal server error#22764.ast-only condition envelope gets no gate (declaredHookConditionreturns nothing). This is pre-existing behaviour, kept byte-identical.HookSchemarefuses that envelope at authoring. This is a read-only observation, not measured on a public door. Carrier: none.Generated by Claude Code