Skip to content

fix(storage): require an imported bundle's context to describe itself - #5196

Merged
M4n5ter merged 2 commits into
apache:mainfrom
Joob1n:fix/import-bundle-context-closure
Sep 12, 2026
Merged

fix(storage): require an imported bundle's context to describe itself#5196
M4n5ter merged 2 commits into
apache:mainfrom
Joob1n:fix/import-bundle-context-closure

Conversation

@Joob1n

@Joob1n Joob1n commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

The three paths @M4n5ter raised as non-blocking when approving #5186, under #5182.

A bundle's context could describe more than the bundle

validateContextSnapshot proves every payload is the bytes its row claims. It says nothing about who those rows belong to, and the archive digest authenticates the archive rather than the state inside it — so a bundle that was assembled rather than exported passes both checks while carrying either of:

  • A reference owned by a Session the bundle does not include. References are released when their Session is retired, and that Session never arrives, so the reference is permanent.
  • Collection state from the workspace it left. The export empties that queue on its private copy. One that survives names a blob the target now references, and collection treats a referenced candidate as corruption — it fails, and keeps failing.

A fresh target adopts the bundle's context database whole, so neither is transient. Both are refused before anything is written.

A fresh target received its context database by progressive copy

That destination is the path a Context Store opens to decide whether the workspace has a store at all, so a Store initialising alongside the copy could read a database that is only partly there. It is now staged and renamed: absent, or complete.

A payload path could be a symlink onto matching bytes

EEXIST at a content-addressed path is normally the same bytes arriving twice, and an ordinary read through a planted symlink to matching content says exactly that. But the Context Store will not read through a link — it reports the payload corrupt — so accepting it imports a tree the Store cannot use.

The comparison now opens no-follow and requires a regular file. A planted link is different content rather than the same content, and so is a directory, which previously surfaced as a raw EISDIR from the read instead of saying what was wrong.

Tests

4 new in the import suite: a reference naming a Session outside the bundle, a surviving collection candidate, a symlink at the payload path pointing at identical bytes, and a directory at the payload path.

Each was checked by reverting the implementation it covers — skipping the closure check, following symlinks in the comparison, and dropping the regular-file requirement — and each turns the matching test red.

Two notes from getting there, both about tests that looked like they worked and did not:

  • The first version of the foreign-reference fixture moved context_refs.session_id without moving the usage rows, so validateContextSnapshot caught the tampering first and the new guard was never reached. The fixture now keeps usage consistent, so it fails on the guard it is named after.
  • O_NOFOLLOW answers the symlink at open, which means the regular-file requirement is never what rejects a link. The directory case is what exercises it.

session-export, session-bundle-policy, session-bundle-context-fence, context-value-mutation-gate, context-offload-snapshot, sqlite-context-offload-store and production-session-snapshot are unchanged and green.

Gates: @maka/core, @maka/storage, @maka/runtime build and typecheck clean; biome check on both changed files; check:asf-headers passes. No schema change, no protocol epoch change.

@github-actions github-actions Bot added the effort/M Under 500 readable lines label Sep 11, 2026

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English

The normal exporter/importer path is mergeable. The remaining findings require a hand-built or tampered bundle, a narrow initialization race, or a pre-planted filesystem entry, so I consider them non-blocking follow-ups:

  1. Portable context closure is still not canonical. assertBundleContextClosure (packages/storage/src/session-bundle-policy.ts:1385) rejects foreign refs and GC candidates, but still accepts context_file_deletions, unreferenced context_blobs, and surplus context_session_usage. A fresh target adopts that database wholesale at line 1420, which can leave unreclaimable quota usage, corrupt physical-byte accounting when the deletion queue drains, or make later writes fail against forged session usage. copyContextSnapshot already removes these states. The simpler fix is to make validateContextSnapshot enforce the exact portable shape in both directions and remove the bundle-specific second validator.

  2. The fresh-database publication is atomic replacement, not atomic creation. The absence check followed by rename(staging, targetContext) at lines 1420-1428 can overwrite a database created by a concurrent same-lease Context Store initializer. On POSIX, the open Store remains attached to the now-unlinked inode while imports and later opens use the renamed inode, causing invisible writes and data loss after restart. Use no-replace publication and merge on EEXIST, or serialize Store construction on the same context mutation gate.

  3. The collision reader is not fully special-file and cross-platform safe. readRegularFile at line 1202 can block opening a FIFO on POSIX because it omits O_NONBLOCK; on Windows, O_NOFOLLOW does not provide the promised final-symlink protection. Reuse the repository's stable regular-file reader or add the equivalent lstat/fstat identity checks and non-blocking open.

中文

常规 exporter/importer 链路已经可以合入。剩余问题分别依赖手工构造或篡改的 bundle、很窄的初始化竞态,或预先占据目标路径,因此我建议作为不阻塞合入的 follow-up:

  1. Portable context 的闭包仍不完整。 assertBundleContextClosurepackages/storage/src/session-bundle-policy.ts:1385)只拒绝外部 Session 引用和 GC candidate,没有拒绝 context_file_deletions、无引用的 context_blobs,也没有发现多余的 context_session_usage。fresh target 会在第 1420 行直接采用整份数据库,后果可能是配额被永久占用、删除队列执行后 physical bytes 记账失真,或者伪造的 Session usage 让后续写入一直失败。copyContextSnapshot 本来就会清理这些状态;更简单的收口方式是让 validateContextSnapshot 双向校验唯一的 portable shape,再删掉 bundle 层的第二套 validator。

  2. fresh database 当前是“原子替换”,不是“原子创建”。 第 1420-1428 行先判断目标不存在,再执行 rename(staging, targetContext);如果同一 lease 下的 Context Store 恰好在复制期间完成初始化,POSIX rename 会覆盖它。已经打开的 Store 继续写入失去路径的旧 inode,import 和后续进程则读取新 inode,最终形成不可见写入,并在重启后丢失。这里应使用 no-replace publication,遇到 EEXIST 后转入 merge;或者让 Store 初始化也经过同一个 context mutation gate。

  3. 碰撞读取对特殊文件和 Windows 的处理还不完整。 第 1202 行的 readRegularFile 在 POSIX 上可能阻塞于 FIFO,因为没有 O_NONBLOCK;Windows 上的 O_NOFOLLOW 也不能提供注释承诺的 final-symlink 防护。建议复用仓库已有的 stable regular-file reader,或补齐非阻塞打开及 lstat/fstat 身份校验。

Three paths M4n5ter raised as non-blocking on apache#5186.

**A bundle's context could describe more than the bundle.** The snapshot
validator proves each payload is the bytes its row claims and says nothing
about who those rows belong to; the archive digest authenticates the
archive, not the state inside it. So a bundle that was assembled rather
than exported passes both while carrying a reference owned by a Session it
does not include -- which can never be released, because that happens when
its Session is retired -- or collection state from the workspace it left,
which names a blob the target now references and makes every later
collection fail. A fresh target adopts the bundle's database whole, so
neither is transient. Both are refused.

**A fresh target received its context database by progressive copy.**
That path is the one a Context Store opens to decide whether the workspace
has a store at all, so a Store initialising alongside the copy could read
a database only partly there. Staged and renamed, it is absent or
complete.

**A payload path could be a symlink onto matching bytes.** Compared
through an ordinary read it looked like the same content arriving twice,
and the import accepted a tree the Store will not read -- it refuses to
read through a link and reports the payload corrupt. The comparison now
opens no-follow and requires a regular file, so a planted link, or a
directory, is different content rather than the same content.

Refs apache#5182
@Joob1n
Joob1n force-pushed the fix/import-bundle-context-closure branch from d76ae78 to f2a7074 Compare September 11, 2026 16:14
Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 11, 2026
…reating

Three follow-ups M4n5ter raised on apache#5196.

**One validator, not two.** `validateContextSnapshot` checked payload
hashes and usage arithmetic; a second, bundle-only check added the
Session closure. Neither covered the transient state a snapshot settles,
so a tree could decode and still be unusable: a surviving deletion queue
drains bytes the target never had, an unreferenced blob is quota nothing
reclaims, and a surplus usage row fails that Session's next write. The
shape a snapshot writes is now stated in the one place that validates it,
including the Session restriction a bundle needs, and the second check is
gone -- it could only ever drift from the first.

**Publication creates; it never replaces.** Asking whether the context
database exists and branching on the answer is a decision that can be
stale by the time it is acted on: a Context Store initialising under the
same lease creates that file, and an import that already decided "absent"
replaced it. On POSIX the Store then keeps writing to the unlinked inode
while every later open reads the new one, so its writes are invisible and
gone at the next restart. There is now one publication path -- stage,
then `link` -- and the filesystem decides which case it is.

**The collision reader is the repository's.** `readStableBoundedFile` is
non-blocking, so a FIFO planted at a payload path cannot hang the import,
and it compares the opened file against the path, which is the final-link
check `O_NOFOLLOW` does not give on Windows.

Refs apache#5182

Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n

Joob1n commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

All three fixed in f2a7074f.

1. Portable closure. You were right that the second validator was the wrong shape of fix. validateContextSnapshot now states what a snapshot is — hashes, usage arithmetic in both directions, an empty collection queue, an empty deletion queue, no unreferenced blob — plus the Session restriction when a caller supplies the ids. assertBundleContextClosure is gone; a bundle-only check could only ever drift from the one that validates the export's own output.

Worth noting for the record: the other caller, validateOperationalStateBackup, validates the output of the same copyContextSnapshot, so tightening the shared check does not narrow it.

2. Creation, not replacement. The pathExists-then-branch shape was the actual defect: the answer can be stale by the time it is acted on. There is now one publication path — stage, then link — and the filesystem decides whether this is a fresh target or a merge. EEXIST means a Store got there first, and the import merges into it.

This also made the property testable. The race window version I wrote first could not land its injection reliably, and a test that silently takes the other path is worse than none; with a single path, "the target already has a database" exercises exactly the same branch.

3. Collision reader. Replaced with readStableBoundedFile: non-blocking, so a FIFO cannot hang the import, and it compares the opened file against the path, which is the protection O_NOFOLLOW does not give on Windows.

Tests: a bundle carrying an unreferenced payload, one carrying usage for a Session it does not hold, and a target whose existing context database keeps its own reference through the import. Each checked by reverting what it covers — allowing the orphan blob, allowing the surplus usage row, and rename instead of link; the last turns five tests red, because replacing the database loses everything the target had.

session-export, context-offload-snapshot, session-bundle-policy, session-bundle-context-fence and production-session-snapshot are unchanged and green. Rebased onto current main.

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@me2seeks me2seeks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 5196 Review

结论

APPROVE
把"快照形状即契约"的约束集中进 validateContextSnapshot(新参数 sessionIds),并用稳定的 no-follow 读取修掉 sameFileContent 的符号链接盲区、用 stage+link 消除 context 数据库的 TOCTOU 覆盖与半写窗口;6 个新回归测试与生产代码一一对应。

Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 11, 2026
…reating

Three follow-ups M4n5ter raised on apache#5196.

**One validator, not two.** `validateContextSnapshot` checked payload
hashes and usage arithmetic; a second, bundle-only check added the
Session closure. Neither covered the transient state a snapshot settles,
so a tree could decode and still be unusable: a surviving deletion queue
drains bytes the target never had, an unreferenced blob is quota nothing
reclaims, and a surplus usage row fails that Session's next write. The
shape a snapshot writes is now stated in the one place that validates it,
including the Session restriction a bundle needs, and the second check is
gone -- it could only ever drift from the first.

**Publication creates; it never replaces.** Asking whether the context
database exists and branching on the answer is a decision that can be
stale by the time it is acted on: a Context Store initialising under the
same lease creates that file, and an import that already decided "absent"
replaced it. On POSIX the Store then keeps writing to the unlinked inode
while every later open reads the new one, so its writes are invisible and
gone at the next restart. There is now one publication path -- stage,
then `link` -- and the filesystem decides which case it is.

**The collision reader is the repository's.** `readStableBoundedFile` is
non-blocking, so a FIFO planted at a payload path cannot hang the import,
and it compares the opened file against the path, which is the final-link
check `O_NOFOLLOW` does not give on Windows.

Refs apache#5182

Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1n force-pushed the fix/import-bundle-context-closure branch from f2a7074 to 097fcbc Compare September 11, 2026 19:49
@Joob1n

Joob1n commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Two of the three uncertainties were worth acting on, because both were my comments claiming more than the code gives:

The Windows claim was too strong. "the final-link check O_NOFOLLOW does not give on Windows" is wrong, and you are right about why: lstat there does not reliably report a file symlink as one, so the identity comparison cannot stand in for the flag. The comment now says what the reader actually gives on each platform — a mid-read identity change is caught everywhere, and the symlink refusal is O_NOFOLLOW on POSIX and only what the platform makes visible on Windows.

The asymmetry. Checked rather than assumed: copyContextValueTree reaches a payload only through entry.isFile() on a Dirent, and a symlink is isSymbolicLink() there, so left cannot be a link. That is now stated where the asymmetry is, rather than left to be rediscovered.

Comment hygiene — you were right, I had appended the new note without removing the one it replaced. One comment now.

No behaviour change; the 25 import tests and the gates are unchanged and green.

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English

The three findings from the previous review are fixed: the snapshot validator now enforces the missing database invariants, fresh-database publication no longer replaces a concurrent Store initializer, and collision reads use the stable bounded reader. I found two additional correctness gaps:

  1. Fresh context-database publication is atomic, but not durable. mergeBundleContextDatabase copies to staging and links context-offload.sqlite at packages/storage/src/session-bundle-policy.ts:1388-1397, but it syncs neither the copied inode nor the new directory entry before mergeBundleDatabase commits the imported Session at line 1009. After power loss, the Session can survive while its context database link or contents do not. The managed-payload path at lines 1570-1584 already has the required ordering: sync the staging file, link it, then sync the directory chain before publishing references. Apply the same barriers to the fresh database branch.

  2. Validation checks declared payloads, while import copies the entire values tree. validateContextSnapshot iterates context_blobs at packages/storage/src/context-offload-snapshot.ts:256-277, so it verifies every database locator but never rejects extra files. copyContextValueTree then recursively publishes every regular file under context-offload-values at packages/storage/src/session-bundle-policy.ts:1482-1504. A digest-valid hand-built bundle can therefore add undeclared bytes that are neither charged to usage nor reachable by GC. Either require the hydrated tree inventory to exactly match the managed-file locators, or publish only the locator set that the validator admitted.

中文

上次 review 的三项问题已经修复:snapshot validator 补齐了数据库不变量,fresh database 的发布不会再覆盖并发初始化的 Store,碰撞读取也改用了 stable bounded reader。复审中又发现两处 correctness 缺口:

  1. fresh context database 的发布具备原子性,但没有持久性保证。 packages/storage/src/session-bundle-policy.ts:1388-1397 先复制 staging 文件,再为它创建 context-offload.sqlite 硬链接;在第 1009 行提交导入的 Session 之前,却没有同步 staging inode,也没有同步新目录项。掉电后可能出现 Session 已经保留,但 context database 的链接或内容丢失。第 1570-1584 行的 managed payload 发布已经给出了正确顺序:先同步 staging 文件,再创建链接,最后同步目录链,然后才能发布引用。fresh database 分支也应使用同样的 barrier。

  2. 校验只覆盖数据库声明的 payload,导入却会复制整棵 values tree。 packages/storage/src/context-offload-snapshot.ts:256-277validateContextSnapshot 会遍历 context_blobs,因此能验证每个数据库 locator,却不会拒绝额外文件;随后 packages/storage/src/session-bundle-policy.ts:1482-1504copyContextValueTree 会递归发布 context-offload-values 下的所有普通文件。手工构造且 digest 合法的 bundle 因而可以夹带未声明的数据,这些字节既不计入 usage,也无法被 GC 找到。应让 hydrated tree 的文件清单与 managed-file locator 完全一致,或者只发布 validator 已确认的 locator 集合。

Joob1n added a commit to Joob1n/maka-agent that referenced this pull request Sep 12, 2026
…reating

Three follow-ups M4n5ter raised on apache#5196.

**One validator, not two.** `validateContextSnapshot` checked payload
hashes and usage arithmetic; a second, bundle-only check added the
Session closure. Neither covered the transient state a snapshot settles,
so a tree could decode and still be unusable: a surviving deletion queue
drains bytes the target never had, an unreferenced blob is quota nothing
reclaims, and a surplus usage row fails that Session's next write. The
shape a snapshot writes is now stated in the one place that validates it,
including the Session restriction a bundle needs, and the second check is
gone -- it could only ever drift from the first.

**Publication creates; it never replaces.** Asking whether the context
database exists and branching on the answer is a decision that can be
stale by the time it is acted on: a Context Store initialising under the
same lease creates that file, and an import that already decided "absent"
replaced it. On POSIX the Store then keeps writing to the unlinked inode
while every later open reads the new one, so its writes are invisible and
gone at the next restart. There is now one publication path -- stage,
then `link` -- and the filesystem decides which case it is.

**The collision reader is the repository's.** `readStableBoundedFile` is
non-blocking, so a FIFO planted at a payload path cannot hang the import,
and it compares the opened file against the path, which is the final-link
check `O_NOFOLLOW` does not give on Windows.

Refs apache#5182

Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1n force-pushed the fix/import-bundle-context-closure branch from 097fcbc to 62e18ae Compare September 12, 2026 01:50
@Joob1n

Joob1n commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed.

1. Durability. The staging file is synced before it is named and the directory chain after, matching the managed-payload ordering you pointed at. The Session rows commit later, so a loss between the two must not leave a Session whose context database is a name with nothing behind it — or no name at all.

2. Undeclared payloads. You identified the real asymmetry: the validator reads rows and the copy read the tree, so the two were never checking the same thing. The copy now publishes the locator set the database declares, and nothing else — a row whose bytes are absent is refused rather than half-published. Walking the tree was the wrong source of truth, not a missing check on top of it.

Tests: a bundle carrying bytes no row names, asserting they did not travel while the declared payload did; and a bundle declaring a payload it does not carry.

What is not covered: the durability barriers. A unit test cannot lose power, and asserting that fsync was called would test the call rather than the property. It rests on matching the ordering the payload path already uses.

Reverting the declared-only publication turns its test red. session-export, context-offload-snapshot, session-bundle-policy, session-bundle-context-fence and production-session-snapshot are unchanged and green; 27 import tests pass.

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@Joob1n
Joob1n requested a review from M4n5ter September 12, 2026 02:40

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English

The two findings from the previous review are fixed: fresh database publication now has the required durability ordering, and only database-declared payloads are copied. One important import gap remains:

  1. A WAL-backed context database can pass validation and then be published without the rows that were validated. validateContextSnapshot opens the hydrated context-offload.sqlite normally at packages/storage/src/context-offload-snapshot.ts:243-277, so SQLite includes committed state from an adjacent -wal/-shm; copyDeclaredContextValues likewise reads the WAL-visible locator set at packages/storage/src/session-bundle-policy.ts:1502-1513. On a fresh target, however, mergeBundleContextDatabase copies and links only the main database file at lines 1382-1405. A canonical hand-built bundle can therefore put its imported Session's context refs only in the WAL: validation and payload publication succeed, the fresh target receives the older checkpoint, and the runtime Session commits without those refs. I reproduced the underlying behavior directly: the read-only source connection saw base plus a committed wal-only row, while a raw copy of the main file saw only base. Require the portable database to be a standalone DELETE-journal snapshot with no sidecars before validation, or use SQLite backup to create the file that is published.
中文

上次 review 的两项问题已经修复:fresh database 发布补齐了持久性顺序,payload 也只会按数据库声明的 locator 复制。当前还剩一处重要的导入缺口:

  1. 依赖 WAL 的 context database 可以通过校验,但最终发布时丢掉刚刚校验过的行。 packages/storage/src/context-offload-snapshot.ts:243-277 的 validateContextSnapshot 会正常打开 hydrated context-offload.sqlite,因此 SQLite 会读取相邻 -wal/-shm 中已经提交的状态;packages/storage/src/session-bundle-policy.ts:1502-1513 的 copyDeclaredContextValues 也会看到 WAL 中的 locator。可是 fresh target 分支在第 1382-1405 行只复制并链接主数据库文件。手工构造且 canonical 的 bundle 因而可以把待导入 Session 的 context refs 只放在 WAL:校验和 payload 发布都会成功,fresh target 得到的却是旧 checkpoint,随后 runtime Session 在没有这些 refs 的情况下提交。我直接复现了这个底层行为:只读源连接能看到 base 和已提交的 wal-only 行,裸复制主文件后只能看到 base。应在校验前要求 portable database 是没有 sidecar 的独立 DELETE-journal snapshot,或者通过 SQLite backup 生成最终要发布的文件。

…reating

Three follow-ups M4n5ter raised on apache#5196.

**One validator, not two.** `validateContextSnapshot` checked payload
hashes and usage arithmetic; a second, bundle-only check added the
Session closure. Neither covered the transient state a snapshot settles,
so a tree could decode and still be unusable: a surviving deletion queue
drains bytes the target never had, an unreferenced blob is quota nothing
reclaims, and a surplus usage row fails that Session's next write. The
shape a snapshot writes is now stated in the one place that validates it,
including the Session restriction a bundle needs, and the second check is
gone -- it could only ever drift from the first.

**Publication creates; it never replaces.** Asking whether the context
database exists and branching on the answer is a decision that can be
stale by the time it is acted on: a Context Store initialising under the
same lease creates that file, and an import that already decided "absent"
replaced it. On POSIX the Store then keeps writing to the unlinked inode
while every later open reads the new one, so its writes are invisible and
gone at the next restart. There is now one publication path -- stage,
then `link` -- and the filesystem decides which case it is.

**The collision reader is the repository's.** `readStableBoundedFile` is
non-blocking, so a FIFO planted at a payload path cannot hang the import,
and it compares the opened file against the path, which is the final-link
check `O_NOFOLLOW` does not give on Windows.

Refs apache#5182

Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1n force-pushed the fix/import-bundle-context-closure branch from 62e18ae to e744187 Compare September 12, 2026 03:03
@github-actions github-actions Bot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 12, 2026

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English

Approved. The normal exporter/importer path is sound, and both findings from the previous round are fixed.

One uncommon case remains as a non-blocking follow-up: validateContextSnapshot and copyDeclaredContextValues open a hydrated context-offload.sqlite normally, so they can see committed rows from adjacent WAL sidecars; fresh-target publication then copies only the main file. A canonical hand-built or tampered bundle can therefore validate WAL-only refs and lose them when the main database is published. Normal bundles cannot produce this state because copyContextSnapshot emits a standalone DELETE-journal database. A follow-up should reject context sidecars / non-DELETE snapshots or publish from a SQLite backup.

中文

同意合入。常规 exporter/importer 链路已经可靠,上一轮两项问题也都修复了。

还有一个低频场景可作为不阻塞合入的 follow-up:validateContextSnapshot 与 copyDeclaredContextValues 会正常打开 hydrated context-offload.sqlite,因此可能读取相邻 WAL sidecar 中已经提交的行;fresh-target 发布随后却只复制主文件。手工构造或篡改且 canonical 的 bundle 因而可能让 WAL-only refs 通过校验,最终发布主库时再丢失。正常 bundle 不会产生这种状态,因为 copyContextSnapshot 输出的是独立 DELETE-journal database。后续可拒绝 context sidecar / 非 DELETE snapshot,或改用 SQLite backup 生成发布文件。

@M4n5ter
M4n5ter merged commit 9928de5 into apache:main Sep 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/L Under 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants