Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/transaction-same-origin-audit-carve-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
"@objectstack/objectql": minor
---

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

**这是行为变化,升级前请读完。** 只影响**注册了第二个数据源**的部署;单数据源部署
(绝大多数)**行为一字未变**,不拒绝、不 carve-out、不打日志。

## 修的是什么

`buildDriverOptions` 此前把 ambient 事务句柄**无条件**塞进每一次 driver 调用,不问
即将收到它的是哪个 driver。于是被路由到别处的对象(`setDatasourceMapping`、显式
`datasource:` 绑定,或 ADR-0057 §3.6 的 lifecycle 分流)拿到的是**默认库那条连接的
事务对象**,knex 的 `.transacting(trx)` 把语句发到了错误的库。

实测后果(#5351,一次真实 boot):`sys_audit_log` 被 §3.6 路由到 `telemetry` 数据源,
insert 尝试 52 次、成功 50 次、失败 2 次,失败的两次堆栈**全部**带 knex 的
`trxClient.query` 帧,报 `no such table: sys_audit_log`。也就是说 —— **凡是在事务中执行
的被审计写入,合规审计行全部静默丢失**:业务写成功、接口 200、数据在盘上,只有「谁做的」
那一行没了,且无人重试。契约 TSDoc 原先写这类写入「在事务外执行」,比实际情况乐观。

## 三条新行为

1. **句柄不再跨驱动**。事务句柄只交给开启它的那个 driver(按**实例身份**比对)。读操作
同样覆盖 —— 它们此前也在错误的连接上跑,而且连诊断都没有。
2. **业务写跨驱动 → 拒绝**。抛 `CrossDatasourceTransactionWriteError`
(`code: 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE'`),在任何 hook / 默认值 / 校验之前,
**一行都没写**。⛔ 这不是跨库原子性:`IDataDriver` 没有两阶段提交,本次刻意不做
(#4619 原文已排除)。
**升级须知**:此前这类写入会静默部分提交(而且是在错的连接上)。现在它会失败。两条修法,
错误消息里都写了 —— 要么让一个 `transaction()` 里写的对象都留在同一个数据源(移动对象,
或删掉把它路由走的 `datasourceMapping` 规则),要么把工作拆成按数据源的独立单元,由调用方
自己对账。
3. **系统账本移出事务执行(carve-out)**。`lifecycle.class` 为 `audit` / `telemetry` /
`event` 的只追加账本**不拒绝**,而是在自己的连接上、事务之外执行 —— 所以审计行**真正
落盘**,插件作者写普通 `afterInsert` 钩子零负担。
⚠️ **孤儿行语义**:这些行会**在业务事务回滚后留下** —— 一条审计行可能描述一次被撤销的
写入。这是维护者 2026-08-06 明确接受的代价:对只追加的合规账本,「多记一条可对账的行」
优于「已提交的写入却少一行」,而后者正是此前在发的版本。判别式是对象**声明**的
`lifecycle.class`(不是它被哪种机制路由走的),ADR-0067 / ADR-0119 的 2026-08-06 修订
记录了全部理由与边界。

## 同时移除

PR #5724 为跨数据源写入加的那条 `error` 级日志随之退休 —— 已经没有「静默穿错连接」可报了。
carve-out 路径改为 `debug` 级、每事务每数据源一次:它现在是**声明过的正常行为**,在分流部署
里每一次被审计的事务写入都会发生,挂在 `error`/`warn` 上只会训练读者跳过真正的持久性告警。

## 已知边界(明确不覆盖,非疏漏)

引擎无法归属的事务句柄不参与同源校验:`ScopedContext` 的离散
`beginTransaction`/`commit`/`rollback` 三件套(跨 `setImmediate` 显式穿句柄,不走 txStore),
以及外部调用方自带的 `execCtx.transaction`。这类句柄是不透明的 driver 对象,没有回指其属主的
引用,猜测(比如假定它属于默认驱动)会在一边误拒合法的单库工作、在另一边误放真正被覆盖的写入。
这条路径保持 #5351 之前的行为,已作为决定钉进测试,收口需要 `IDataDriver` 暴露句柄属主
(另单跟踪)。
64 changes: 63 additions & 1 deletion docs/adr/0067-commit-history-and-rollback-for-ai-authoring.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0067: Commit history and rollback for AI authoring — turns become atomic, revertible commits

**Status**: Accepted (2026-06-24; completed 2026-07-16) — fully implemented: commit grouping (`sys_metadata_commit`), `revertCommit`/`rollbackToPackageCommit`/`listCommits`, REST routes; **Decision-2 landed via #3066**: `publishPackageDrafts` runs every promotion + the commit record inside ONE `engine.transaction()` (two-phase — side effects post-commit), so a commit cannot half-land; `engine.transaction()` joins ambient transactions to make nested repository writes participate. Locked by `protocol-publish-package-drafts.test.ts` (all-or-nothing + rollback tracking) and `engine-ambient-transaction.test.ts`.
**Status**: Accepted (2026-06-24; completed 2026-07-16) · **Amended** (2026-08-06, #5351/#5696 — the D2 join is now distinguishable (`owned`), and Decision-2's atomicity is enforced as a ONE-datasource promise, with audit rows carved out and possibly orphaned; see the Amendment at the end) — fully implemented: commit grouping (`sys_metadata_commit`), `revertCommit`/`rollbackToPackageCommit`/`listCommits`, REST routes; **Decision-2 landed via #3066**: `publishPackageDrafts` runs every promotion + the commit record inside ONE `engine.transaction()` (two-phase — side effects post-commit), so a commit cannot half-land; `engine.transaction()` joins ambient transactions to make nested repository writes participate. Locked by `protocol-publish-package-drafts.test.ts` (all-or-nothing + rollback tracking) and `engine-ambient-transaction.test.ts`.
**Deciders**: ObjectStack Protocol Architects
**Builds on / amends**: [ADR-0045](./0045-additive-materialization-and-visibility-gate.md) (**amended**: ADR-0045 keeps a *draft + human-confirm* gate on mutations as the safety mechanism; this ADR replaces *confirm-before* with *revert-after* for everything except irreversible data loss, and unifies the two authoring regimes under one primitive — the commit), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (draft workspace — retained as a *review affordance*, demoted from *safety mechanism*), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) ("AI never publishes — it drafts" → **AI commits; commits are revertible**), [ADR-0034](./0034-transactional-writes-and-ambient-transaction.md) (per-write transaction — **extended to span a whole turn**), [ADR-0038](./0038-build-verification-loop.md) (machine gate — runs per commit, before it lands)
**Consumers**: `@objectstack/objectql` (commit grouping, atomic turn-apply, `revertCommit`, history query — built on the existing `sys_metadata_history` + `restoreVersion`), `@objectstack/runtime` + `@objectstack/rest` (commit/revert routes), `../cloud/service-ai-studio` (turn = commit; auto-commit policy; data-loss confirmation), `../objectui` (commit timeline + "revert to here")
Expand Down Expand Up @@ -160,3 +160,65 @@ Acceptance, browser-level: *build an app (commit 1) → ask the AI to change it
2. **Reverting an additive commit that holds real user data** → **allowed, tiered** (§Decision-5): silent when only sample data; typed-confirmation + auto-snapshot escape hatch when user-entered rows exist. Reversibility-by-recovery, not safety-by-prohibition — hard-blocking ("export first") is paternalistic and contradicts the friction-removal goal.
3. **Per-turn full-bundle snapshot** → **rejected** (Option B); `sys_metadata_history` is the commit substrate, `sys_package_version` is reserved for named restore points, cut in v1.1, not per turn.
4. **Does the draft workspace go away?** → **No, but it is demoted.** ADR-0027's draft + `?preview=draft` diff review stays as a *review affordance* (governed orgs, optional preview); it is no longer the *safety mechanism* for the default path. Revertibility is.

---

## Amendment (2026-08-06, #5351 / #5696) — the D2 join is now distinguishable, and "a commit cannot half-land" is scoped to one datasource

Decision-2 ("commits are atomic") and its join rule — `engine.transaction()`
JOINS an already-open ambient transaction so the outermost caller owns the one
and only commit/rollback — are both **unchanged and reaffirmed**. A nested
`begin` would take a second connection (a deadlock on the single-connection
SQLite pool) and would not be covered by the outer rollback, which is the exact
half-landing the join prevents. Two things around it are amended.

### The join is now distinguishable by the callback (#5696 point 3)

The join was correct but **silent**: a nested caller could not tell whether it
owned the transaction it was running in, and a helper whose own contract reads
"this all rolls back together" was making a promise it might not control. The
callback's second argument now carries `{ owned: boolean }` — `true` when this
call opened the transaction, `false` when it joined an outer one or ran on the
no-transaction degrade path. Nothing about who commits changed; only whether the
callback can find out.

### "A commit cannot half-land" is a promise about ONE datasource

Decision-2's atomicity was always scoped to the driver the transaction was
opened on — ADR-0119 D1 says so — but the engine did not enforce that scope, and
the gap was worse than the wording suggested: a write routed to another
datasource was handed the FIRST driver's transaction handle and executed on the
wrong connection entirely (measured in #5351; see ADR-0119's 2026-08-06
amendment for the full mechanism and evidence). Since that amendment:

- the handle never reaches a driver that does not own it;
- a **business** write that would cross drivers inside a transaction is
**refused** by name, so a metadata commit spanning two datasources fails
loudly at the first crossing instead of half-landing invisibly — which is
Decision-2's own goal, now actually enforced rather than assumed;
- **append-only system ledgers** (`lifecycle.class` audit / telemetry / event,
routed away by ADR-0057 §3.6) are **carved out**: executed outside the
transaction, and therefore surviving its rollback.

### Orphan rows are a deliberate consequence of the carve-out

A reverted commit — `revertCommit` / `rollbackToPackageCommit` — restores the
metadata, but the **audit rows written during the reverted work remain**. They
are not undone, and this record now says so rather than leaving it to be
discovered.

That is the correct direction of error here, and it fits this ADR's own design
center. History is append-only by construction — Decision-3 makes a revert a
*new forward commit* precisely so the record of what happened is never rewritten
— so an audit row describing work that was later undone is consistent with how
this ADR already treats history, not an exception to it. The alternative was
refusing the audit write, which an audit hook's `try/catch` turns straight back
into a dropped row; a spurious row is reconcilable against the commit history,
a missing row for a write that DID commit is not recoverable at all.

**Decided by**: the maintainer, 2026-08-06, on #5351 (plan A) and #5696.
**Mechanism, limits and evidence**: ADR-0119's amendment of the same date —
including the one path the same-origin gate declines to judge (handles the
engine never opened and cannot attribute). **Implemented in**
`packages/objectql/src/engine.ts`; pinned by
`engine-transaction-same-origin.test.ts`.
Loading
Loading