Skip to content

Commit 04ba65a

Browse files
authored
Merge pull request #4761 from loopx-project/codex/authority-projection-recovery
refactor(todos): unify continuation evidence and completion readback
2 parents 84fb052 + 1dcc712 commit 04ba65a

24 files changed

Lines changed: 1013 additions & 274 deletions

‎docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3016,6 +3016,16 @@ independent legacy three-arm comparison or D2 soak.
30163016
move. Exit with deterministic freshness/readback and an actionable repair
30173017
path; a successful render once is insufficient.
30183018

3019+
Continuation and closure readback now uses the same TS relation evidence for
3020+
completed-work gaps and handoff states before filtering/capping. Capture v1
3021+
retains reachable archived successors and original deferred status; derived
3022+
summary evaluations never enter provider records. Completion retries also
3023+
recover the matching receipt when a peer commits between receipt and head reads,
3024+
without accepting state-only replay with fresh validation evidence. Real CLI and complete-graph
3025+
provider conformance cover the consumer family. See [operation and semantic
3026+
changes](../../reference/todo-continuation-readback.md). This closes a bounded
3027+
L5/L7 gap; permanent projection delivery/recovery, D2 and D3 are still open.
3028+
30193029
**D2 — qualify exactly one local profile; independent of PostgreSQL deployment.**
30203030

30213031
- Reconcile the SQLite candidate #4121 with Section 7.2 before adding code.
@@ -3073,8 +3083,9 @@ The reconciled baseline includes #4286 (command receipts/archive), #4289
30733083
#4316 (Goal Channel observation), #4317 (provider opening), #4348 (renew),
30743084
#4328 (first SQLite D2 batch) and #4334 (PostgreSQL service admission): all are
30753085
merged at the 2026-09-20 checkpoint. Their existence does not qualify the full
3076-
cards. #4732 remains the open Monitor observation/reactivation slice; #4224
3077-
retains contributor ownership of SQLite D2. Re-read actual heads before work.
3086+
cards. Monitor observation/reactivation #4732 and linked User completion #4754
3087+
are also merged. #4224 retains contributor ownership of SQLite D2. Re-read
3088+
actual heads before work.
30783089

30793090
The identifiers below are **planned PR packages**, not reserved GitHub numbers.
30803091
A package may split at a real effect/compatibility boundary; changing languages
@@ -3096,12 +3107,11 @@ or moving a helper is not by itself a package exit.
30963107
packages as complete operations while L6/L7 progress independently. B integrates
30973108
those contracts into complete user flows; C has one reproducible qualification
30983109
checkpoint; D changes the default in its own reviewable PR. After the linked
3099-
User completion slice, the 2026-09-20 planning estimate is **6–9 further cohesive
3110+
User completion slice, the 2026-09-20 planning estimate is **5–8 further cohesive
31003111
PRs**, conditional on the caller audit finding no additional missing effects:
31013112

31023113
| Remaining work package | Estimated PRs | Exit |
31033114
| --- | --- | --- |
3104-
| Monitor observation/reactivation | 1, existing #4732 | Real caller and complete graph acceptance; avoid a duplicate implementation. |
31053115
| Remaining L2/L3 caller and executor-effect fences | 1–2 | Actual CLI/Turn/Chat command inventory and external-effect boundary closure. |
31063116
| L5 / D1 consumer and projection closure | 1 | Full consumer parity, lag/recovery and packaged client readback. |
31073117
| L6 / SQLite D2 | 1–2, contributor-owned #4224 | Capacity, crash/restore and separately authorized elapsed-soak evidence on one profile. |
@@ -3113,8 +3123,8 @@ Scope may split only where a real effect/compatibility boundary warrants it.
31133123
Small Python business-rule deletions can ship with each TS owner; rendering,
31143124
private command execution and import/export keep their active adapters.
31153125

3116-
截至 2026-09-20,关联 User 完成链路补齐后,按以上六类完整交付边界估算还需 **6–9 个 PR**。
3117-
Monitor 复用 #4732,SQLite D2 仍归 #4224 contributor;其余顺序是调用方/执行围栏、
3126+
截至 2026-09-20,关联 User 完成链路补齐后,按以上五类完整交付边界估算还需 **5–8 个 PR**。
3127+
Monitor #4732 已合并,SQLite D2 仍归 #4224 contributor;其余顺序是调用方/执行围栏、
31183128
消费与投影、capture 与整 Goal 演练,最后独立切换默认值。该估算以未发现更多缺失
31193129
effect 为前提,不是合并数承诺,也不要求先删完 Python。TS owner 每收敛一块即可
31203130
删除对应旧规则;仍有真实调用方的渲染、私有命令执行和导入导出适配器继续保留。

‎docs/architecture/rfcs/typescript-control-plane-migration-v0.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,19 @@ history before that checkpoint. Successful schemas, File/NoKV persisted bytes,
10171017
request identity and revision algorithms remain compatible. This supports T3/D1
10181018
readers but does not finish Todo writers, retention/compaction or promotion.
10191019

1020+
Continuation readback now shares one typed succession resolver, handoff state
1021+
machine and summary closure decision. The legacy adapter no longer owns those
1022+
rules. Full-source evaluations survive display selection; nonexistent/self
1023+
successors cannot certify closure and archived continuation evidence survives
1024+
capture. The existing archive-capture request advances to v1 so older runtimes
1025+
cannot silently omit the expanded graph. Query subsets do not emit whole-source
1026+
closure proofs, and bounded handoff views preserve their state and exclusions.
1027+
See [continuation readback](../../reference/todo-continuation-readback.md).
1028+
This closes that T3/L5 consumer family and its bounded L7 dependency, not D1–D3
1029+
or every T3 consumer. Python retains codecs, IO and the documented legacy route
1030+
prose hint until its remaining writers emit explicit replan flags; no new
1031+
capability/provider or parallel business authority is introduced.
1032+
10201033
**T4 — collect full-writer retirement after durability cutover.**
10211034

10221035
- The 2026-09-19 command audit retires two already-typed but unconsumed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Todo continuation and closure readback
2+
3+
Todo list, status and quota distinguish a completed record from a closed work
4+
slice. A completed tracked advancement Todo still needs an existing successor
5+
or an explicit `no_followup=true`. This read policy lives in
6+
`control_plane/todos/succession.ts`; Python normalizes legacy input and renders
7+
its decisions. It belongs to the existing Todo control plane, uses the selected
8+
AuthorityStore, and adds no capability or extension provider.
9+
10+
## Relationship evidence
11+
12+
The policy evaluates the complete available Todo graph before role, status,
13+
Agent, ID or display-limit selection. It recognizes explicit
14+
`successor_todo_ids`, `superseded_by`, and advancement records pointing back
15+
through `unblocks_todo_id` or `resume_when=todo_done:<source>`.
16+
17+
- A declared successor must exist and differ from the source. A dangling or
18+
self reference does not close work. A retained archived record remains
19+
relationship evidence; it does not become active work.
20+
- Explicit links retain their existing role-neutral meaning. Inferred
21+
successors require an advancement task. `monitor_changed` is a resume
22+
condition, not an inferred work successor.
23+
- An existing successor records continuation lineage. It does not prove that
24+
the successor has executed, been accepted or acquired a lease. This is not a
25+
transitive Goal acceptance proof or a cycle-freedom certificate.
26+
- Basic historical checkboxes without structured execution context keep their
27+
compatibility behavior. Explicit no-follow-up remains an independent closeout
28+
choice. Deferred work is never classified as a completed advancement gap.
29+
30+
Both completed-work warnings and handoff gates use that same graph. Previously
31+
handoff ignored explicit successor lists, while completed-work warnings accepted
32+
nonexistent/self links. Filtering or archiving a valid inferred successor could
33+
also manufacture a warning that was absent on the full source.
34+
35+
| Handoff facts, in precedence order | State |
36+
| --- | --- |
37+
| Existing, non-self supersession target | `superseded` |
38+
| Deferred source | `deferred` |
39+
| Source has not completed | `blocking` |
40+
| Completed with explicit no-follow-up | `cleared_no_followup` |
41+
| Completed with a resolved successor | `cleared_with_successor` |
42+
| Completed without either | `cleared_without_successor` |
43+
44+
Only active dependency-linked executor exclusions are handoff gates. These
45+
states describe the gate; none changes claims, grants, leases or stored Todos.
46+
The existing legacy stale-closeout prose hint remains a compatibility adapter
47+
until route-closeout writers supply the explicit replan flag. Its substring
48+
matching can overmatch narrative and is not used for successor resolution,
49+
handoff state or permission. An explicit boolean replan flag takes precedence.
50+
51+
## Selection, proofs and transport
52+
53+
A fresh full-source evaluation accompanies each internal summary row as an
54+
ephemeral Python attribute, outside dictionary fields and JSON serialization. Its fact
55+
digest prevents reuse after relevant item edits; it is a consistency check,
56+
not authentication. Fresh parsing/canonical reads always recompute it rather
57+
than trusting stored evaluations. Shadow capture discards this derived field;
58+
canonical records and durable source digests do not gain a second authority.
59+
Public parser rows keep their existing dictionary schema. Final list/status
60+
responses copy plain dictionaries, retaining decision fields without exposing
61+
the internal evaluation or expanding the hot-path payload.
62+
63+
Legacy archive/recreate can retain one archived and one active record with the
64+
same logical Todo ID. The active record owns that ID's inferred edges regardless
65+
of source order; archived metadata cannot supply stale edges for the replacement.
66+
Two active or two archived records with the same ID remain ambiguous and reject.
67+
This read precedence does not relax canonical capture's unique-identity contract.
68+
69+
A status/ID/Agent-filtered list describes that selection but emits no Goal-source
70+
or terminal-closure proof. A display limit alone does not change the source:
71+
counts, warning decisions and proof eligibility are computed first. Handoff
72+
state, successor count and executor exclusions survive the bounded list view.
73+
74+
Terminal closure additionally requires no deferred/convergent work, unresolved
75+
handoff, successor gap or route-replan obligation. Watch-only monitors retain
76+
the existing convergent-work exception. It remains separate from Goal acceptance.
77+
78+
Field-presence sets are interned inside a succession RPC request so long archive
79+
histories do not repeat identical metadata shapes. The full graph is retained;
80+
no record sampling, per-page rule evaluation or RPC limit increase is used.
81+
82+
## Migration and operation
83+
84+
The shared archive-capture owner retains the reachable continuation graph as
85+
well as resume dependencies and standing decisions. It preserves real record
86+
status, including deferred history: capturing a deferred record does **not**
87+
satisfy `todo_done`. Duplicate identities, invalid archive state and incompatible
88+
role/authority combinations still reject capture. Unrelated archive records
89+
remain outside the bounded canonical capture.
90+
91+
The internal request is `todo_archive_dependency_capture_request_v1`. Python
92+
and the bundled TS runtime must be upgraded together; an older runtime rejects
93+
the new request instead of silently omitting continuation edges. Existing
94+
historical capture/promotion receipts are not rewritten or upgraded in place.
95+
Requalify capture on this runtime before a future promotion.
96+
97+
Use existing read commands; no activation or new option is needed:
98+
99+
```bash
100+
loopx --registry registry.json todo list --goal-id example-goal --format json
101+
loopx --registry registry.json todo list --goal-id example-goal --todo-id todo_source --limit 1 --format json
102+
```
103+
104+
Completion retries also close a receipt/head read race: if the first receipt
105+
lookup misses a peer commit but the head already shows completion, recheck the
106+
matching operation receipt before interpreting a supplied validation receipt.
107+
This returns the committed result without repeating effects; no matching
108+
receipt still follows the existing validation and identity guards.
109+
110+
Reads do not repair Markdown, mutate Todo/lease state or replay a business
111+
operation. Missing promoted Markdown is acceptable; an unavailable provider is
112+
not an empty Goal. No frontend configuration changes are needed: CLI, manager
113+
Chat details and existing status/quota consumers retain their current entry
114+
points. To reverse a business decision, use its ordinary mutation, not a read
115+
model or restored Markdown. Code rollback retains provider state and fences;
116+
old read policies can again misclassify these cases.
117+
118+
## 中文
119+
120+
“这个 Todo 已完成”与“这一段工作已闭环”不同。结构化推进任务完成后,要有真实
121+
存在的后继,或明确声明 `no_followup=true`。TS 现在统一解析显式后继、替代关系和
122+
反向交接关系;Python 保留旧输入规范化与展示。不存在的 ID、自指 ID 不再遮住
123+
未闭环工作,归档与筛选也不再凭空制造后继缺口。
124+
125+
关系在完整可用源上判定,然后才筛选、分页。按状态、ID 或 Agent 筛出的列表不能
126+
为整个源出具闭环证明;仅限制显示条数不会改变完整源上的计数与判断。handoff 的
127+
状态与排除执行者信息不会在压缩展示时丢失。派生判断带相关事实摘要以防陈旧复用,
128+
但不是授权凭据,也不写回 provider。旧 prose replan 提示仍仅用于兼容;显式
129+
布尔标记优先,不能靠标题里的几个词推导后继存在或授予权限。
130+
131+
归档捕获现在保留与当前工作有关的后继图和原有依赖、standing decision。延后历史
132+
可以被保留,但状态仍是 deferred,绝不会因此满足 `todo_done`。新的内部 v1 请求
133+
要求 Python 与 TS 配套升级;旧回执不被重新解释。长历史重复字段集合采用无损共享,
134+
没有放宽 RPC 上限或丢弃历史节点。
135+
136+
本阶段关闭一组 T3/L5 读语义及其 L7 捕获依赖,不代表 D1 投影投递、D2 耐久性、
137+
D3 整 Goal 切换完成,也不修改默认 provider。PostgreSQL 使用相同规则,服务部署与
138+
资格仍独立。复杂 fixture 和只读快照演练不是长期 soak 或生产晋升许可。

‎examples/control_plane/hot-path-interface-budget-smoke.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,8 @@ def main() -> int:
388388
assert "presentation_surfaces" not in status_payload, status_payload
389389
status_items = status_payload["attention_queue"]["items"]
390390
assert status_items, status_payload
391+
# Internal graph evaluations must not expand public status payloads.
392+
assert "succession_evaluation" not in json.dumps(status_items)
391393
assert "task_graph_projection" not in status_items[0], status_items[0]
392394
route_health = status_payload["runtime_projection_routes"]
393395
assert route_health["healthy"] is True, route_health

‎examples/control_plane/quota-cleared-blocker-successor-gate-smoke.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,12 +456,14 @@ def assert_completed_successor_keeps_old_gate_cleared() -> None:
456456
assert summary["current_agent_cleared_without_successor_handoff_count"] == 0, payload
457457

458458

459-
def assert_superseded_completed_blocker_does_not_wake_agent() -> None:
459+
def assert_resolved_supersession_does_not_wake_agent() -> None:
460460
payload = build_quota_should_run(
461461
status_payload(
462462
[
463463
primary_owned_todo(),
464464
handoff_review(status="done", superseded_by="todo_value_successor"),
465+
todo_item(todo_id="todo_value_successor", text="Verified replacement",
466+
status="done", no_followup=True),
465467
],
466468
recommended_action="Wait for main-control after superseded handoff.",
467469
),
@@ -503,7 +505,7 @@ def main() -> int:
503505
assert_archived_completed_blocker_does_not_wake_agent()
504506
assert_existing_successor_runs_normally()
505507
assert_completed_successor_keeps_old_gate_cleared()
506-
assert_superseded_completed_blocker_does_not_wake_agent()
508+
assert_resolved_supersession_does_not_wake_agent()
507509
assert_no_followup_completed_blocker_does_not_wake_agent()
508510
print("quota-cleared-blocker-successor-gate-smoke ok")
509511
return 0

‎loopx/control_plane/coordination/runtime_shadow.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,11 @@ def capture_todo_archive_dependencies(todos: list[dict[str, Any]], state_text: s
159159
_, archived, _ = parse_todo_source(state_text)
160160
# No prose or wide diagnostics cross the selection transport budget.
161161
capture_fields = ("todo_id", "role", "task_class", "status", "done", "archive_state", "resume_when",
162-
"decision_scope", "decision_outcome", "global_gate", "blocks_agent", "bound_agent", "goal_bound")
162+
"decision_scope", "decision_outcome", "global_gate", "blocks_agent", "bound_agent", "goal_bound",
163+
"successor_todo_ids", "superseded_by", "unblocks_todo_id")
163164
capture = effect_runtime_result("todo.archive.capture_dependencies", {
164-
"schema_version": "todo_archive_dependency_capture_request_v0",
165-
"active": [{"todo_id": item["todo_id"], "resume_when": item.get("resume_when")} for item in todos],
165+
"schema_version": "todo_archive_dependency_capture_request_v1",
166+
"active": [{key: item[key] for key in capture_fields if key in item} for item in todos],
166167
"archived": [{key: item[key] for key in capture_fields if key in item} for item in archived],
167168
})
168169
if not isinstance(capture, dict) or capture.get("schema_version") != "todo_archive_dependency_capture_result_v0":

‎loopx/control_plane/coordination/todo_terminal_lifecycle.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,13 @@ export async function executeCoordinationTodoTerminalLifecycle(
805805
todo_id: input.todo_id,
806806
}, "decision_rejection");
807807
}
808+
// The first receipt read can precede a peer commit while this head already
809+
// observes it. Recover only the matching operation receipt; never discard a
810+
// validation receipt to manufacture a terminal replay from Todo state alone.
811+
if (input.command === "complete" && todo.status === "done" && input.validation_receipt !== null) {
812+
const committedReplay = await terminalReceipt(input, requestSha).read(store);
813+
if (committedReplay !== null) return committedReplay;
814+
}
808815
if (input.expected_role !== null && todo.role !== input.expected_role) {
809816
return terminalFailure(
810817
"todo_role_mismatch",

‎loopx/control_plane/effect_runtime_handlers.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {evaluateUserCompletion} from "./todos/user_completion.ts";
2+
import {projectTodoSuccession, projectTodoClosure} from "./todos/succession.ts";
23
import {projectTodoSummaryLanes, projectLegacyTodoWorkCounts} from "./todos/summary_lanes.ts";
34
import {recordDelegationAdoption, delegationInventoryItem, delegationInventoryQuery, delegationPreflight, delegationTurnPlanDecision, selectDelegationBinding, transitionDelegationObservation} from "./collaboration/delegation.ts";
45
import {planChatMode} from "./collaboration/chat_mode.ts";
@@ -412,6 +413,8 @@ export function createEffectRuntimeHandlers(
412413
["todo.public_update.plan", planPublicTodoUpdate],
413414
["todo.standing_decision.project", evaluateStandingDecisionProjection],
414415
["todo.summary_lanes.project", projectTodoSummaryLanes],
416+
["todo.succession.project", projectTodoSuccession],
417+
["todo.succession.closure", projectTodoClosure],
415418
["todo.work_counts.project", projectLegacyTodoWorkCounts],
416419
["todo.decision_scope.evaluate", evaluateDecisionScope],
417420
["todo.user_completion.plan", evaluateUserCompletion],

‎loopx/control_plane/todos/active_state_todos.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
read_canonical_todos_if_promoted,
1010
)
1111

12+
from .succession_warning import public_todo_summary
13+
1214
MONITOR_WRITEBACK_CONTRACT_SCHEMA_VERSION = "monitor_writeback_contract_v0"
1315

1416

@@ -46,7 +48,7 @@ def _redacted_status_todo_fields(fields: dict[str, Any]) -> dict[str, Any]:
4648
group = redacted.get(key)
4749
if not isinstance(group, dict):
4850
continue
49-
group_copy = dict(group)
51+
group_copy = public_todo_summary(group)
5052
items: list[Any] = []
5153
for item in group_copy.get("items") or []:
5254
if not isinstance(item, dict):

0 commit comments

Comments
 (0)