Conversation
…ineage root digest cdcd630 made the shadow-management journal carry the canonical (realpath) digest of the runtime root so promotion survives runtime aliases, and taught the TypeScript reader and drain to compare against that binding digest. Three readers and writers still hashed their own lexical spelling of the root: the Python primary-write guard (`read_shadow_management_state`), the Python todo outbox writer, the TypeScript lease outbox writer, and the Python drain's receipt history. On a root reached through a symlink, which is every macOS temp directory, the guard rejected the journal the owner had just written (`shadow_management_state_invalid`) and, once past the guard, the drain refused every entry (`source_root_mismatch`). Every `s2c2` ladder row failed on macOS while Linux CI never saw it (loopx-project#4892). The guard now accepts the same digest set the TypeScript reader accepts, the lexical and the canonical spelling, and still rejects an unrelated root. Both outbox writers stamp entries with the binding's `source_root_digest` instead of re-deriving it, and the drain compares receipts against the binding's digest, so every artifact of a lineage carries the digest the owner wrote. `runtime_root_digest` remains for diagnostics and says so. Regression tests: the guard on a symlinked root (both spellings accepted, a foreign root refused; the TypeScript bootstrap read through the symlink), the Python capture and drain through a symlinked runtime root, the TypeScript lease writer through a symlinked root, and the `s2c2` outbox ladder row with a symlinked ladder root. All four fail on the parent commit. Fixes loopx-project#4892 Signed-off-by: wchwawa <wch19961116@gmail.com>
|
I independently opened #4896 for #4892 a few minutes before this PR appeared; the production changes otherwise converge on the same four call sites. One semantic difference should be resolved before choosing a branch. This PR changes The narrower check is: first validate the persisted journal digest against the lexical/canonical set, then validate the binding against that exact persisted digest. #4896 keeps this invariant. Your explicit symlinked ladder regression is useful additional coverage; whichever PR the maintainer chooses, I recommend combining that coverage with the strict state/binding equality. |
huangruiteng
left a comment
There was a problem hiding this comment.
动机
我按 exact head 51513cb1a0d3cf806a99e763633a94177710484b 审阅了 #4892、完整 base-to-head diff、相邻实现 #4896,以及 Python/TypeScript 的 persisted-state reader、两个 outbox writer 和 drain。PR 解决的是一个真实且高频的 macOS 问题:默认 TMPDIR 含 symlink,TypeScript owner 写 canonical root digest,而 Python 旧路径重新计算 lexical digest,导致全部 11 个 Stage 2C2 row 在 guard 或 drain 处失败。
这个目标与四处生产修改是匹配的,正常 symlink journey 也确实恢复了;但 Python reader 同时把既有 state↔binding identity invariant 放宽了,形成同一个持久状态在 Python 可写、TypeScript 不可读的分叉,因此当前 head 不能合入。
改动思路
正确的架构流应是:runtime root 的 lexical/canonical 两种 spelling 只用于判定“journal 顶层 digest 是否属于同一物理 root”;一旦顶层 state.source_root_digest 被验证,它就是该 journal 的 lineage identity,binding.source_root_digest、outbox entry 和 receipt 都必须精确携带这个值。
本 PR 后半段做对了:Python Todo writer、TypeScript lease writer 与 Python drainer 都改为复用 active binding digest,不再各自从当前路径 spelling 推导。问题出在 Python decoder 的前半段:_binding 也接受整个 root_digests set,于是顶层为 lexical、binding 为 canonical(或反向)会被当成合法;TypeScript decodeState 仍坚持 binding.source_root_digest === state.source_root_digest。相邻 PR #4896 已保留这个既有规则,说明无需引入新的更宽状态契约。
具体改动
关键代码讲解
shadow_source_root_digests(shadow_management.py:61):新增 lexical/canonical digest set,正确对齐 TypeScriptsourceRootDigests;realpath失败时回退 lexical,且没有副作用。_binding/read_shadow_management_state(shadow_management.py:84-138):当前分别校验 state 与 binding 都“属于 set”,但没有要求两者相等。这正是 blocker;最小改法是顶层做 set admission,然后把已验证的state.source_root_digest单值传给_binding。TodoPartitionCapture.__enter__(local_authority_shadow_outbox.py:873)和beginLeaseOutboxEntry(.ts:295):entries 改用 binding digest 计算 identity 与 record 字段,修复 symlink spelling 漂移;这部分方向正确。_PartitionDrainer._partition_history(local_authority_shadow_adapter.py:560):receipt history 改与 binding digest 比较,保持 capture/drain 同一 lineage;任何 sequence/lineage/root/entry mismatch 仍 fail closed。
整个 PR 是 8 文件 +219/-17,其中 4 个生产模块、4 个测试模块;没有新 schema、CLI 或 provider routing。正常路径验证:Python 相关 suite 66 passed, 8 skipped;固定 .venv/bin 为子进程解释器后,TypeScript 两个 suite 46/46 passed。
对主干的风险
[P1] Python 接受 TypeScript 明确拒绝的 mixed-digest journal。 我独立构造了同一 physical root 下 state.source_root_digest=lexical、binding.source_root_digest=canonical 的 active journal:
- Python
require_shadow_primary_write_allowed返回 binding(accepted=true); - TypeScript
readShadowManagementState对完全相同的文件返回shadow_management_state_invalid。
这意味着 Python 可能在跨语言非法 state 上继续 primary write/capture,而 TypeScript restart/readback 随后 fail closed。现有正向测试之所以没发现,是因为它每次让 state 与 binding 使用相同 digest,只覆盖“两种 spelling 都合法”,没有覆盖“两字段混用仍非法”。
最小修复:保留 shadow_source_root_digests(runtime_root) 对顶层的 lexical/canonical admission;把 _binding 恢复为接收单个、已验证的 journal digest,并要求 exact equality。增加正反两个 mixed-digest negative case,断言 Python 与 TypeScript 都拒绝且 state bytes 不变;随后重跑 test_shadow_management.py、TypeScript shadow_management.test.ts 和一条真实 Stage 2C2 row。
语义与 CI 对齐
这不是新增 vocabulary,而是复用既有 source_root_digest contract。当前 Python 放宽违反 TypeScript decoder 和 persisted lineage 的现行语义;远端 CI 全绿也不能替代这个未覆盖反例。packet 明确 wait_for_ci=false,本结论来自 exact-head 本地真实 filesystem/Node/Python 证据,而不是 CI 状态。
我的整体评价
结论是 REQUEST_CHANGES。PR 的主体修复、active callers、代码规模和正常 symlink 验证都扎实;我不建议回退 writer/drain 使用 binding digest 的改动,也不要求扩大 scope。只需要收紧 decoder 的一个 invariant 并补一个高价值负向用例。future-facing 检查同样指向这个小修:让 alias equivalence 只有一个 owner,让 state↔binding equality 继续由跨语言 decoder 共同拥有,而不是留下两个可漂移的接受集合。
English verdict: REQUEST_CHANGES - exact head 51513cb1a0d3cf806a99e763633a94177710484b fixes the normal symlinked-root flow (Python 66 passed/8 skipped; TypeScript 46/46 passed) but Python accepts a mixed journal/binding root digest that TypeScript rejects; restore exact state-to-binding equality and add the negative parity test.
Goal And Delivered Outcome
cdcd63080the shadow-management journal carries the canonical (realpathSync) digest of the runtime root and the TypeScript reader and drain compare against that binding digest, so promotion survives runtime aliases. Four places still hashed their own lexical spelling of the root: the Python primary-write guard (read_shadow_management_state), the Python todo outbox writer, the TypeScript lease outbox writer, and the Python drain's receipt history. On any root reached through a symlink, which is every macOS temp directory (/tmp,$TMPDIR), the guard rejected the journal the owner had just written and, once past the guard, the drain refused every entry. All elevens2c2ladder rows failed on macOS; Linux CI never exercised a symlinked root.python -m loopx.control_plane.testing.authority_e2e_ladder --stage 2c2under macOS's defaultTMPDIRreports 0 of 11 rows passing (shadow_management_state_invalid, thensource_root_mismatchat the drain once the guard alone is fixed). After, the same command reports 11 of 11 (real_entrypointrow). The four new regression tests fail on the parent commit and pass on this head (regression_parityrow).main.Scope And Continuation
source_root_digestinstead of re-deriving it; the drain compares receipts against the binding's digest.runtime_root_digeststays for diagnostics and its docstring now says so. No schema, file layout, reason code or CLI surface changes; journals and entries written before this change stay readable (a lexical binding digest is still in the accepted set, and entries already carry whatever digest the binding held when they were written).Validation
51513cb1a0d3cf806a99e763633a94177710484bregression_paritypassedtest_shadow_management.py×2,test_local_authority_shadow_outbox.py,test_shared_goal_authority_e2e.py) run against the parent commit's production code: 4 failed; the new TypeScript test (local_authority_shadow_outbox.test.ts) against the parent: 1 failed. All pass on this head.real_entrypointpassed--stage 2c2under macOS's default symlinkedTMPDIR, Node 22.22.3: parent commit 0/11 pass; with only the guard fixed 0/11 (all rows now fail at the drain,must deliver its outbox entry); this head 11/11 pass,s2c2.sustained_parity_soakpending as declared, privacy scan 0. Also reproduced and fixed with a self-made symlink outside/var, so the cause is the symlink, not the macOS temp location.unitpassedpytest tests/control_plane/test_shadow_management.py tests/control_plane/test_local_authority_shadow_outbox.py tests/control_plane/test_local_authority_shadow_drain.pyplus the new ladder-root test: 42 passed.unitpassedtest_shadow_drain_e2e,test_shadow_management_e2e,test_shadow_management_variant_e2e,test_shadow_native_todo_update_e2e,test_runtime_shadow_bounded_e2e,test_local_authority_shadow_outbox,test_local_authority_shadow_drain,test_shadow_management): 105 passed in 4 min 59 s, 0 failed, 0 skipped.unitpassedlocal_authority_shadow_outbox.test.ts+shadow_management.test.ts: 46 pass, 0 fail. Fulltests/control_plane_ts/*.test.ts: 2366 tests, 2352 pass, 8 skipped, 6 fail; all 6 failures are pre-existing and reproduce identically on unmodifiedmain(c4314d9b0) with the same runtime (two PostgreSQL integration files that need a live database,goal_acceptance_authority,goal_acceptance_runtime, and one assertion each inmonitor_successorandquota_monitor_poll_commit), none of them touching the shadow, outbox or drain code.staticpassedruff checkon the changed Python;python -m mypy(pyproject file list, 22 files, no issues;local_authority_shadow_outbox.pyis in that list);git diff --checkclean;loopx canary premerge --from-git-diff --git-diff-base origin/main: gate passed, 0 failures, 0 manual holds.See validation disclosure guidance.
Frontend / Visual Evidence
Type of Change
LoopX Area
Technical Direction
cdcd63080across the Python guard, both outbox writers and the drain.Shared-authority RFC fixture impact
outboxEntryIdentity(goal, partition, seq, source_ref, capture_lineage_id, source_root_digest); only the value ofsource_root_digestan entry carries changes on symlinked roots (the binding's digest instead of the writer's spelling), which is what the drain already required.Boundary Checklist
.loopx/,.codex/goals/, and liveACTIVE_GOAL_STATE.md).none.Signed-off-bytrailer (git commit -s).