fix(isolation): reinspect published results before settlement

Problem: a Store could apply an old cached Git tree after another Store had already applied or discarded that result, resurrecting its bytes in the source workspace.

Root cause: cache hits skipped durable publication and candidate admission; Git objects survive removal of the worktree. Refreshing without comparing the previous observation would also silently widen an earlier path declaration.

Fix: always reuse durable recovery/inspection for lookups, keep finished caches out of Pending, and refuse changed baseline/candidate/target trees until fresh path admission. Preserve first-Finish live-only and unsupported-version checks. No persisted, permission, tool-definition or default changes. Cross-process settlement locking and journals remain separate.

Verification: final complete 238-package root inventory passes (217 pass/21 no tests;23310 test/subtest passes,215 existing skips,194 valid cached packages). Full isolation race52 passes and real boot focus31 passes with no skips. The accepted predecessor Store compiles and fails9 behavior events. Gofmt, vet, full lint, make check and diff checks pass. Additional required builtin/boot command is running; new hosted/native evidence remains pending.
This commit is contained in:
SivanCola committed 2026-10-25 10:20:29 +08:00
1 parent e59262c519
commit 749e691863
4 files changed
+218 -11

No files matched your search

@@ -0,0 +1,82 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-25
---
# Result reinspection / 隔离结果重新检查
## Cause / 根因
Finished results were also cached as Git tree identities in each Store. A
second Store could settle the result while the first still trusted its cache.
Git objects survive worktree removal, so a discarded result could still be
applied again from that stale tree. No concurrent interleaving is necessary.
每个 Store 缓存成果的 Git tree。另一个 Store 应用或丢弃成果后,旧缓存仍被当作
操作依据。Git 对象不会随 worktree 删除,旧 tree 因此仍可再次写回主工作区。
顺序操作即可复现,不依赖竞态时序。
## Owner and authority / 所有者与权限
All result lookups now use the existing durable discovery, rooted record
admission and candidate inspector. In-memory trees no longer authorize a
settlement or supply missing results to Pending.
Missing or malformed publication cannot be bypassed by a previously populated
Store. Refusal does not delete an unrecognized record or its candidate bytes.
Unknown JSON fields retain the existing no-rewrite behavior.
所有结果读取重新经过既有持久发现、目录身份准入和候选检查,不再由内存 tree
授权操作。Pending 也不能靠缓存重新加入已不存在的成果。
缺失或损坏的持久记录不能被已有缓存绕过。拒绝操作不会删除不识别的记录或候选,
未知字段沿用原不重写行为。
## Path admission / 路径准入
Reinspection must not silently widen an earlier path declaration. If the
inspected baseline, candidate identity or target tree differs from the Store's
previous observation, this operation refuses and requires fresh path admission.
The refreshed observation is available for a subsequent operation.
The tool regression declares one path, adds another file to the candidate,
and verifies that Execute refuses without changing the source. Declaring paths
again sees both files; the subsequent execution applies their current content.
重新检查不能静默扩大旧路径声明。基线、候选身份或目标 tree 发生变化时,本次操作
拒绝并要求重新检查路径。工具回归先声明一个文件,再向候选增加文件,确认执行拒绝且
源文件不变;重新准入后声明两个文件并应用当前内容。
## Compatibility and verification / 兼容与验证
No persisted format, tool definition, permission, configuration, default or
provider prefix changes. The existing record inspector remains authoritative;
there is no second JSON decoder or new directory authorization mechanism.
The regression uses real Git worktrees and two public Store instances for
apply/discard followed by stale paths/apply/discard. A deliberately malformed
record is separately labelled as external metadata replacement.
Each lookup now pays the existing inspector's cost. No performance improvement
or native-platform result is inferred from local tests. Complete-tree and
platform results must identify their tested source separately.
不改持久格式、工具定义、权限、配置、默认值或 provider 前缀,也不新增 JSON 解析器。
回归使用真实 Git 与两个 Store,另将人为篡改记录明确区分于正常执行。
每次读取增加既有检查器的成本;本地通过不能推断性能改善或其他原生平台通过。
## Remaining limits / 尚未闭合的边界
- Reinspection is not a cross-process settlement lock. A writer can still race
after inspection; apply/discard serialization and journals remain separate.
- Git's actual-byte snapshot fidelity remains the independent snapshot-content
repair. This change does not upgrade a Git tree to a complete content receipt.
- Failed-first-Finish recovery, running identity, safe expiration, frozen
verification and automatic integration retain their own review units.
- This does not authenticate local state against an arbitrary hostile process
or sandbox external writers. Author analysis is not independent approval.
本批不完成跨进程整合锁、日志、实际字节快照、完整运行身份、保留策略、冻结验证或
自动整合。检查后的外部竞争仍存在,作者分析不能替代独立审查。
@@ -0,0 +1,120 @@
package isolation
import (
"path/filepath"
"slices"
"testing"
"reasonix/internal/base/testenv"
)
func TestCachedResultCannotResurrectAfterAnotherStoreSettles(t *testing.T) {
for _, settlement := range []string{"apply", "discard"} {
for _, staleOperation := range []string{"paths", "apply", "discard"} {
t.Run(settlement+"/"+staleOperation, func(t *testing.T) {
repo, managed := gitRepo(t), testenv.TempDir(t)
producer := NewStore(managed, repo, opened(t, repo))
report, err := producer.Execute(t.Context(), writes(map[string]string{"a.txt": "candidate\n"}), repo, "change a", "")
if err != nil {
t.Fatal(err)
}
id := idOf(t, report)
cached := NewStore(managed, repo, opened(t, repo))
if paths, err := cached.Paths(id); err != nil || len(paths) != 1 {
t.Fatalf("prime result: %v, %v", paths, err)
}
if settlement == "apply" {
_, err = producer.Apply(t.Context(), id)
} else {
_, err = producer.Discard(t.Context(), id)
}
if err != nil {
t.Fatal(err)
}
if slices.Contains(cached.Pending(), id) {
t.Error("settled result offered before another cache lookup")
}
// Return the source to the original baseline after settlement. A
// stale cached tree must not authorize writing the result again.
write(t, repo, "a.txt", "one\n")
switch staleOperation {
case "paths":
_, err = cached.Paths(id)
case "apply":
_, err = cached.Apply(t.Context(), id)
case "discard":
_, err = cached.Discard(t.Context(), id)
}
if err == nil {
t.Errorf("cached %s still authorized after %s", staleOperation, settlement)
}
if got := read(t, repo, "a.txt"); got != "one\n" {
t.Errorf("settled candidate resurrected into source: %q", got)
}
if slices.Contains(cached.Pending(), id) {
t.Error("settled result still offered from stale cache")
}
})
}
}
}
func TestCachedResultRechecksPublishedIdentity(t *testing.T) {
repo, managed := gitRepo(t), testenv.TempDir(t)
store := NewStore(managed, repo, opened(t, repo))
report, err := store.Execute(t.Context(), writes(map[string]string{"a.txt": "candidate\n"}), repo, "change a", "")
if err != nil {
t.Fatal(err)
}
id := idOf(t, report)
entry, err := store.lookup(id)
if err != nil {
t.Fatal(err)
}
// Model external record replacement explicitly; normal writers do not
// produce this deliberately malformed identity.
write(t, filepath.Dir(entry.Cand.WorktreeRoot), entryFile, `{"id":"another-result"}`)
if _, err := store.Apply(t.Context(), id); err == nil {
t.Error("cached result bypassed changed durable identity")
}
if got := read(t, repo, "a.txt"); got != "one\n" {
t.Errorf("unbound cached identity wrote source: %q", got)
}
if got := read(t, entry.Cand.WorkspaceRoot, "a.txt"); got != "candidate\n" {
t.Errorf("refused cached identity lost candidate bytes: %q", got)
}
}
func TestCachedResultChangeRequiresFreshPathAdmission(t *testing.T) {
repo, managed := gitRepo(t), testenv.TempDir(t)
store := NewStore(managed, repo, opened(t, repo))
report, err := store.Execute(t.Context(), writes(map[string]string{"a.txt": "candidate\n"}), repo, "change a", "")
if err != nil {
t.Fatal(err)
}
id := idOf(t, report)
apply := NewApplyTool(store)
if paths, err := apply.DeclaredWritePaths(t.Context(), args(id)); err != nil || len(paths) != 1 {
t.Fatalf("initial path admission: %v, %v", paths, err)
}
entry, err := store.lookup(id)
if err != nil {
t.Fatal(err)
}
write(t, entry.Cand.WorkspaceRoot, "extra.txt", "added after admission\n")
if _, err := apply.Execute(t.Context(), args(id)); err == nil {
t.Fatal("changed candidate applied under its previous path admission")
}
if got := read(t, repo, "a.txt"); got != "one\n" {
t.Errorf("refused candidate changed source: %q", got)
}
if paths, err := apply.DeclaredWritePaths(t.Context(), args(id)); err != nil || len(paths) != 2 {
t.Fatalf("fresh path admission: %v, %v", paths, err)
}
if _, err := apply.Execute(t.Context(), args(id)); err != nil {
t.Fatal(err)
}
if got := read(t, repo, "extra.txt"); got != "added after admission\n" {
t.Errorf("readmitted result lost new content: %q", got)
}
}
+10 -11
View File
@@ -156,11 +156,6 @@ func (s *Store) Pending() []string {
}
ids := s.durableIDs()
s.mu.Lock()
for id := range s.entries {
if !slices.Contains(ids, id) {
ids = append(ids, id)
}
}
for id := range s.uninspected {
if !slices.Contains(ids, id) {
ids = append(ids, id)
@@ -190,13 +185,17 @@ func (s *Store) lookup(id string) (*Entry, error) {
return nil, tool.Refusal{Code: CodeUnavailable, Message: fmt.Sprintf("record isolated result %s; files retained: %v", id, err)}
}
}
if !ok {
recovered, err := s.recover(id)
if err != nil {
return nil, tool.Refusal{Code: CodeUnavailable, Message: err.Error()}
}
e, ok = recovered, recovered != nil
// Git objects outlive settled worktrees. Reinspect durable identity and
// content rather than treating a cached tree as settlement authority.
recovered, err := s.recover(id)
if err != nil {
return nil, tool.Refusal{Code: CodeUnavailable, Message: err.Error()}
}
if e != nil && recovered != nil && (e.Snap.Commit != recovered.Snap.Commit || e.Snap.Tree != recovered.Snap.Tree ||
e.Snap.Prefix != recovered.Snap.Prefix || e.Cand.WorktreeRoot != recovered.Cand.WorktreeRoot || e.Tree != recovered.Tree) {
return nil, tool.Refusal{Code: CodeUnavailable, Message: fmt.Sprintf("isolated result %s changed since it was inspected; inspect its paths again before settlement", id)}
}
e, ok = recovered, recovered != nil
if !ok {
pending := s.Pending()
known := "none are pending"
@@ -0,0 +1,6 @@
## 修复
- 隔离成果在其他会话应用或丢弃后,旧会话不能再靠缓存写回;候选发生变化时,
要求重新检查路径后再应用。Refs #14025
- Reinspect isolated results before settlement so stale sessions cannot revive
settled work. Changed candidates require fresh path admission. Refs #14025