Skip to content

fix(objectql)!: 事务句柄不再跨数据源穿透 —— 业务写响亮拒绝、系统账本移出事务落盘 (#5351) - #6171

Merged
baozhoutao merged 13 commits into
mainfrom
claude/issue-5351-txn-audit-same-origin
Aug 7, 2026
Merged

fix(objectql)!: 事务句柄不再跨数据源穿透 —— 业务写响亮拒绝、系统账本移出事务落盘 (#5351)#6171
baozhoutao merged 13 commits into
mainfrom
claude/issue-5351-txn-audit-same-origin

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #5351
Refs #5696(第 2 点在本 PR 落地)、#4619(母单)、#5724(可发现性半边)

2026-08-07 08:3xZ 更新 —— PR #6165(契约半边)已由合并队列 squash 合入 main(136308428),本 PR 的 base 已自动转到 main。分支上携带的 #6165 原始 commit 与 main 上的 squash 版本一度被视为两份来源(dirty),已按语义合并收口:三个冲突文件的每一处 hunk 都是「#6165 内容两边等价 + 本 PR 自己的改动叠在其上」,取 HEAD;合并后全量重验(见「测试」)。现在 GitHub 显示的 9 files 就是本 PR 的真实改动面,packages/objectql/src/{engine.ts, index.ts, transaction-errors.ts} + 两个测试文件 + docs/adr/0067-*.md + docs/adr/0119-*.md + scripts/adr-anchors.json + 一个 changeset。


修的是什么(#5351 的真实根因)

buildDriverOptions 把 ambient 事务句柄无条件塞进每一次 driver 调用,不问即将收到它的是哪个 driver:

const tx = execCtx?.transaction !== undefined
  ? execCtx.transaction
  : this.txStore.getStore()?.transaction;   // ← 不问这个 tx 属于哪个数据源

于是被路由走的对象拿到的是默认库那条连接的事务对象,knex 的 .transacting(trx) 把语句发到错误的库。实测(#5351,一次真实 boot):sys_audit_log 被 ADR-0057 §3.6 路由到 telemetry 数据源,insert 尝试 52 次、成功 50 次、失败 2 次,失败的两次堆栈全部带 knex 的 trxClient.query 帧,报 no such table: sys_audit_log;成功的没有一次带。

即:凡是在事务中执行的被审计写入,合规审计行全部静默丢失 —— 业务写成功、接口 200、数据在盘上,只有「谁做的」那一行没了,且无人重试。

契约 TSDoc 原先写这类写入「在事务外执行」。实测比这更糟:它是带着另一个连接的事务对象执行的。PR #5724 用纯 in-memory 双数据源独立复现了同一现象(expected { __trx: 'primary' } to be undefined),证明缺陷属于事务缝本身,与审计无关、与 SQL 无关 —— 只要有任何一条 mapping 规则把对象路由走。

三条新行为(2026-08-06 裁决,必须同时成立)

1. 句柄不再跨驱动。 buildDriverOptions 只在解析到的 driver 就是事务属主时才穿句柄,按 TransactionScope(#5724)记录的实例身份比对。结构性、不分动词 —— 读操作同样覆盖,它们此前也在错误的连接上跑,而且连诊断都没有。

2. 业务写跨驱动 → 拒绝(#5696 第 2 点)。CrossDatasourceTransactionWriteError(code: 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE'),在 insert/update/delete最顶端抛出,早于任何 hook / 默认值 / 校验 —— 拒绝时调用方一行都没写。⛔ 这不是跨库原子性:IDataDriver 没有两阶段提交,#4619 原文已排除;在第二个 driver 上开伴随事务只会用一个更坏的持久性风险换掉当前这个。

3. 系统账本移出事务执行(carve-out)(#5351 裁 A)。lifecycle.classaudit/telemetry/event 的只追加账本不拒绝,而是在自己的连接上、事务之外执行 —— 审计行真正落盘

判别式(裁决要求写明)

对象声明的 lifecycle.class ∈ {audit, telemetry, event} —— 不是「它被哪种机制路由走的」。

一个用显式 datasource: 绑定钉到自己库上的审计账本,是同一种只追加合规账本、有同样的理由被 carve-out;按机制判会让一条合规担保取决于运维恰好用了三种等价配置中的哪一种。该 tuple 只存在一处 —— ObjectQL.SYSTEM_LEDGER_LIFECYCLE_CLASSES —— 由 ADR-0057 §3.6 的路由步骤与本门共同读取,因为两份手抄会差一个 class,而差掉的恰好就是本次要救的那一行。

孤儿行语义(裁决明确接受的代价)

carve-out 的行会在业务事务回滚后留下 —— 一条审计行可能描述一次被撤销的写入。方向比数量重要:对只追加的合规账本,一条多余的行是可对账的麻烦(拿业务数据一比即知),而一条已提交写入却缺失的审计行是不可恢复的合规空洞 —— 后者正是今天在发的版本。已按裁决写进 ADR-0067 / ADR-0119 修订,并由测试钉住(不是留给读者推断)。

为什么必须同批(#5696 第 2 点为何不能单独先落)

carve-out 之外只留拒绝,就是「响亮化但不修复」:审计 hook 的 try/catch 把拒绝吃成日志,合规行照丢,只是换了条日志。这不是推理 —— 反向验证肢 B 实测到了它,见下。

#5724 那条 error 日志的语义调整

跨源写入的 error 级诊断随它描述的缺陷一起退休 —— 已经没有「静默穿错连接」可报了:业务写抛错(抛比任何日志都响),系统账本按声明落盘。carve-out 路径改为 debug,每事务每数据源一次。

理由按 AGENTS.md 的判据:降级后系统是否「看起来正常而声称持久化的东西没落地」? —— 审计行落了。它现在是声明过的正常行为,在分流部署里每一次被审计的事务写入都会发生;挂在 error 甚至 warn 上就是该节名的镜像错误,会训练读者跳过真正的持久性告警。持久答案在 ADR-0067/0119,不在日志级别。

P1 边界:明确不覆盖,并写进文档

同源校验只判能归属属主的句柄TransactionScope 覆盖引擎自己开的每一个事务;显式穿回来的 trxCtx句柄身份匹配回 txStore 条目,所以主流显式路径(transaction() 给你 trxCtx、你以 { context: trxCtx } 传回)是覆盖的 —— 调用方不能靠传引擎给它的上下文绕开这道门(已钉测试)。

不覆盖(决定,非疏漏):ScopedContext 的离散 beginTransaction/commit/rollback 三件套(跨 setImmediate 显式穿句柄,根本不走 txStore),以及外部调用方自带的 execCtx.transaction。这类句柄是不透明的 driver 对象,没有任何回指其属主的引用,没有诚实的比较可做。猜(比如「无属主记录就当它属于默认驱动」)两边都会错:一边误拒合法的单库工作,一边把真正被覆盖的写入误判为跨源、悄悄移出事务失去回滚保护。建立在猜测上的保证比明确不覆盖更坏。

该路径保持 #5351 之前的行为,已作为决定钉进 engine-transaction-same-origin.test.ts(“does NOT cover a foreign handle passed in from outside — declared, not overlooked”),写进 ADR-0119 修订的「The declared LIMIT」一节,收口需要 IDataDriver 暴露句柄属主 —— 另立单 #6167

ADR 修订落点

按仓内既有惯例(Status 行加 **Amended** + 文末 ## Amendment (日期, #单号) 段),⛔ 未触 content/docs/releases/:

  • docs/adr/0119-plugin-reachable-transactions-and-honest-atomic-batch.md —— 主修订。D1-R1…R5:句柄不跨驱动 / 业务写拒绝 / 系统账本 carve-out 与孤儿行 / opts.require / owned;含「caveat 原文事实性错误」一节(D1 说的「written outside it」经实测不成立)与「declared LIMIT」一节。
  • docs/adr/0067-commit-history-and-rollback-for-ai-authoring.md —— D2 join 语义现在可分辨(owned);Decision-2「commit 不会半落地」明确为单数据源承诺并真正被强制;revertCommit 之后审计行留存这件事写明白了,并论证它与本 ADR「历史只追加、revert 是新的正向 commit」的设计中心一致。
  • scripts/adr-anchors.json(PD [WIP] Add Chinese version of the documentation #13)—— 更新 objectql-engine.ts 的 invariant(旧文本仍在描述被推翻的 caveat),新增 packages/objectql/src/engine.ts 锚(ADR-0057/0067/0119),把「不许恢复无条件穿句柄、不许加伴随事务」写成下一位作者能读到的话。

测试

新增 packages/objectql/src/engine-transaction-same-origin.test.ts(18 例),用真实 #5351 拓扑(primary + telemetry 专用数据源 + 一条把普通业务对象路由走的 mapping 规则)。driver 替身开出的句柄自带自己的名字(形如 { __trx: 'primary' }),所以句柄跑错 driver 一眼可见:

  • (a) carve-out:审计写落到 telemetry、options.transactionundefined(断言 undefined 而非「不是 primary 的」—— 这里本就不该有任何句柄)、三种 class 全覆盖、update/delete 同样 carve-out、业务回滚后审计行保留(孤儿行钉死)debug 每事务每数据源一次且 error/warn 计数为 0、新事务重新计数;
  • (b) 业务写拒绝:抛型 + code + 四个字段 + 消息含两条修法 + Nothing was written.;两个库都没写且外层已回滚;update/delete 同样拒绝;join 的嵌套事务按外层属主判定后拒绝;ScopedContext.transaction 同样拒绝;事务外的 mapped 写入不误报;事务关闭后作用域不泄漏;
  • :跨源读丢句柄但不拒绝(读没有原子性主张可破坏,拒绝只会打断合法的引用校验/expand);⚠️ 先断言两个 reads 数组长度 —— 空数组上 reads.at(-1)?.transaction 也是 undefined,只断句柄会因「没发生读」而假绿;
  • (e) 同源回归:单数据源(不注册 telemetry)下审计对象照常解析到默认驱动,两条写入都骑同一个事务、随业务一起回滚,error/warn/carve-out debug 全为 0 —— 门对绝大多数部署完全不可见;
  • 边界:显式穿回 trxCtx 被覆盖;引擎没开过的外来句柄不覆盖(见上)。

P2 的钉子按设计翻绿:engine-transaction-observability.test.ts 第 2 节(9 例)钉的正是被删除的那条肢 —— error 日志 + 句柄穿透。按三分法它属于「整体替换」而不是「重新拼写」:它的主体没有了。9 例的每一条事实都在新文件里对裁决后的判决重新提问(每事务预算改挂 carve-out —— 拒绝是抛错,抛错从不去重;update/delete;join 嵌套;ScopedContext;事务外不误报;作用域不泄漏),原地留桩只会把同一件事钉两遍。第 1 节(降级 warn-once)原样保留 —— 它仍然纯观测,opts.require 只是给了调用方一个选项,没改它覆盖的默认行为。文件头已如实记录这次搬迁。

真实输出(合入 main 136308428、全量 pnpm build 之后重跑;合并前的绿一律作废)

四个事务相关测试文件:

 Test Files  4 passed (4)      ← same-origin + contract + observability + lifecycle-datasource
      Tests  41 passed (41)

五包全跑:

packages/spec test:               Test Files  330 passed (330)   Tests  8419 passed (8419)
packages/core test:               Test Files   27 passed (27)    Tests   498 passed (498)
packages/metadata-protocol test:  Test Files   49 passed (49)    Tests   502 passed (502)
packages/objectql test:           Test Files  135 passed (135)   Tests  2214 passed (2214)
packages/runtime test:            Test Files  105 passed (105)   Tests  1506 passed (1506)

typecheck 与门:

packages/spec typecheck: Done
packages/objectql typecheck: Done
packages/runtime typecheck: Done

check:generated: 10/10 ✓(spec-changes / upgrade-guide / skill-docs / skill-refs / react-blocks /
                        authorable-surface / api-surface / docs / strictness-ledger / test-typecheck)
check-type-check-coverage --self-test: 22 semantic + 11 observation + 11 re-measure case(s) hold
check-type-check-coverage --re-measure: OK — 34 ledger entr(ies) re-measured in 326.6s,
                        2054 raw tsc error(s) total, none above its recorded number  (EXIT=0)
                        └ objectql TEST_DEBT 355 → 344 (-11),下降,报为 ℹ 非红
check-engine-double-contract: OK — 73 pinned, 133 in the DEBT ledger, 2 exempt
check-adr-anchors: OK — 37 anchored file(s), every governing ADR still referenced
check-nul-bytes: OK — scanned 5920 tracked text file(s), no raw ASCII control bytes

反向验证(三肢,方向先写死再运行)

肢 A —— 去掉同源校验(buildDriverOptions 恢复无条件穿句柄,enforceTransactionOrigin 不动)。

  • 预测:(a) 组三条「无外来句柄」断言 + 读那条转红 = 4 红;拒绝各例、孤儿行、单源回归保持绿 —— 因为 in-memory 替身感觉不到错误的句柄,这正是 fix(objectql): make transaction() 的两条既声明 caveat 可被观测 (#4619) #5724 记录的那件事。
  • 实测:4 红,其中两条是逐字的
    × reaches the telemetry datasource with NO foreign transaction handle
       AssertionError: expected { __trx: 'primary' } to be undefined
    × covers all three system-ledger classes, not just audit
    × carves out update and delete of a system ledger too, not just insert
    × does not hand the other driver the transaction handle
       AssertionError: expected { __trx: 'primary' } to be undefined
     Tests  4 failed | 14 passed (18)
    
    ——与 PR fix(objectql): make transaction() 的两条既声明 caveat 可被观测 (#4619) #5724 当初把现状钉成 toEqual({ __trx: 'primary' }) 时看到的是同一句话,方向相反。匹配:是。

肢 B —— 关掉系统写 carve-out,只留拒绝(即「单独先落 #5696 第 2 点」的形状)。

  • 预测:(a) 组 6 例全红,审计写变成拒绝路径;拒绝组、读、单源回归、边界保持绿 = 6 红 / 12 绿
  • 实测:6 红 / 12 绿,完全一致。最有说服力的是第三条:
    × SURVIVES the rollback of the business transaction — the orphan row, pinned
       AssertionError: expected [Function] to throw error including 'business failure'
         but got 'insert of \'sys_audit_log\' inside tr…'
    
    审计写的拒绝顶掉了业务错误本身。生产里这条拒绝会被审计 hook 的 try/catch 吃掉,合规行照丢 —— 「响亮化但不修复」不再是论证,是实测到的形状。匹配:是。这是耦合裁决必要性的直接证据。

肢 C —— 移除 require 判定(引擎面与沙箱面各一处;require#6165 引入,在本分支上重跑取证)。

  • 预测:4 条引擎面 + 1 条沙箱面拒绝断言转红 = 5 红,owned、降级默认、同源各例保持绿。
  • 实测:Tests 5 failed | 28 passed (33),红的正是那 5 条。匹配:是。

三肢均已还原,还原后全绿。


必答项

#5226(审计行丢失的症状单)是否应随之关闭?

不需要动 —— 它已于 2026-08-05 由 os-zhuangcompleted 关闭,早于本批。给 PM 的证据,分两层:

结论:无需再动 #5226 若 PM 要留档,建议在其下补一条指回本 PR 的说明,让下一个搜到该单的人看到根因编号。

#5929 / #5846 是否触其面?

,与预期一致。两单都在 hook 前置行(pre-image)读取与按对象需求门那一片:#5846 是单 id update 三读同一行 + 全局注册架空按对象门;#5929sys_fetch_previous_deleteobject: '*' 注册让 delete 侧的门恒真。

本 PR 的 diff 完全不碰这片:未改 hasHooksFor、未改 wantsPreImage / needsPriorRecord、未改 packages/objectql/src/plugin.ts 的任何内建 hook 注册、未改 plugin-audit。唯一的接触面是顺序:enforceTransactionOrigin 在写路径最顶端、早于前置行读与 hook 派发,所以一次被拒绝的跨源业务写现在不会再触发那些读 —— 这只会让两单描述的多余读更少,不改变它们的判定逻辑,两单的事实陈述与验收判据全部不受影响。

#4619(母单)落地后还剩什么

三点全部落完:

#4619 状态
1 静默降级 可发现性 = warn-once(#5724);收紧 = opts.require fail-closed(#6165,已合入 main 136308428)
2 default-driver-only 可发现性 = error 诊断(#5724);收紧 = 本 PR(业务写拒绝 + 系统账本 carve-out + 句柄不跨驱动)
3 owned-vs-joined owned 信号(#6165,已合入)

给 PM 的收口判断:本 PR 合入后 #4619 可以关(completed),关闭说明里指向 #5724 / #6165 / 本 PR 三处,以及 ADR-0067/0119 的 2026-08-06 修订。

⚠️ 关之前请注意两件不属于 #4619 三点、因此不该拿来拖住它的事:

  1. 事务句柄没有属主可查,同源校验对「引擎没开过的句柄」只能弃权(#5351 的已知边界) #6167 —— 同源校验对「引擎没开过的句柄」弃权。这是本次实现的已声明边界,收口要动 IDataDriver 契约,量级和性质都不同,已独立立单;
  2. ScopedContext.transaction 不 join ambient 事务 —— 沙箱 hook 体里的 ctx.api.transaction() 会在事务中再开一个事务(违反 ADR-0067 D2) #6168 —— ScopedContext.transaction 不 join ambient 事务(违反 ADR-0067 D2),实施中发现,独立立单。

#4619 原文明确排除的跨驱动原子性 / 两阶段提交依然不在任何单里,也不应因本批而被认为「还欠着」—— 它是被裁掉的,不是被推迟的。

范围外发现(PD #10,均未在本 PR 内修改)

消费者影响

单数据源部署(绝大多数)零变化:不拒绝、不 carve-out、不打日志 —— 已由回归组钉住。门只在「注册了第二个数据源」+「对象路由到它」+「事务开着」三者同时成立时可达。changeset 里带了升级须知(跨驱动业务写从静默部分提交变为拒绝;审计写从静默丢失变为事务外落盘 + 孤儿行语义)。

claude added 5 commits August 7, 2026 04:11
…losed and an `owned` signal (#5696)

`IObjectQLEngine.transaction` declared two degradations as part of its meaning
(ADR-0119 D1): default-driver-only routing, and a silent fallback to "no
transaction, no rollback" on a driver without `beginTransaction`. #4619 made
both audible (PR #5724). This lands the first two of #5696's three tightenings,
each opt-in, with every existing caller's behaviour unchanged:

- `opts.require: true` throws `TransactionUnsupportedError` instead of
  degrading — refused BEFORE the callback runs, so nothing is written when the
  caller finds out. Generalizes `batchData`'s atomic gate (ADR-0119 D4).
- the callback's second argument carries `owned`: true when this call opened
  the transaction, false when it JOINED an outer one (ADR-0067 D2) or ran on
  the degrade path where there is no transaction to own.

Both are honoured on `ScopedContext.transaction` (`ctx.api.transaction`) too —
a second implementation of one primitive must not become a second dialect.

The contract TSDoc is corrected on a point measurement disproved: writes routed
off the transaction's datasource were NOT "written outside it", they were handed
the owner's transaction handle and executed on the wrong connection (#5351). The
TSDoc now states that, plus the two decided semantics landing next: business
writes refused across drivers, and system ledgers (`lifecycle.class` of
audit/telemetry/event) carved out to execute outside the transaction.

`@objectstack/core`'s `EngineWithTransaction` is typed FROM the contract rather
than transcribed from it — the hand-copy had already started to drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…rred artifact)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…ness writes, carve out system ledgers (#5351, #5696)

`buildDriverOptions` lifted the ambient transaction handle onto EVERY driver
call without asking which driver was about to receive it, so a write routed
elsewhere was handed the DEFAULT driver's handle and executed on the wrong
connection. Measured on a real boot (#5351): sys_audit_log, routed to the
dedicated telemetry datasource by ADR-0057 §3.6, took 52 insert attempts, 50
succeeded, and the 2 failures were exactly the 2 whose stack carried a knex
`trxClient.query` frame — `no such table` against the primary database. Every
audited write performed inside a transaction lost its compliance row, silently,
with no retry.

Per the 2026-08-06 maintainer ruling, three changes that had to land together:

- a transaction handle is threaded only to the driver that OWNS it, compared by
  instance identity via TransactionScope (#5724). Reads covered too — they had
  the same defect and no diagnostic at all.
- a cross-driver BUSINESS write inside a transaction is REFUSED
  (CrossDatasourceTransactionWriteError), at the top of insert/update/delete
  before any hook, default or validation runs. This is #5696 point 2; it is not
  cross-driver atomicity, which needs two-phase commit and stays out of scope.
- append-only SYSTEM LEDGERS (lifecycle.class audit/telemetry/event) are CARVED
  OUT and executed outside the transaction, so the compliance row lands. They
  survive a rollback — the orphan row is the accepted cost: for an append-only
  ledger a spurious row is reconcilable, a missing row for a committed write is
  not. Refusing them is not available even in principle, since the audit hook's
  try/catch turns any refusal back into a dropped row.

The `error`-level split diagnostic PR #5724 added retires with the defect it
described; the carve-out is noted at `debug`, once per transaction per
datasource, because it is now declared behaviour that fires on every audited
transactional write in a split deployment.

Single-datasource deployments see no change of any kind.

ADR-0067 and ADR-0119 carry the revision, including the gate's declared limit:
handles the engine never opened cannot be attributed to a driver, so the gate
declines to judge them rather than guess (filed as #6167).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…-audit-same-origin

Conflict: packages/objectql/src/engine.ts import block — both sides added an
import next to the same line (#5351's transaction-errors, main's
summary-aggregate). Both kept; no logic overlap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 8:37am

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation ci/cd dependencies Pull requests that update a dependency file tests tooling labels Aug 7, 2026
claude added 2 commits August 7, 2026 05:42
…ransaction signature with the contract (#5696)

CI's `TypeScript Type Check` went red where a scoped `pnpm --filter …
typecheck` could not: @objectstack/metadata-protocol has no `typecheck`
script, so its errors are only ever seen by the DEBT ledger's `--re-measure`,
which reported 63 -> 70 (+7).

All seven were one shape. Six test doubles stand in for `SysMetadataEngine`,
whose `transaction?` member is typed FROM the contract
(`IObjectQLEngine['transaction']`), and each declared its callback as
`(ctx: any) => Promise<T>` — one parameter, from before the callback gained
its `info` argument. A double may be narrower than the producer; it may not
contradict it.

Each now declares `(ctx: any, info: { owned: boolean })` and passes
`{ owned: true }` at the call — the honest value, since every one of these
doubles OPENS the transaction it stands in for, which is exactly what
`ObjectQL.transaction` reports on its own open branch.

Re-measured: 63 raw errors, the frozen count, with zero transaction-related
errors remaining. `check-type-check-coverage --re-measure` reports no upward
drift. metadata-protocol: 49 files / 502 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…ntract' into claude/issue-5351-txn-audit-same-origin
claude added 4 commits August 7, 2026 06:14
…e TEST_DEBT re-measure (#5696)

objectql's test layer is excluded from its own `tsconfig.json`, so
`pnpm --filter @objectstack/objectql typecheck` never reads these files —
only the ledger's `--re-measure` does, by synthesizing a config with the test
globs unexcluded. The new file arrived with 7 errors nothing local reported.

Two shapes, both fixed rather than absorbed:

- `registry.registerObject(x as any)` — `packageId` is not optional (TS2554).
- `promise.catch((e) => e as E)` types the result `E | <resolved type>`, so
  every property read on it is TS2339. Replaced by a `rejection<E>()` helper
  that narrows to the rejection AND throws if the call did not reject at all —
  which the bare `.catch()` would have let pass silently as a green test.

Re-measured: zero errors in this file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…re-measure (#5351)

Same blind spot as #5696's contract test: objectql excludes its tests from
`tsconfig.json`, so `pnpm --filter … typecheck` reads none of these files and
only the ledger's `--re-measure` does. The new same-origin file arrived with
15 errors, which the ratchet caught as TEST_DEBT 355 -> 370.

Three shapes:

- `registry.registerObject(x as any)` — `packageId` is not optional (TS2554);
- `promise.catch((e) => e as E)` types the result `E | <resolved type>`, so
  every property read is TS2339. Replaced by a `rejection<E>()` helper that
  narrows to the rejection AND throws if the call did not reject at all —
  which the bare `.catch()` would have let pass silently as a green test;
- callbacks over the driver double's `writes` were implicitly `any` (TS7006),
  since the double itself is `any`. The shape now has a name, `RecordedWrite`.

Re-measured: objectql's test layer reports 344 against a recorded 355 (-11) —
zero errors in any of the three transaction test files, and the two
pre-existing `registerObject` arity errors in the observability file are gone
with them. `check-type-check-coverage --re-measure`: 34 entries, none above
its recorded number.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…ntract' into claude/issue-5351-txn-audit-same-origin
…nsaction-contract

Conflict: packages/objectql/src/engine.ts import block — both sides added an
import next to the same line (#5696's transaction-errors, main's
summary-aggregate). Both kept; no logic overlap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…n/main) into claude/issue-5351-txn-audit-same-origin

Two conflicts, both additive-on-both-sides:

- packages/objectql/src/engine.ts — the transaction-errors import line. This
  branch's version is the superset (it also imports
  CrossDatasourceTransactionWriteError); kept.
- scripts/adr-anchors.json — both sides appended a distinct anchor entry
  (#5351's engine.ts anchor; main's two ADR-0122 alias-convention anchors).
  Both kept; re-parsed as JSON and re-verified with check-adr-anchors (37
  anchored files).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…-audit-same-origin

main squash-merged #6165 (the contract half) as 1363084, so this branch's
original #6165 commits and main's squashed version are two sources for the same
content — the known stacked-PR shape. Resolved semantically, not textually.

Three conflicted files, all one pattern: the #6165 content is identical on both
sides (it auto-merged wherever only #6165 touched it), and every conflict hunk
is #5351's own work sitting ON TOP of that identical base. HEAD taken in each:

- packages/objectql/src/transaction-errors.ts (add/add) — main has
  TransactionUnsupportedError; this branch has it verbatim PLUS
  CrossDatasourceTransactionWriteError (#5696 point 2). Superset kept.
- packages/objectql/src/index.ts — same shape: the second error's export.
- packages/objectql/src/engine.ts, two hunks — the transaction-errors import
  (superset), and the transaction() TSDoc bullet that #5351 rewrote from
  "routed elsewhere runs OUTSIDE it, reported at error" to the decided
  behaviour (refuse business writes / carve out system ledgers). main's side is
  the pre-#5351 text and still names reportWriteOutsideTransaction, which this
  branch renamed to enforceTransactionOrigin.

Verified after resolving: no conflict markers, and the only surviving mention
of reportWriteOutsideTransaction is the TSDoc line that deliberately records
the rename.

Changesets: both survive intact. main's `chore: version packages (rc)`
(bd19133) landed BEFORE the #6165 squash, so it consumed neither — #6165's
changeset is still on main and this branch's own
transaction-same-origin-audit-carve-out.md is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@os-zhuang
os-zhuang marked this pull request as ready for review August 7, 2026 08:26

Copy link
Copy Markdown
Contributor Author

ACCEPT(执行席 PM 验收,同批 PR 序第二落 / 本席收官单)

核过:① 裁 A 完整落地 —— 同源校验结构半(transactionCoversDriverFor,句柄只交属主、读也覆盖但不拒)+ 行为半(enforceTransactionOrigin,业务写响亮拒绝、audit/telemetry/event 账本 carve-out 移出事务落盘、孤儿行按 ADR-0067/0119 修订记档);判别式按对象声明的 lifecycle.class(与 ADR-0057 §3.6 同一份 tuple)而非路由机制 —— 论证成立;② P1 边界「不覆盖引擎从未开过的句柄」为声明的限界而非疏漏(TSDoc + ADR「declared LIMIT」节 + 专项测试三处落笔,收口另立 #6167);③ 三肢反向验证精确命中,肢 B(关 carve-out 只留拒绝)实测出「审计写拒绝顶掉业务错误」—— 同批耦合裁决的必要性由此从论证变为实测;④ squash 冲突的语义合并已复核:三文件取 HEAD 的依据(分支为 main squash 版逐字超集)、TSDoc 取裁决后文案避免 reportWriteOutsideTransaction 悬空、冲突标记归零 + 全仓 grep 证据在案;合并态五包全量重验(spec 8419 / core 498 / metadata-protocol 502 / objectql 2214 / runtime 1506)+ 十道生成物门 + --re-measure EXIT=0;⑤ spec 契约半边已由 spec 席在 #6184 五点全核可(含 owned 第二参数选型);⑥ breaking 变更的 changeset 升级须知在(跨驱动业务写:静默部分提交 → 抛错,含两条修法)。

后续跟进单:#6167(不可归属句柄收口)、#6168(ScopedContext join)。本 PR 合入后:关 #4619(三点全落)、#5226 补根因指引。

enable auto-merge,CI 全绿即进队列(rc.5 口径)。


Generated by Claude Code

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

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ambient 事务句柄跨数据源泄漏:凡在事务中执行的被审计写入,合规审计行全部静默丢失(#5226 的真实根因)

2 participants