diff --git a/docs/reference/required-work-context.md b/docs/reference/required-work-context.md index d6e9035cc5..c5fc23234c 100644 --- a/docs/reference/required-work-context.md +++ b/docs/reference/required-work-context.md @@ -196,14 +196,21 @@ marked `required=false` defer their guidance/templates; their routes stay visibl and the complete captured attachment must be read before use. Unknown plan versions remain whole. Initial pages do not repeat pointers already present in their content; the selection digest still prevents a full-input receipt. -Its `context-read --initial-read` uses the same exact-address selector; paged -continuations retain the short option and pin the selection digest. A changed -profile rejects rather than mixing views. Initial navigation can replace fully +Its `context-read --initial-read` uses the same exact-address selector. New private +selection references pin the resolved addresses and complete selection digest; +continuations use the existing explicit reader instead of rerunning initial +navigation. A later navigation change cannot replace the saved selection. +Historical recipe-only references and explicit-file initial cursors still reject +a changed selection digest; recover through the same original full-capture +reference, without another admission or rewriting the saved descriptor. These +legacy readers still require reconciliation of their unfinished original Turns +before retirement; this slice retains their decoder and full-capture recovery. +Initial navigation can replace fully selected children with their complete captured parent to avoid repeating addresses. An unselected child, including optional or unknown material, prevents that merge; the selected facts and obligations stay identical. Explicit selectors and index -entries keep their original addresses. The option is read-only and exclusive with explicit -selectors or discovery; unknown schemas use generic discovery. +entries keep their original addresses. The option is read-only and exclusive with +explicit selectors or discovery; unknown schemas use generic discovery. The concrete first read replaces the broad scalar preview and root index when it fits; unknown schemas or a tight reference budget keep generic discovery. Warm deltas keep their existing reader, and disabled native delivery is unchanged. @@ -232,7 +239,11 @@ activation defaults, saved files, Delta and full-read receipts are unchanged. 后续偏好相关外部动作前精确作用域新读;偏好及其交付均不授予权限。 未知 schema、过小引用预算和 warm delta 保留既有路线, 关闭原生传输的输出不变;完整恢复和原 SHA 校验均保留。只读的 `--initial-read` 不与 -显式选读或索引混用;续页保留短选项和固定的选读摘要,profile 改变则拒绝混读。 +显式选读或索引混用。新私有选读引用固定已解析路径和完整选读摘要,续页复用显式 +reader,不重新运行首读导航;后续导航改变不能替换原选读。历史仅保存导航配方的引用 +及显式文件首读游标在选读摘要变化时仍拒绝,沿同一完整原 capture 引用恢复,不重新 +准入或改写旧描述文件。退役这些 legacy reader 前仍须对账原未完成 Turn;本切片 +保留历史解码与完整捕获恢复。 首读可把已经完整选中的同级内容合并为其完整父节点,减少地址重复;任何未选中的 可选或未知内容都会阻止合并,选中事实和义务不变。显式选读和索引仍保留原地址。 首读也给出现有验收材料地址和当前结算义务。仅明确 `required=false` 的附件延后完整 diff --git a/loopx/cli_commands/quota_capture.py b/loopx/cli_commands/quota_capture.py index 670430896f..a3a3996c27 100644 --- a/loopx/cli_commands/quota_capture.py +++ b/loopx/cli_commands/quota_capture.py @@ -292,8 +292,11 @@ def read_context_capture(args: argparse.Namespace, *, presentation: dict[str, An selectors = [item["pointer"] for item in views] if reference_file is not None and pinned_selection is None: try: + # Pin the resolved addresses, not the initial navigation + # recipe. Existing explicit readers can replay these exact + # bytes after a navigation change, with both digests checked. reference_file = write_context_read_reference(path, digest, selection={ - "select": None if initial_read else selectors, "initial_read": initial_read, + "select": selectors, "initial_read": False, "list_fields": list_fields, "list_depth": list_depth, "sha256": expected}) except OSError: # A verified read must not require a writable capture root. diff --git a/tests/control_plane/test_context_read_reference.py b/tests/control_plane/test_context_read_reference.py index 297dbe4eb7..f82ebf4b67 100644 --- a/tests/control_plane/test_context_read_reference.py +++ b/tests/control_plane/test_context_read_reference.py @@ -132,6 +132,58 @@ def changed(operation, **kwargs): assert "content" not in rejected +@pytest.mark.parametrize("legacy", [False, True], ids=["pinned-addresses", "historical-profile"]) +def test_initial_reference_navigation_change_preserves_original_read_boundary(tmp_path, monkeypatch, legacy): + payload, machine, model = fixture(tmp_path) + expected = read(model["first_read_command"]) + assert expected["ok"] and expected["eof"] + first = read(model["first_read_command"], max_page_output_bytes=3000, page_bytes=3000) + assert first["ok"] and not first["eof"] + reference = Path(first["reference_file"]) + if legacy: + # Historical documents stored the navigation recipe, not its addresses. + _, descriptor = read_context_read_reference(str(reference)) + reference = write_context_read_reference(Path(machine["path"]), machine["sha256"], selection={ + **descriptor["selection"], "select": None, "initial_read": True}) + reference_before, capture_before = reference.read_bytes(), Path(machine["path"]).read_bytes() + from loopx.control_plane.quota import turn_envelope + + original = turn_envelope._turn_envelope_result + + def changed_navigation(operation, **kwargs): + if kwargs.get("initial_read"): + kwargs = {**kwargs, "initial_read": False, "select": ["/criterion"]} + return original(operation, **kwargs) + + monkeypatch.setattr(turn_envelope, "_turn_envelope_result", changed_navigation) + # A fresh initial read now has different navigation over the same source. + current = read(model["first_read_command"]) + assert json.loads(current["content"])["selections"] == [{"pointer": "/criterion", "value": payload["criterion"]}] + following = read(first["next_command"], reference_file=str(reference)) + if legacy: + assert following["ok"] is False and "digest changed" in following["reason"] + assert "content" not in following + recovered = read(model["recovery"]["command"], select=["/goal_boundary", "/selected_todo"]) + assert recovered["ok"] and recovered["eof"] + values = {row["pointer"]: row["value"] for row in json.loads(recovered["content"])["selections"]} + else: + parts = [first["content"]] + while True: + assert following["ok"] and following["schema_version"] == "context_capture_selection_page_v1" + assert _context_pages(following) == [] + assert following["offset"] == len("".join(parts).encode()) + parts.append(following["content"]) + if following["eof"]: + break + following = read(following["next_command"]) + assert "".join(parts) == expected["content"] + values = {row["pointer"]: row["value"] for row in json.loads("".join(parts))["selections"]} + assert values["/goal_boundary"] == payload["goal_boundary"] + assert values["/selected_todo"] == payload["selected_todo"] + assert reference.read_bytes() == reference_before + assert Path(machine["path"]).read_bytes() == capture_before + + @pytest.mark.parametrize("selection", ["fields", "index", "initial"]) def test_selected_read_survives_unwritable_reference_metadata(tmp_path, monkeypatch, selection): import errno