Skip to content

fix(coordination): bind shadow entries and the primary guard to the lineage root digest - #4898

Open
wchwawa wants to merge 1 commit into
loopx-project:mainfrom
wchwawa:fix/shadow-management-symlinked-root-digest
Open

wchwawa wants to merge 1 commit into
loopx-project:mainfrom
wchwawa:fix/shadow-management-symlinked-root-digest

Conversation

@wchwawa

@wchwawa wchwawa commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Goal And Delivered Outcome

Scope And Continuation

  • Completed scope and remaining work: Complete within this scope. The guard accepts the same digest set the TypeScript reader accepts (lexical and canonical spelling) and still rejects a foreign root; both outbox writers stamp entries with the binding's source_root_digest instead of re-deriving it; the drain compares receipts against the binding's digest. runtime_root_digest stays 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).
  • Slice boundary / successor: N/A with reason: one defect, one cause, reversible by revert.

Validation

  • Tested revision: 51513cb1a0d3cf806a99e763633a94177710484b
  • Run state: finished
  • Input classes: synthetic
Check kind Result Public-safe evidence / limitation
regression_parity passed Fail-before: the four new Python-side tests (test_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_entrypoint passed Ladder --stage 2c2 under macOS's default symlinked TMPDIR, 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_soak pending 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.
unit passed pytest tests/control_plane/test_shadow_management.py tests/control_plane/test_local_authority_shadow_outbox.py tests/control_plane/test_local_authority_shadow_drain.py plus the new ladder-root test: 42 passed.
unit passed Every shadow, outbox, drain and runtime-shadow Python test file (test_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.
unit passed Node 22.22.3: local_authority_shadow_outbox.test.ts + shadow_management.test.ts: 46 pass, 0 fail. Full tests/control_plane_ts/*.test.ts: 2366 tests, 2352 pass, 8 skipped, 6 fail; all 6 failures are pre-existing and reproduce identically on unmodified main (c4314d9b0) with the same runtime (two PostgreSQL integration files that need a live database, goal_acceptance_authority, goal_acceptance_runtime, and one assertion each in monitor_successor and quota_monitor_poll_commit), none of them touching the shadow, outbox or drain code.
static passed ruff check on the changed Python; python -m mypy (pyproject file list, 22 files, no issues; local_authority_shadow_outbox.py is in that list); git diff --check clean; loopx canary premerge --from-git-diff --git-diff-base origin/main: gate passed, 0 failures, 0 manual holds.
  • Coverage and gaps: The changed lines are the guard's digest set, the two writers' digest source and the drain's comparison; each has a symlinked-root test on its own layer (guard unit, capture+drain unit, TypeScript writer unit, ladder row end to end). Not covered: Windows junctions (no runner available; the change only widens acceptance there), and the ten-day soak row (declared pending).

See validation disclosure guidance.

Frontend / Visual Evidence

  • UI impact: none
  • Before: N/A
  • After: N/A
  • States and viewports shown: N/A
  • Source data: none

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Refactoring (no functional changes)
  • Documentation update
  • Test update

LoopX Area

  • Control plane (goals, todos, quota, scheduler, registry, runtime)
  • Benchmark boundary (adapters, runners, verifiers, scoring, evidence)
  • Capability or extension (providers, adapters, skills)
  • Public docs or presentation surface (README, protocols, dashboard)
  • Build, packaging, installer, or CI
  • Host or runtime integration

Technical Direction

  • Direction / acceptance reference, when applicable: Core control-plane hardening (shadow capture and drain lineage integrity); completes the runtime-alias intent of cdcd63080 across the Python guard, both outbox writers and the drain.

Shared-authority RFC fixture impact

  • Production-scale fixture schema: unchanged.
  • Semantic dimensions changed, or reviewed no-impact rationale: none. Entry identity is still outboxEntryIdentity(goal, partition, seq, source_ref, capture_lineage_id, source_root_digest); only the value of source_root_digest an entry carries changes on symlinked roots (the binding's digest instead of the writer's spelling), which is what the drain already required.
  • Provider conformance arms run: file arm through the Python and TypeScript shadow suites above; NoKV, SQLite and PostgreSQL arms are untouched by this diff.
  • Read-only legacy/file/PostgreSQL three-arm rehearsal (required for promotion, runtime-routing, or compatibility-projection changes): N/A, no promotion, routing or compatibility-projection change.

Boundary Checklist

  • Neither the diff nor this PR body/comments/attachments disclose private state, credentials, raw traces or verifier output, internal links, or local machine paths (including .loopx/, .codex/goals/, and live ACTIVE_GOAL_STATE.md).
  • I did not duplicate maintainer-owned benchmark work unless a maintainer split out a public issue for it.
  • I kept the change scoped to the linked issue/task.
  • I completed the visual evidence section for UI changes, or marked UI impact none.
  • Every commit includes a DCO Signed-off-by trailer (git commit -s).

…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>
@Duang777

Copy link
Copy Markdown
Contributor

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 _binding to accept any member of root_digests. That allows the journal's top-level source_root_digest to be lexical while binding.source_root_digest is canonical (or vice versa). I verified that exact mismatched state is accepted on 51513cb1a. The TypeScript decodeState still requires binding.source_root_digest === state.source_root_digest, because the binding is part of the persisted lineage identity.

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 huangruiteng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

动机

我按 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_digestsshadow_management.py:61):新增 lexical/canonical digest set,正确对齐 TypeScript sourceRootDigestsrealpath 失败时回退 lexical,且没有副作用。
  • _binding / read_shadow_management_stateshadow_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_historylocal_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=lexicalbinding.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.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

3 participants