Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -534,13 +534,22 @@ but optimizing it must retain identity consumption, replay and conflict checks.

Current local facades reuse their generated id within managed-runtime retries.
Separate CLI invocations are not implicitly one attempt: claim exposes
`--claim-operation-id`, while create and text/note update do not currently expose
an equivalent cross-process recovery key. That is a caller-recovery limitation,
`--claim-operation-id` and update exposes `--update-operation-id`; create does not
currently expose an equivalent cross-process recovery key. That is a caller-recovery limitation,
not proof of duplicate business effects or universal exactly-once execution.
Any extension must define the retry boundary and distinguish retries from new
intent before adding keys or durable attempt tracking. Test lost responses and
intervening writes; a source-level ban on UUID construction proves neither.

The canonical Todo commands now share one TS receipt recovery owner. Their
local result contract retains unresolved post-commit readback as `ambiguous`
and names the original operation for recovery; it does not infer no-write from
an unavailable receipt or retry the CAS automatically. See the
[command recovery checkpoint](typescript-control-plane-migration-v0.md#command-receipt-and-recovery-ownership)
for intentional diagnostic changes and the complete fixture matrix. Historical
receipt identity and one-way Markdown delivery remain intact. This is command
recovery qualification, not storage retention, service availability or promotion.

For every request, the authority performs this sequence:

1. load the aggregate and provider generation;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -454,12 +454,19 @@ Operation identity 标识调用方的一次逻辑尝试,不是参数组合。
存储成本,但优化时必须保留 identity consumption、重放与冲突校验。

当前本地 facade 在 managed-runtime retry 内复用生成的 id。两次独立 CLI 调用不会
自动视为同一尝试:claim 提供 `--claim-operation-id`,create 和 text/note update
目前没有等价的跨进程恢复 key。这是 caller recovery 的限制,不证明业务效果重复,
自动视为同一尝试:claim 提供 `--claim-operation-id`,update 提供
`--update-operation-id`,create 目前没有等价的跨进程恢复 key。这是 caller recovery 的限制,不证明业务效果重复,
也不能宣称通用 exactly-once。扩展前应先定义重试边界、区分 retry 与新 intent,再
决定是否需要 key 或耐久 attempt tracking。用丢响应与中间插入其他写入来验证,
而不是用禁止 UUID 构造的源码扫描代替语义测试。

Canonical Todo 命令现在共用一个 TS 回执恢复 owner。本地结果合同将提交后未能
确认的回读保留为 `ambiguous`,并指出恢复所需的原 operation;不能从回执不可用
推断未写入,也不会自动重试 CAS。明确的诊断变化及完整 fixture 矩阵见
[命令恢复检查点](typescript-control-plane-migration-v0.zh-CN.md#命令回执与恢复的统一所有者)。
历史回执身份和单向 Markdown 投递保持不变。这是命令恢复验证,不代表存储保留、
服务可用性或 promotion 已获资格。

对每个 request,authority 执行以下顺序:

1. load aggregate 与 provider generation;
Expand Down
43 changes: 43 additions & 0 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,49 @@ rewrite or new writer admission is implied. General add/update admission and a
generic repair action for every invalid condition remain separate scopes; this
is not a claim of zero behavior change or full Todo writer closure.

#### Command receipt and recovery ownership

At baseline `bfd1ec8db`, create, claim, update, complete/supersede, archive and
Monitor poll repeated envelope matching, result projection and post-CAS
readback. `coordination/command_receipt.ts` now owns those shared semantics;
command modules retain request normalization/digests, admission, payload
validation and mutations. `coordination/todo_archive.ts` owns retention,
separate from terminal validation and lease release. Internal callers import
that owner directly; the old module does not retain an unused re-export.

Intentional observable changes on these canonical command paths:

- An applied/ambiguous commit followed by unreadable receipt remains
`ambiguous`, with `recovery.operation_id` and
`retry_with_same_operation_id=true`. A read failure cannot erase possible
durable acceptance. A thrown commit response receives one receipt lookup,
never an automatic second write.
- A conclusive CAS conflict or failed commit remains that result if diagnostic
readback fails. An exact historical receipt still takes precedence. An
applied response with a missing receipt remains a protocol failure.
- Malformed create/Monitor result objects and update/terminal/archive change decisions
fail with `invalid_coordination_command_receipt`; they cannot become successful
replay/no-op through coercion or escape as an unchecked decoder error. Claim
retains its existing receipt-error code and historical omitted-change codec.
- Read failures consistently include `changed=false`; this with an `ambiguous`
status means no proven successful result, **not** proof that nothing was written.
Identity-conflict and missing-receipt messages use shared coordination wording;
their existing reason codes remain stable.

Existing request digests, receipt schemas, success payloads, no-op consumption,
lease/grant checks, permanent Markdown delivery and provider defaults remain
compatible. The production-scale fixture now drives all seven command operations
through normal, lost-response, unreadable-readback and thrown-response cases,
including replay after an intervening commit. Real File/SQLite/PostgreSQL and
NoKV transport conformance share that matrix. The three-arm read-only source
rehearsal also loses archive responses on actual File/PostgreSQL commits.

This removes duplicated TS transaction authority, not Python business writers:
no new bridge or RPC is introduced, and cross-runtime calls are unchanged.
T1 metadata/effect closure, T2 retained Monitor leases and D1–D3 qualification
remain separate work; the compatibility editor and other command protocols
retain their distinct receipt contracts. No Goal promotion is implied.

#### Execution cards after the current stack

This is a **conditional execution plan**, not a merged-status declaration.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,42 @@ generation fence、claim/exclusion、capacity 和 PR 等待语义保持。非法
准入。普通 add/update 准入及覆盖全部非法条件的通用修复动作仍是独立范围;不能宣称
全量零行为变化或全部 Todo writer 已闭合。

#### 命令回执与恢复的统一所有者

在基线 `bfd1ec8db`,create、claim、update、complete/supersede、archive 与
Monitor poll 分别重复 envelope 匹配、结果投影和 CAS 后回读。
现在由 `coordination/command_receipt.ts` 统一这些语义;各命令继续拥有请求
规范化/摘要、准入、回执业务载荷校验及状态变更。
`coordination/todo_archive.ts` 单独拥有归档保留事务,与终态校验和 lease
释放分离。内部调用方直接导入新 owner,旧模块不保留无实际用途的 re-export。

这些 canonical 命令路径有以下明确的可观察变化:

- 提交已 applied 或 ambiguous、但回执不可读时,结果保留为 `ambiguous`,
携带 `recovery.operation_id` 和 `retry_with_same_operation_id=true`。
读取失败不能抹掉可能已经持久化的事实。提交响应抛异常后只查一次回执,
不自动再次写入。
- 明确的 CAS conflict 或提交失败,不再被随后的诊断读取失败覆盖。精确的
历史回执仍优先返回;applied 响应却缺少回执,仍然是协议失败。
- create/Monitor 结果对象、update/terminal/archive 的变更判定损坏时,返回
`invalid_coordination_command_receipt`,不能通过隐式转换变成成功 replay/
no-op,也不能直接逸出为未处理的解码异常。Claim 保留原回执错误码和历史
省略 changed 字段的兼容解析。
- 读取失败统一携带 `changed=false`;当 status 为 `ambiguous` 时,它表示
尚无成功结果证明,**不表示**已证明没有写入。身份冲突与回执缺失的错误消息
采用统一 coordination 措辞,原 reason code 保持不变。

原请求摘要、回执 schema、成功载荷、no-op 身份消耗、lease/grant 校验、永久
Markdown 投递和默认 provider 保持兼容。完整生产规模 fixture 现在覆盖七种
命令的正常提交、响应丢失、回读不可用、响应抛异常,以及插入其他提交后的
历史重放。真实 File/SQLite/PostgreSQL 和 NoKV transport conformance
共用该矩阵。三路只读源演练还在真实 File/PostgreSQL 归档提交后丢弃响应。

本次删除重复的 TS 事务权威,不宣称删除 Python 业务 writer;没有新增 bridge
或 RPC,跨运行时调用数不变。T1 的 metadata/effect 闭合、T2 的带 lease
Monitor 和 D1–D3 资格验证仍待后续;兼容编辑器及其他命令保留各自的回执合同。
本次不代表 Goal promotion。

#### 当前 stack 合入后的执行卡

这是**条件式执行规划**,不是所有阶段已完成的声明。2026-09-09 核查时,#4053、#4117、
Expand Down
34 changes: 28 additions & 6 deletions examples/control_plane/authority-three-arm-rehearsal.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
import {Pool} from 'pg';
import {FileAuthorityStore} from '__FILE_STORE__';
import {PostgreSqlAuthorityStore, installPostgreSqlAuthorityStoreSchema} from '__PG_STORE__';
import {executeCoordinationTodoArchiveCompleted} from '__TERMINAL__';
import {executeCoordinationTodoArchiveCompleted} from '__ARCHIVE__';
import {canonicalAuthorityBytes} from '__CODEC__';
import {evaluateTodoResumeConditions} from '__RESUME__';

Expand Down Expand Up @@ -117,17 +117,35 @@
next_projection: request.initial,
});
assert.equal(initialized.status, 'applied', `${name} initialization failed`);
const archived = await executeCoordinationTodoArchiveCompleted(store, {
// Lose the response after the actual backend commit. Recovery must read
// the original receipt rather than attempt the archive a second time.
let commitCount = 0;
const responseLostStore = {
storeIdentity: () => store.storeIdentity(),
loadAuthority: () => store.loadAuthority(),
readReceipt: (id) => store.readReceipt(id),
scanCommitted: (cursor, limit) => store.scanCommitted(cursor, limit),
commitAuthority: async (request) => {
commitCount++;
const committed = await store.commitAuthority(request);
assert.equal(committed.status, 'applied');
return {status: 'ambiguous', reason_code: 'synthetic_response_loss',
reason: 'isolated rehearsal discarded the commit response'};
},
};
const archiveRequest = {
goal_id: request.goal_id,
role: request.role,
max_active_done: request.max_active_done,
operation_id: `${name}-three-arm-archive`,
dry_run: false,
now: new Date('2026-01-01T00:00:00Z'),
});
};
const archived = await executeCoordinationTodoArchiveCompleted(responseLostStore, archiveRequest);
assert.equal(commitCount, 1);
assert.equal(
archived.status,
'applied',
'recovered',
`${name} archive failed (${String(archived.reason_code ?? 'unknown')}:` +
`${archiveFailureCategory(archived)})`,
);
Expand All @@ -149,6 +167,10 @@
assert.deepEqual(end, {status: 'page', transactions: [],
next_cursor: finalPage.next_cursor, has_more: false});
assert.deepEqual(await store.loadAuthority(), loaded, 'journal reads changed authority');
const replay = await executeCoordinationTodoArchiveCompleted(store, archiveRequest);
assert.equal(replay.status, 'replayed');
assert.equal(replay.cursor, archived.cursor);
assert.deepEqual(await store.loadAuthority(), loaded);
results[name] = {archived, head: loaded.head};
}

Expand Down Expand Up @@ -290,10 +312,10 @@ def _node_script(repository: Path) -> str:
),
)
.replace(
"__TERMINAL__",
"__ARCHIVE__",
_module_uri(
repository,
"loopx/control_plane/coordination/todo_terminal_lifecycle.ts",
"loopx/control_plane/coordination/todo_archive.ts",
),
)
.replace(
Expand Down
106 changes: 106 additions & 0 deletions loopx/control_plane/coordination/command_receipt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/** Durable command recovery, owned by coordination rather than by a provider.
* A receipt proves a historical decision, never current execution authority.
* Business planners and request hashes remain with their command owners. */
import type {JsonObject} from "../effect_program.ts";
import type {AuthorityStore, AuthorityStoreCommit, AuthorityStoreCommitResult,
AuthorityStoreReceiptResult} from "./authority_store.ts";
import {AuthorityStoreProtocolError, canonicalAuthorityObject} from "./authority_store_codec.ts";
import {projectionDelivery} from "../todos/projection_delivery.ts";

export type ReceiptPhase = "applied" | "recovered" | "replayed";
interface ReceiptPayload {
fields: JsonObject;
changed: boolean;
}
interface CommandReceiptContract<S extends string> {
result_schema: S;
identity: JsonObject & {schema_version: string; operation_id: string; goal_id: string; request_sha256: string};
/** Decode only the command's historical payload, without reading current state. */
decode(original: JsonObject, phase: ReceiptPhase): ReceiptPayload;
failure(code: string, reason: string): JsonObject & {schema_version: S};
}
type Result<S extends string> = JsonObject & {schema_version: S};

/** One envelope identity, result projection and post-commit state machine for
* canonical Todo commands. Existing wire schemas and request digests are retained. */

Check warning on line 25 in loopx/control_plane/coordination/command_receipt.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaCZSwU46BeFNClEWBaU&open=AaCZSwU46BeFNClEWBaU&pullRequest=4286
export class CoordinationCommandReceipt<S extends string> {
readonly contract: CommandReceiptContract<S>;
constructor(contract: CommandReceiptContract<S>) { this.contract = contract; }

private project(receipt: AuthorityStoreReceiptResult, phase: ReceiptPhase): Result<S> | null {
if (receipt.status === "missing") return null;
const {result_schema, identity, decode, failure} = this.contract;
if (receipt.status !== "found") return {schema_version: result_schema, ...receipt, changed: false};
const original = receipt.receipts[0];
if (receipt.receipts.length !== 1 || !original ||
Object.entries(identity).some(([key, value]) => original[key] !== value)) {
return failure("coordination_operation_identity_mismatch",
"operation id already names a different coordination request");
}
let payload: ReceiptPayload;
try {
payload = decode(original, phase);
} catch (error) {
if (!(error instanceof AuthorityStoreProtocolError)) throw error;
return failure("invalid_coordination_command_receipt", error.message);
}
return {...payload.fields, schema_version: result_schema,
status: phase === "applied" && !payload.changed ? "no_change" : phase,
changed: phase !== "replayed" && payload.changed,
provider_revision: receipt.provider_revision, cursor: receipt.cursor,
projection_delivery: projectionDelivery(payload.changed),
projection_source: "committed_authority_journal"};
}

async read(store: AuthorityStore): Promise<Result<S> | null> {
return this.project(await store.readReceipt(this.contract.identity.operation_id), "replayed");
}

async commit(store: AuthorityStore, commit: AuthorityStoreCommit): Promise<Result<S>> {
const {identity, result_schema, failure} = this.contract;
if (commit.operation_id !== identity.operation_id) {
throw new AuthorityStoreProtocolError("commit and receipt operation identities differ");
}
// Catch only the effect whose response can be lost after durable acceptance.
// Never retry the write here, and never turn an exception into no-write proof.
let committed: AuthorityStoreCommitResult;
try { committed = await store.commitAuthority(commit); }
catch {
committed = {status: "ambiguous", reason_code: "coordination_commit_response_lost",
reason: "commit response was not received; recover the original operation"};
}
let receipt: AuthorityStoreReceiptResult;
try { receipt = await store.readReceipt(identity.operation_id); }
catch {
receipt = {status: "unavailable", reason_code: "coordination_receipt_read_failed",
reason: "durable receipt read did not complete"};
}
if (receipt.status === "found") {
return this.project(receipt, committed.status === "applied" ? "applied" : "recovered")!;
}
if (receipt.status === "missing" && committed.status === "applied") {
return failure("coordination_commit_readback_mismatch", "applied command lacks its durable receipt");
}
if (committed.status === "ambiguous" ||
(committed.status === "applied" && receipt.status !== "missing")) {
return {schema_version: result_schema, status: "ambiguous", changed: false,
reason_code: "coordination_receipt_recovery_required",
reason: "commit may be durable; recover its receipt using the same operation id",
commit_status: committed.status, receipt_status: receipt.status,
recovery: {operation_id: identity.operation_id, retry_with_same_operation_id: true},
...(receipt.status === "missing" ? {} : {readback_reason_code: receipt.reason_code})};
}
// A conclusive rejection is not erased by a later diagnostic read failure.
return {schema_version: result_schema, ...committed, changed: false};
}
}

/** Terminal/archive results persist an explicit change decision. Missing or
* malformed decisions must not be coerced into a successful no-op. */
export function commandReceiptResult(original: JsonObject): ReceiptPayload {
const fields = canonicalAuthorityObject(original.result, "command receipt result");
if (typeof fields.changed !== "boolean") {
throw new AuthorityStoreProtocolError("command receipt result.changed must be boolean");
}
return {fields: {...fields, original_receipt: original}, changed: fields.changed};
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
COORDINATION_TODO_ARCHIVE_RECEIPT_SCHEMA,
executeCoordinationTodoArchiveCompleted,
type CoordinationTodoArchiveInput,
} from "./todo_terminal_lifecycle.ts";
} from "./todo_archive.ts";

const ATTEMPT_SCHEMA = "loopx_local_todo_archive_attempt_v0";
export const LOCAL_TODO_ARCHIVE_ACK_RESULT_SCHEMA =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA} from "./todo_archive.ts";
import {executeTodoContinuation} from "./todo_continuation.ts";
import { withFileMutationLock } from "../effect_runtime_io.ts";
import { ShadowManagementError, requireShadowPrimaryWriteAllowed, shadowMaintenanceLockPath } from "./shadow_management.ts";
Expand Down Expand Up @@ -59,7 +60,6 @@ import {
executeCoordinationTodoUpdate,
} from "./todo_update.ts";
import {
COORDINATION_TODO_ARCHIVE_RESULT_SCHEMA,
COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA,
executeCoordinationTodoTerminalLifecycle,
} from "./todo_terminal_lifecycle.ts";
Expand Down
Loading