Skip to content

fix(objectql)!: an after* hook carrying a condition rolls its write back when the condition cannot be evaluated - #22906

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22856-log-hook-condition-unit
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22856-log-hook-condition-unit

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22856
Clause-②: no (narrowing)

What the caller sees

A hook condition that cannot be evaluated aborts the operation whatever the hook's onError says. The gate throws HookConditionError outside onError, and before an async: true hook detaches. Until now the write door opened its unit of work only for a blocking onError: 'abort' hook. So an after* hook with onError: 'log' or async: true whose condition could not be evaluated refused the caller while the row stayed stored. The card measured this on REST createData: 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 blocking onError: 'log' hook's write to an object on another datasource is refused with CrossDatasourceTransactionWriteError and logged where it used to land. So is such a write made by the part of an async: true body 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 the condition, 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-bound after* hook that carries a condition, whatever its onError and async. 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 the skipAutomations exit and the off-default-driver and no-transactions exits. The DISPATCHABLE_HOOK_EVENTS docblock 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". Both wrapDeclarativeHook (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 in wrapDeclarativeHook is 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.
  • A changeset, minor for @objectstack/objectql, declared BREAKING (Clause-②: no (narrowing), ADR-0087 not-required, no migration). It names what the new unit refuses for a log or async hook carrying a condition, and the fix. Plus two one-clause corrections of the pending 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 changeset (see "Deliberate correction").

Mechanism readings (dispatch section 2)

  • Where the condition is visible. hook-binder.ts passes the same metadata Hook object to wrapDeclarativeHook and, as meta, to registerHook. So entry.meta.condition is exactly what the gate reads, and no new field on the entry is needed. Hooks registered in code have no meta and declare no condition. The builtin audit stamps that plugin.ts wraps are before* 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 an async: true hook 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.
  • Predicate text at 63739521d8 matched 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:

The first draft of the first pin kept noteWriter, an abort hook, 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 now onError: 'log'.

Real door: packages/qa/dogfood/test/hook-after-condition-rollback.dogfood.test.ts. This is a test-only file, because objectql has no real driver in its dev dependencies. It drives REST POST /data/:object on the verify stack and reads back through the engine:

  • log with a condition that cannot be evaluated: 500 INTERNAL_ERROR, the row absent.
  • async: true with a condition that cannot be evaluated: 500, the row absent.
  • CONTROL, log with a condition that evaluates and a handler that throws: 201, the row stored.
  • CONTROL, abort with the same unbindable condition: 500, the row absent.
  • CONTROL, async: true with a condition that evaluates: the late ctx.api write 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.mjs reverted the predicate to the pre-fix expression. The tool reported anchor 1 to 0, blob 669d4e7c0dd0 to 8f11826b4054. Then @objectstack/objectql was rebuilt, because dogfood reads dist/ (KNOWN_UNALIASED_TEST_IMPORTS).

  • Before mutating, ablation-dist-preflight.mjs (present mode) found the marker declaredHookCondition(entry.meta) in 4 built files, with the tree clean.
  • Mutate leg: preflight --absent with --source-marker passed, with the marker absent from all 14 built files.
    • Unit tests: 3 failed and 11 passed. The red ones are the three new pins (log insert, log update and delete, async), with the row stored or deleted. Every control stayed green.
    • Dogfood: 2 failed and 3 passed. The red ones are the log and async pins, expected 1 to be +0 (row stored). All three controls stayed green.
  • Restore: git checkout HEAD, blob back to 669d4e7c0dd0 (equal to HEAD), and git diff HEAD empty. 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, on a20ce84865 (this diff without the merge and the doc row).
  • pnpm --filter @objectstack/objectql typecheck (tsc and check:test-typecheck): exit 0.
  • Dogfood: the new file and its sibling hook-after-abort-rollback.dogfood.test.ts passed 11 of 11. A narrowed tsc --noEmit over those two files alone, using the package's tsconfig, exited 0 with both files listed (--listFiles). The package's full tsc reads dist/ types of packages not built in this worktree, so it was not run.
  • Gates: dispatch-gates --commands --repo objectstack-ai/objectstack derived 97 commands on this diff. All 97 were run on head 9eb65e6a44. 96 exited 0. check-empty-changeset exited 1 by design: it refuses exactly the deliberately corrected .changeset/22782-objectql-after-hook-abort-rolls-back.md (see "Deliberate correction"). --ran reconciliation: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN. check-adr-0087-registration reads the 22856 note as [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription), and check-changeset-no-major reports no major bump. Earlier, the same 97 ran on 9ba561108f (all exit 0) and on 48b64091df (96 exit 0, check-empty-changeset exit 1 by design). An earlier run of the 72 dispatch-time commands on a20ce84865 had check:dual-build-cjs-loads at PREREQUISITE NOT MET (exit 3, packages not built). On the later heads it measured: 107 entry points in 66 packages load.
  • Lint, narrowed: eslint --no-inline-config --format json over 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.mjs enables no type-aware linting (no parserOptions.project, no projectService), so this diff cannot change the verdict on any untouched file. The repo-wide pnpm lint is CI's.
  • objectql source is identical between a20ce84865 and the final head. Since then the branch gained only the origin/main merge (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-test packages/**/src, the real configurations in this population are two showcase afterUpdate hooks: showcase_audit_task_completion (showcase_task) and showcase_warn_over_budget (showcase_project). Both are async: 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 that null > null would 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 an onError: 'log' or async: true hook that carries a condition, because a condition that cannot be evaluated aborts the write. Two clauses of that note said otherwise:

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-changeset refuses this change by design (its DELIBERATE CORRECTION class), and that red is expected until the correction is confirmed. In this PR's own changeset, commit 48b64091df drops the sentence that pointed at the old 22782 line, and 9eb65e6a44 declares the change a narrowing and names what the new unit refuses.

Acceptance notes


Generated by Claude Code

…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>
…ying a condition

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 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
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 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 — 17 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 eb20c33c456f4b9fdeafd0e7a5b5de274888848e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cf5f72c416cef64a4a1ea4256c251fcd0ec8840b — the merge of head 9eb65e6a449c2135a7968283e961c5157de51fa5 into base eb20c33c456f4b9fdeafd0e7a5b5de274888848e, 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 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

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

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

DELIBERATE CORRECTION — Check Changeset is red by design on this PR. domain:engine seat 2 · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-11T23:57Z.

  • The gate: check-empty-changeset.mjs's foreign-changeset refusal in the Check Changeset job of pr-automation.yml. Its one error line names .changeset/22782-objectql-after-hook-abort-rolls-back.md; the job's other steps pass.
  • Why it is red: commit 4a3e842580 rewrites one clause of that pending note's "Unchanged" bullet, a note this PR did not add. It said "No unit is opened for an onError: 'log' hook, an async: true hook, …". This PR makes the door open a unit for such a hook when it carries a condition, so the clause now reads "an onError: 'log' or async: true hook that carries no condition (objectql: an after* hook with onError: 'log' whose condition cannot be evaluated refuses the write (HookConditionError) while the row stays stored #22856)". Restoring it would ship a false sentence in the next CHANGELOG. Commit 48b64091df drops this PR's own note's pointer to that line, since the line now says it itself.
  • Why it may land red: the gate's own source names this class red by design; Check Changeset runs on pull_request only, not on merge_group, and is not a required context. A same-head contract-tier record confirms the corrected sentence before this seat arms the queue.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 48b64091df805f10264f295c69f21b01eda61e4c
Local-runs: none

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 origin/main (merge-base 3f694715ee, 7 files, +269/-59, identical to the PR's file list), and the origin/main readings the brief allows (packages/objectql/package.json, src/index.ts, src/hook-wrappers.ts, src/engine.ts around opensAfterAbortUnit and its three call sites, packages/spec/src/data/hook.zod.ts, content/docs/automation/hooks.mdx). Two reads beyond that list, named here because they decided a finding: the gate's own statement of what the Clause-② line asks (scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs), and, following PR #22819's round-4 remediation which the family card cites, the two sibling pages that restate the door's rule (content/docs/api/data-flow.mdx, content/docs/kernel/events.mdx). Nothing was built, run or re-run; the git fetch of the branch into a review ref is a read of objects.

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 pull_request run), 2 failure: Check Changeset, twice (the first run and the re-run after the body edit). Each red carries exactly one failing annotation, naming .changeset/22782-objectql-after-hook-abort-rolls-back.md as present on the merge base and changed by this PR, with the gate's two-class text (COLLISION / DELIBERATE CORRECTION). No other check is red. The PR is a same-repo branch (not a fork), draft, auto-merge not armed.

① Derived judgments

  • Which writes now open the door's unit — RIGHT. opensAfterAbortUnit (engine.ts) keeps its first line (an open threaded or ambient unit: no unit of its own; 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 kept), its skipAutomations exit, and its tail (default driver only, and only one that declares transactions). The only changed expression is the aborting test: a metadata-bound entry (entry.meta, so a code-registered registerHook entry still never counts) matching the object, that EITHER is blocking with onError abort/omitted (the 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 arm, unchanged) OR has declaredHookCondition(entry.meta) defined. So an after* hook carrying a condition opens the unit whatever its onError and async, on afterInsert, afterUpdate and afterDelete only (the three call sites are untouched; a before* hook never reaches the predicate, and its abort refuses before anything is stored).
  • Which still do not — RIGHT, each one. No condition on a log or async hook (the second disjunct is undefined, the first fails on log or on async); skipAutomations (exit unchanged; pinned by the unit CONTROL, zero begins); off the default datasource and a driver with no transactions (tail unchanged); a write already inside a unit (first line unchanged); a hook registered in code (no meta). An abort plus async: true hook with a condition is newly counted, correctly: the gate can abort it before it detaches.
  • "Carries a condition" has one reading — RIGHT, by construction. declaredHookCondition(meta) returns the Expression envelope (string shorthand lifted to cel) only when its source is non-blank, else undefined. wrapDeclarativeHook now installs its conditionFn on exactly if (expr) over that value, and the compile/evaluate block inside is the pre-change code one nesting level shallower (same compile, same unevaluableConditionError and uncompilableConditionError throws, same compile-failure log line). The door tests the same function for !== undefined. So the ast-only envelope the dev notes (no source) installs no gate and opens no unit, as before, and the door cannot disagree with the gate on any shape. The wrapper's step order is unchanged: the gate runs first, synchronously, outside runWithErrorPolicy and before the fireAndForget branch, which is why a log or async hook's unevaluable condition aborts the write — the premise the whole change rests on.
  • Not on the published surface — RIGHT. declaredHookCondition is a module export of hook-wrappers.ts; neither src/index.ts (the . entry: wrapDeclarativeHook, HookConditionError, hookRecordState, the WrapDeclarativeOptions type) nor src/core.ts (the ./core entry) re-exports it and neither file is in the diff; package.json exports maps only . and ./core, so no deep import reaches it. Per the contract-review reference, a symbol unreachable from the entry type graph and not addressable through exports is shipped bytes, not published accept set. engine.ts gains a value import from hook-wrappers.ts; hook-wrappers.ts imports only a type from engine.ts, so no runtime cycle (Build Core and the dual-build gate are green).
  • A condition that EVALUATES behaves as before — RIGHT as pinned, with one gap carried to ②. false still skips (gate unchanged; pinned by the second insert in the log CONTROL). true with onError: 'log' and a throwing handler: [hook] handler failed (onError=log; suppressing) logged, both rows stored, zero rollbacks (unit CONTROL) and REST 201 with the row stored (dogfood CONTROL). An async: true hook with a true condition now starts inside the door's unit and its 40 ms late ctx.api write still lands (dogfood CONTROL, exercising the objectql: ScopedContext.transaction() publishes an ambient transaction entry it never closes, so an async hook's later write inside it is lost ("Transaction query already complete") #22855 closed-handle path on the new population). Test Core and the three Dogfood lanes are green on this head. The gap: "as before" is not exact for a hook body whose ctx.api write runs inside the new unit, see ②.
  • Docs table row (hooks.mdx, the unit-of-work table) — RIGHT. The first row now names "a blocking one whose onError is abort (the default), or one carrying a condition, whatever its onError and async", with the second cell's two exclusions kept. It matches the predicate. Kept imprecision, not introduced: the row says "an after* hook" where the predicate requires a metadata-bound one, as the previous wording did. Sibling restatements left as they were: ③.
  • Cost line — RIGHT. The unit is opened per write on every object carrying such a hook, not only on writes whose condition fails (the door cannot know in advance), so "one transaction per write on objects that carry such a hook" is the true cost, the same as 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's. The in-repo reach (the two showcase async: true, onError: 'log' afterUpdate hooks on showcase_task and showcase_project) is the dev's measurement, reported here as such and not re-measured.
  • Pins — adequate. Unit: log insert (refused with HookConditionError, reason: 'unevaluable', hook and event named; row and the log chain-writer's note absent; one begin, one rollback), log on update and delete (stored row unchanged, two begins, two rollbacks), async (refused, row absent, handler never ran), plus the three CONTROLs. The dev's first-draft confound (an abort chain writer that opened the unit by itself) was caught and replaced, which is the right fix. Real door: five REST createData cases on the verify stack, including the async late-write CONTROL. Ablation evidence is the dev's; CI's green on the head is what this record relies on.

② Semver level

  • Clause-②: no — RIGHT as the gate defines the axis. PR body line 2 reads Clause-②: no; the changeset .changeset/22856-log-hook-unevaluable-condition.md declares Clause-②: no; the card's triage grade (6110316957) and claim (6114412794) both say no. The gate's own text asks whether the PR puts a new key on a published payload, widens an accept set or enlarges the public surface. This diff adds no authorable key, no payload key, no exports-reachable symbol, and refuses nothing an author could write before. patch for @objectstack/objectql is the level the repo assigns a bug fix in a released package. check-changeset-no-major and check-adr-0087-registration are inside the green Lint and Repo Gates.
  • Escalated, not a FAIL ground: the family precedent read the same mechanism the other way. PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819 (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, merged two days before this head) declared Clause-②: yes (narrowing) and shipped minor with an ADR-0087 marker, explicitly for what happens INSIDE the door's unit: a hook body's cross-datasource ctx.api business write is refused with CrossDatasourceTransactionWriteError under the [spec] engine.transaction 契约收紧:opts.require fail-closed、跨驱动拒绝、owned-vs-joined 信号(#4619 的契约半边,维护者已批 P2) #5696 rule, and a MongoDB default store needs a replica set for the unit; its seat ruled (Q1, Q2) that the changeset must name that population and the remedy. This PR extends the identical unit to a new population (every write on an object carrying a log or async hook with a condition) and inherits those consequences unchanged, under no/patch. The owning seat decides on the next head whether this stays no/patch (defensible under the gate's definition, and this record's reading) or matches the family's yes (narrowing)/minor with the marker; the record does not refuse the PR on the arm alone.
  • The changeset TEXT, however, does not match what the diff publishes — FAIL ground F2. The 22856 note's "Unchanged" paragraph opens "a condition that evaluates behaves as before". On this head a condition that evaluates TRUE now runs its hook inside the door's unit, so, by the family note's own statement about this unit: a blocking onError: 'log' hook whose body writes a business row to an object on another datasource has that write refused and logged where it used to land (the caller still gets its row); the same holds for the part of an async: true body that runs before the unit's callback settles; and a MongoDB default store without a replica set now refuses writes to objects carrying such a hook, where it accepted them. The note names neither population nor a remedy (drop the condition, or async: true for a late cross-datasource write, or one datasource / a replica set). That is the sentence 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's seat ruled a changeset in this family must carry. One sentence fixes it. Everything else in the note is true on this head (the FROM/TO paragraphs, false skips, true plus log logs and stores, async late writes land, inside-a-unit joins, the three non-opening exits, log/async with no condition opens no unit, the cost line).

③ Boundary flags

The DELIBERATE CORRECTION (PR comment 6115345828, the seat's note; addendum 6115328926 on the card). Named and judged:

  • Commit 4a3e842580 touches only .changeset/22782-objectql-after-hook-abort-rolls-back.md, one line, blob f02b523a08 to 2364fe4a54. The blob at the merge-base equals the blob on current origin/main (eb20c33c45, after main moved past 9963322fe8), so no third party's edit to that note is overwritten. Word-diff: two token runs only ("hook, an" to "or"; "hook," to "hook that carries no condition (objectql: an after* hook with onError: 'log' whose condition cannot be evaluated refuses the write (HookConditionError) while the row stays stored #22856),"). Front matter (minor), title, Clause-②: yes (narrowing), the ADR-0087 marker, the BREAKING paragraph, FROM, TO, the three later bullets and the cost bullet are byte-identical. Nothing else in the file changed: confirmed.
  • The rewritten sentence, clause by clause, on this head: (a) "No unit is opened for an onError: 'log' or async: true hook that carries no condition" — TRUE (second disjunct undefined, first fails on log or async), and its converse, that such a hook WITH a condition opens one, is this PR; (b) "a hook registered in code with registerHook (no declared onError)" — TRUE, the entry.meta guard is kept; (c) "a write with skipAutomations" — TRUE, exit kept; (d) "an object whose datasource is not the default one" — TRUE, tail kept; (e) "a default driver with no transactions (ADR-0119 D1, ...)" — TRUE, tail kept; (f) "On those the row stays stored after an abort, as before" — TRUE for every population the sentence now lists. The rewritten clause is true.
  • But the file is not yet wholly true on this head — FAIL ground F1. Two bullets below, the "Inside the new unit, the rules of any unit apply" bullet still ends "If either applies to you, mark the hook onError: 'log' or async: true, or keep the objects on one datasource." After this PR, marking a hook that carries a condition as log or async no longer takes its object out of the unit; the cross-datasource refusal and the replica-set requirement stay. The correction's own reason ("restoring it would ship a false sentence") applies to that remedy word for word, and it is the prescription an upgrading reader greps. One clause fixes it (the remedy holds for a hook with no condition; otherwise drop the condition, or one datasource). Until then this record cannot confirm that the deliberately changed note is true, which is what the seat said a same-head record must do before arming.
  • Commit 48b64091df removes the last sentence of the 22856 note's "Unchanged" paragraph (the pointer at the 22782 line's old wording). After 4a3e842580 that sentence described text that no longer exists, so removing it leaves the note no less true: every remaining sentence holds except the "behaves as before" opener (F2), which predates round 1.
  • The seat's note also says the gate's class is red by design, runs on pull_request only and is not a required context; this record relied on the check-run conclusions alone and did not read the workflow, so that part is the seat's statement, not verified here.

Dev flags, round 0 (report 6115299755). Deviations: (1) hooks.mdx row beyond the claim's surface — accepted, declared cross-lane in the addendum; the row is right (①). (2) Ablation restore preflight in present mode — a process note on a DELETE ablation; not re-run; CI green on the head is the evidence used. (3) origin/main merged with a merge commit — the net diff against the merge-base is the seven files above; fine. (4) First union stopped and re-run on 9ba561108f — superseded by round 1's union and by CI on this head. (5) A stray whole-repo eslint, output discarded — no evidence cited from it; Lint and Repo Gates green. (6) Narrowed dogfood tsc — the four Type Check lanes are green on the head. (7) Attribution: commits carry Claude-Session plus a model-free Co-authored-by, the PR footer carries the session link — matches the commit list read here; AGENTS.md's rule wins, accepted. Out-of-scope findings: (1) load-time refusal of a never-bindable condition — population measured 0, not filed per triage, accepted. (2) The 22782 note's "Unchanged" clause — became the DELIBERATE CORRECTION; judged above, with F1 on the same file. (3) ast-only envelope installs no gate — pre-existing, and the shared reading makes the door agree with the gate on it; accepted. Open questions: none.

Dev flags, round 1 (report 6115564493). Deviations: (1) origin/main moved and was not merged — CI tested the merge of this head into the then main; the 22782 blob is identical on current main; accepted. (2) Rebuild from the turbo cache before the union — accepted. (3) Push-count correction — noted. Out-of-scope findings: none. Open questions: none. The report's pr_body_sentences_this_round_makes_false replacements are all present in the PR body as read; one nit for the seat's next body edit: the "Acceptance notes" bullet says "See Deliberate correction below" while that section sits above it.

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: packages/spec/src/data/hook.zod.ts, the HookEvent JSDoc's "Four ordinary ways a write ends up inside such a unit", first bullet (blocking abort only), and the onError TSDoc's "the unit the write door opens for a blocking abort after* hook"; content/docs/api/data-flow.mdx, the afterInsert/Update/Delete row; content/docs/kernel/events.mdx, the onError: 'abort' bullet. The two doc lines stay true about their subject (what abort does) and link to the corrected hooks.mdx section; the spec JSDoc enumeration is under-inclusive the same way the hooks.mdx row was before this PR fixed it. Touching packages/spec enlarges the surface (PR #22819 did it with a spec patch changeset and a regenerated reference page), so this is the seat's call: fold in on the next head, or file it. The generated content/docs/references/data/hook.mdx row and the onError describe() string are unqualified ("for an after* hook on an object on the default datasource") and stay true. (E3) The sanitised 500 and triage's third option stay out, as triage ruled.

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: claude/issue-22856-log-hook-condition-unit
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

VERDICT: FAIL

Adopted by domain:engine#2 (session_01ADzJtzYTLUfgrRZHxkagkX) at 2026-10-12T00:47Z from an isolated at-tier reviewer whose transcript shows read-only tool use (its one write is its own scratch record). FAIL is adopted: F1 and F2 are confirmed by this seat on the head. The PR stays draft and un-armed, and patch round 2 is under way. The seat rules on the escalations, recorded on the card: E1 Clause-②: no (narrowing), minor, BREAKING with the ADR-0087 marker, the family rule a narrowing is declared by in this lane (the arm, not the letter, follows PR #22819); E2 not folded into this PR (packages/spec JSDoc is outside the pre-approved cross-lane classes) and handed to domain:spec by pointer; the "below" nit fixed in the PR body. A new same-head record is owed on the next head.

…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>
@objectstack-fleet objectstack-fleet Bot changed the title fix(objectql): an after* hook carrying a condition rolls its write back when the condition cannot be evaluated fix(objectql)!: an after* hook carrying a condition rolls its write back when the condition cannot be evaluated Oct 12, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9eb65e6a449c2135a7968283e961c5157de51fa5
Local-runs: none

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 Check Changeset jobs' step lists and annotations, and the net diff of the branch against origin/main (fetched into a review ref of this session's own; merge-base 3f694715ee; 7 files, +278/-60, identical to the PR's file list). origin/main readings the brief allows: packages/objectql/package.json, src/index.ts, src/core.ts, src/hook-wrappers.ts, src/engine.ts around opensAfterAbortUnit, its three call sites, HookEntry and enforceTransactionOrigin, packages/spec/src/data/hook.zod.ts, content/docs/automation/hooks.mdx, and the three changeset gates' own text (check-empty-changeset.mjs, check-adr-0087-registration.mjs, check-changeset-no-major.mjs with the Clause-② reader it imports, scripts/pm/clause2-line.mjs). Two reads beyond that list, named because they decided a sentence below: AGENTS.md's required-context list (Multi-agent discipline §7), and one grep of the hand-written content/docs tree at the head for onError: 'log' / async: true restatements. Nothing was built, run or re-run; the record template was printed as the brief prescribes.

Since the earlier head 48b64091df: git diff 48b64091df..head --stat names exactly two files, .changeset/22782-objectql-after-hook-abort-rolls-back.md (+1/-1) and .changeset/22856-log-hook-unevaluable-condition.md (+15/-7). The source, test and docs-table hunks are byte-identical to the ones record 6115773915 judged; they are re-judged below all the same. origin/main (now 7dbb89dc5d) has not moved on any of the seven files since the merge-base, and the 22782 note's blob on current main equals the merge-base blob, so no third party's edit is overwritten.

Check-runs at the final read: 42, all completed, none pending. 36 success, among them every one of the seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core with its six shards, Dogfood Regression Gate with its three, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard), plus Build Docs, Dogfood Verify CLI, the four Type Check lanes, Spec property liveness, Check Documentation Links, Flag docs affected, and the four PR-claim guards on both pull_request runs. 4 skipped (Console Pin Gate, Packed-tarball smoke opt-in, and Auto Label plus Check PR Size on the second run). 2 failure: Check Changeset, twice, judged in ③. The PR is a same-repo branch, draft, auto-merge not armed, no governed-surface path in its file list, 338 changed lines.

① Derived judgments

  • Which writes now open the door's unit — RIGHT. opensAfterAbortUnit keeps its first line (an open ambient or threaded unit: no unit of its own; 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 kept), its skipAutomations exit, and its tail (the object's driver is the default one and declares transactions). The only changed expression is the aborting test: a metadata-bound entry (entry.meta, documented on HookEntry as the original metadata Hook the binder registered; a code-registered registerHook entry has none and never counts) that matches the object and EITHER is blocking with onError abort or omitted (the 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 arm, unchanged) OR has declaredHookCondition(entry.meta) defined. Moving hookMatchesObject ahead of the policy test is a commutation, not a change. So on afterInsert, afterUpdate and afterDelete, the three call sites being untouched, an after* hook carrying a condition opens the unit whatever its onError and async; an abort plus async: true hook with a condition is newly counted too, correctly, because the gate can abort it before it detaches.
  • Which still do not — RIGHT, each one. A log or async hook with no condition (second disjunct undefined, first fails on log or on async); skipAutomations (pinned: zero begins); off the default datasource or a driver with no transactions (tail unchanged); a write already inside a unit (first line unchanged); a hook registered in code (no meta); a before* hook (never reaches the predicate; its abort refuses before anything is stored). An ast-only condition envelope installs no gate and opens no unit, as before.
  • One shared reading — RIGHT, by construction. declaredHookCondition(meta) returns the Expression envelope (string shorthand lifted to cel) only when its source is non-blank, else undefined. wrapDeclarativeHook now installs conditionFn on exactly if (expr) over that value; the compile/evaluate block inside is the pre-change code one nesting level shallower (same compile, same unevaluableConditionError / uncompilableConditionError throws, same compile-failure log line). The door tests the same function for !== undefined, so it cannot count a gate the wrapper never installs or miss one it does. The wrapper's step order is unchanged: the gate runs first, synchronously, in the returned handler before installFlatInput, outside runWithErrorPolicy and before the fireAndForget branch, which is why a log or async hook's unevaluable condition aborts the write at all. One observation, not a finding: HookEntry.meta is typed any, so the type system does not prove the door reads the same object the wrapper got; the pins through the real binder do (the log insert pin is red without the predicate change, per the dev's pre-fix run and ablation).
  • Not on the published surface — RIGHT. declaredHookCondition is a module export of hook-wrappers.ts alone. src/index.ts (the . entry) re-exports wrapDeclarativeHook, HookConditionError, hookRecordState and the WrapDeclarativeOptions type; src/core.ts (the ./core entry) the first two and the type; neither names the new function and neither is in the diff. package.json exports maps only . and ./core, so no deep import reaches it. engine.ts gains one value import from hook-wrappers.ts; hook-wrappers.ts imports only a type from engine.ts. Build Core and the four Type Check lanes are green; the dev's union measured check:dual-build-cjs-loads at exit 0.
  • What a condition that evaluates now does inside the unit — RIGHT as the 22856 note now states it, population by population. false still skips (gate code unchanged; pinned by the second insert in the log CONTROL). true with onError: 'log' and a throwing handler: [hook] handler failed (onError=log; suppressing) logged, both rows stored, zero rollbacks (unit CONTROL), REST 201 with the row stored (dogfood CONTROL). The three populations the note names: (1) a blocking log hook whose body writes a business row to an object on another datasource: the body now runs inside the door's transaction(), enforceTransactionOrigin sees a scope whose driver differs from the target's and, for a non-system-ledger object, throws CrossDatasourceTransactionWriteError at the top of the inner write; the throw reaches runWithErrorPolicy, onError: 'log' logs it and returns; the caller's write commits. Stated right; the dev's M1 reading matches and is reported, not re-measured here. (2) The part of an async: true body that runs before the unit's callback settles: runWithErrorPolicy(detached) is started inside the transaction's async scope, so a write it makes while the unit is open is judged by the same seam; a write after the unit closed runs on its own connection (the objectql: ScopedContext.transaction() publishes an ambient transaction entry it never closes, so an async hook's later write inside it is lost ("Transaction query already complete") #22855 reading, exercised by the dogfood late-write CONTROL on the same datasource; the dev's M2 cross-datasource reading is reported, not re-measured). Stated right. (3) A MongoDB default store without a replica set: not measured by anyone on this head, and declared NOT MEASURED in the report; the sentence follows from the merged family note on main ("a MongoDB default store needs a replica set for this unit, as it does for transaction() already") applied to the new population. Carried as the family's statement; not a FAIL ground. The system-ledger carve-out (audit/telemetry rows execute outside the unit) is unchanged, and the note's "business row" wording keeps clear of it.
  • Docs table row (hooks.mdx, the unit-of-work table) — RIGHT. The first row now names "a blocking one whose onError is abort (the default), or one carrying a condition, whatever its onError and async", with the second cell's two exclusions kept; four rows, as the sentence above the table counts. It matches the predicate. Kept imprecision, not introduced: "an after* hook" where the predicate requires a metadata-bound one. The grep of the hand-written tree found two other log/async statements, both still true on this head: content/docs/api/data-flow.mdx ("use onError: 'log' so a failing side effect doesn't roll back the write": a failing handler still does not; a failing condition refused before and refuses now) and the content/docs/api/error-handling-server.mdx example comment ("never abort the write": unchanged for a handler failure). The spec JSDoc restatements are E2, in ③.
  • Cost line — RIGHT. The predicate runs per write and cannot know whether the condition will fail, so every write on an object carrying such a hook pays one unit: "one transaction per write on objects that carry such a hook", the same per-write cost as 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's. The in-repo reach (the showcase's two async: true, onError: 'log' afterUpdate hooks on showcase_task and showcase_project) is the dev's measurement, reported as such.
  • Pins — adequate, unchanged since the earlier head. Unit: log insert (HookConditionError, reason: 'unevaluable', hook and event named; row and the log chain-writer's note absent; one begin, one rollback), log on update and delete (stored row unchanged; two begins, two rollbacks), async (refused, row absent, handler never ran), and the three CONTROLs. Real door: five REST createData cases on the verify stack, including the async late-write CONTROL. Test Core and the three Dogfood lanes are green on this head; the ablation evidence is the dev's.

② Semver level

  • minor, BREAKING, Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription) — all RIGHT for what the diff publishes. The diff adds no authorable key, no payload key, no exports-reachable symbol and refuses nothing an author could write before, so no is the true value of the clause-② question; inside the new unit, writes that were accepted on the new population are now refused (①, the three populations), which is a narrowing of accepted behaviour, so (narrowing) is the true arm; the reader's own table names no (narrowing) as "NOT a widening, but breaking", the whole point of the arm. A declared breaking change ships as minor under the launch-window convention the no-major gate states, so minor is the right level and patch would have under-declared it. The ! in the title and the BREAKING line are the breaking-ness carriers the gate family reads; the ADR-0087 gate reads the note as BREAKING plus bang plus clause-②-narrowing and records the no-migration-prescription exemption as a notice (the annotation on both Check Changeset runs). The category matches the diff: no spec key, export or stored shape is removed, renamed or re-shaped, so there is nothing for objectstack migrate meta to rewrite; the marker's three "closed on facts" statements (published package, no ADR-0087 id touched, no exported declaration removed or narrowed) are each true. The 22856 note's "Fix" line is a configuration choice for a reader the narrowing reaches, not a rewrite prescription for a retired spelling, and the gate agreed (step 13 green inside both red jobs).
  • The four declarations agree. PR title fix(objectql)!: …; PR body line 2 Clause-②: no (narrowing); the changeset's Clause-②: no (narrowing) and '@objectstack/objectql': minor; claim 6114412794 as amended by ruling 6115782204 to Clause-②: no (narrowing). Triage 6110316957's Clause-②: no predates the finding and is superseded on the card by that ruling. The family precedent (PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819: yes (narrowing), minor, the marker) is matched on the arm, the level and the marker; the value differs because this diff widens nothing, which the ruling says is also legal.
  • The changeset text now matches what the diff publishes — F2 FIXED. The "behaves as before" opener is gone; "Unchanged" now states only what is unchanged (a condition's verdict is read as before; false skips; true plus log logs and stores; inside-a-unit joins; the three non-opening exits; log/async with no condition opens no unit), each true per ①. The new "Inside the new unit" bullet names the three populations and a remedy (drop the condition and test it in the body, one datasource, a replica set). FROM, TO and Cost are true on this head. One kept generality, not introduced and not owed: the title says "no longer leaves its row behind" without the off-default-datasource and no-transactions exclusions, which the Unchanged bullet carries, in the family title's own shape.

③ Boundary flags

The DELIBERATE CORRECTION (PR comment 6115345828; addendum 6115328926 and ruling 6115782204 on the card). Named and judged on this head:

  • Each Check Changeset red carries only that one refusal — CONFIRMED. Both jobs on this head show the same shape: step 12 (check-empty-changeset.mjs) is the only failing step; step 11 (a changeset is present), step 13 (the ADR-0087 disposition) and step 15 (the no-major guard) are green in the same job. Each run has exactly one failure-level file annotation, naming .changeset/22782-objectql-after-hook-abort-rolls-back.md as present on the merge base and changed by this PR, with the gate's two-class text (COLLISION / DELIBERATE CORRECTION); the other annotations are the ADR-0087 notice on the 22856 note and the runner's generic exit line. The gate's own header puts this case in the DELIBERATE CORRECTION class ("do NOT restore it; get it confirmed on the PR"), and this record is that confirmation. Check Changeset is not among the seven required contexts AGENTS.md names, so the queue does not read it; that it runs on pull_request only is the seat's statement, not verified here (the workflow is outside this record's inputs).
  • The two rewritten clauses, on this head. Commit 4a3e842580 (one line of the "Unchanged" bullet): "No unit is opened for an onError: 'log' or async: true hook that carries no condition (objectql: an after* hook with onError: 'log' whose condition cannot be evaluated refuses the write (HookConditionError) while the row stays stored #22856), a hook registered in code with registerHook (no declared onError), a write with skipAutomations, an object whose datasource is not the default one, or a default driver with no transactions (ADR-0119 D1, …)" is TRUE clause by clause against the predicate (①), and the following "On those the row stays stored after an abort, as before" is true for every population the sentence now lists. Commit b2eefbd375 (one line of the "Inside the new unit" bullet): "If either applies to you, mark the hook onError: 'log' or async: true and give it no condition (objectql: an after* hook with onError: 'log' whose condition cannot be evaluated refuses the write (HookConditionError) while the row stays stored #22856), or keep the objects on one datasource" is TRUE: such a hook with no condition fails both disjuncts, so it opens no unit, and a reader who follows the remedy gets what it promises. Kept imprecision, not introduced: the remedy speaks of "the hook", and an object that also carries a blocking abort hook stays in the unit for that other hook, as it did before this PR. F1 is FIXED.
  • Every other sentence of the 22782 note, on this head. The word-diff from the merge-base shows only the two token runs above; the front matter (minor), the title, Clause-②: yes (narrowing), the ADR-0087 marker, the BREAKING line, FROM, TO, the first sentence of "Unchanged", the first two sentences of "Inside the new unit", the sandboxed ctx.api.transaction bullet, the "Work a unit starts and does not await" bullet and the Cost bullet are byte-identical. Each stays true on this head: the abort arm of the predicate is unchanged, and the unit's own rules (enforceTransactionOrigin, the family's replica-set statement, the join of a sandboxed transaction, the closed-handle reading for detached work) are not touched by this diff. The Cost bullet ("one transaction per write on the objects above only … Objects without such a hook pay one map lookup"), which the round-2 report flags: it stays true as what it is, the cost of THAT change on THAT change's population (its FROM objects, with its own 1.36x measurement). The only reading on which it fails is a system-wide one, and the same note now points the reader at objectql: an after* hook with onError: 'log' whose condition cannot be evaluated refuses the write (HookConditionError) while the row stays stored #22856 two bullets above for the conditional log/async population, whose cost the 22856 note states in its own words. Not a false sentence under the correction's own standard; dropping "only" is open to the seat and is not owed by this record. The whole note is true on this head and nothing else in it changed: confirmed.
  • Commits 48b64091df and 9eb65e6a44 touch only .changeset/22856-log-hook-unevaluable-condition.md, this PR's own file: the first dropped the pointer at the old 22782 wording, the second rewrote the note into the narrowing form (②). Both are judged above and are no part of the foreign-changeset refusal.

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 (fix(objectql)!: … (#22856)): matches the family's carriers; accepted. (3) The 22782 Cost "only" left byte-identical under the seat's instruction: judged above; not owed. (4) The "below" nit: the live PR body now reads "(see "Deliberate correction" above)"; fixed. (5) origin/main moved and was not merged, mergeable_state unstable: CI tested the merge of this head into the then main, none of the seven files moved on main since the merge-base, and the queue rebuilds on landing; accepted. (6) A throwaway measurement test created, run and deleted uncommitted: absent from the head tree and from the net diff; its readings (M1, M2) are cited here as the dev's, not as this record's. (7) Worktree cleanup: a process note. Open questions: none. Out-of-scope findings: none. MongoDB NOT MEASURED is declared in the report and carried as such (①).

Dev flags, round 1 (report 6115564493). Deviations: (1) origin/main not merged: as above. (2) A rebuild from the turbo cache before the union: accepted. (3) The push-count correction: noted. Open questions and out-of-scope findings: none. The pr_body_sentences_this_round_makes_false replacements are present in the body as read, with the round-2 replacements layered on them.

Dev flags, round 0 (report 6115299755). Deviations: (1) the hooks.mdx row beyond the claim's surface: accepted, declared cross-lane in addendum 6115328926; the row is right (①). (2) Ablation restore preflight in present mode on a DELETE ablation: a process note; CI green on the head is the evidence this record uses. (3) origin/main merged by a merge commit: the net diff against the merge-base is the seven files; fine. (4) The first union stopped and re-run: superseded by the later unions and by CI on this head. (5) A stray whole-repo eslint, output discarded: nothing cited from it; Lint and Repo Gates green. (6) Narrowed dogfood tsc: the four Type Check lanes are green on the head. (7) Attribution: the eight authored commits carry Claude-Session plus the model-free Co-authored-by; the origin/main merge commit carries no trailer and is squashed away at landing; AGENTS.md's rule wins, accepted. Out-of-scope findings: (1) load-time refusal of a never-bindable condition: population measured 0, not filed per triage; accepted. (2) The 22782 "Unchanged" clause: became the DELIBERATE CORRECTION, judged above. (3) The ast-only envelope installs no gate: pre-existing and byte-identical; the shared reading makes the door agree with the gate on it; accepted. Open questions: none.

Escalations from record 6115773915, as the seat ruled them (6115782204). E1: reflected on this head in all four declarations (②). E2: packages/spec is untouched by this diff; the HookEvent JSDoc's first "Four ordinary ways" bullet and the onError TSDoc in hook.zod.ts still describe the door's unit as the blocking-abort case only (under-inclusive the way the hooks.mdx row was before this PR), while the onError describe() string and the generated reference row are unqualified and stay true; handed to domain:spec by pointer per the ruling, outside this PR; not a FAIL ground. E3: the sanitised 500 and triage's third option stay out, as triage ruled.

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 Check Changeset red carries only that refusal; no check-run is pending; every required context is green. No source, test or doc-table change is owed.

Implemented-by: claude/issue-22856-log-hook-condition-unit
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

VERDICT: PASS

Adopted by domain:engine#2 (session_01ADzJtzYTLUfgrRZHxkagkX) at 2026-10-12T01:39Z from an isolated at-tier reviewer whose transcript shows read-only tool use (its one write is its own scratch record). Head and checks re-read by this seat at adoption: 9eb65e6a44, 42 runs, 36 success, 4 skipped, 2 failure (Check Changeset, the DELIBERATE CORRECTION judged above), merge-tree clean against current main. Correction of this seat's own earlier record: the adoption line of 6115773915 said the "below" nit was fixed in the PR body; it was not fixed then. It was fixed in the body edit of this round (relay run 38191844116), as this record reads.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 12, 2026 01:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 12, 2026 01:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit 3caae97 Oct 12, 2026
42 of 44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22856-log-hook-condition-unit branch October 12, 2026 02:29
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

objectql: an after* hook with onError: 'log' whose condition cannot be evaluated refuses the write (HookConditionError) while the row stays stored

2 participants