diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index db1e310ed0..1914aa8e08 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -515,6 +515,31 @@ is not a client domain precondition and is not part of the request digest. A caller may carry a previously observed head revision only as transport metadata; changing that observation does not create a new semantic operation. +Operation identity names a caller's logical attempt, not the parameter tuple. +Mint an id once outside transport retries and reuse it for that attempt; a UUID +is valid for this purpose. A later independent call may have identical parameters +and still require a new id (for example, setting a value again after another +writer changed it). Hashing parameters forever would replay stale history; +hashing a newly observed provider revision cannot recover the original id after +a commit whose response was lost. + +In the shipped claim/update contract, `changed=false` describes Todo/lease state, +not the absence of a storage write: a first accepted named no-change operation +persists its terminal receipt under CAS. Retrying that id after a later state +change must replay the original no-change result, not perform new work. An empty +archive selection has a different, explicit no-transaction contract; do not +generalize it to all verbs. Receipt-only history growth is a real storage cost, +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, +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. + For every request, the authority performs this sequence: 1. load the aggregate and provider generation; diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 02a83e38a3..672077b00a 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -441,6 +441,24 @@ precondition 与 command parameter;不覆盖 transport retry metadata。Goal-w 携带读到的 head revision,只能把它作为 transport observation;改变该观测不构成 一条新的语义 operation。 +Operation identity 标识调用方的一次逻辑尝试,不是参数组合。在 transport retry 外 +生成一次 id,并在该尝试内复用;UUID 可以满足这个用途。稍后的独立调用即使参数 +相同,也可能需要新 id(例如其他 writer 改值后再次设置原值)。永久按参数哈希会 +重放过期历史;按新读到的 provider revision 哈希,也无法在提交后丢响应时找回原 id。 + +当前 claim/update 合同中的 `changed=false` 描述 Todo/lease 状态,不表示存储零写入: +首次接受一个具名 no-change operation 时,会在 CAS 下保存终结 receipt。状态后来 +变化,再重试该 id,必须重放原来的 no-change,不能变成新工作。空 archive selection +有另一套明确的零事务合同,不能推广到所有动词。Receipt-only history 增长确实有 +存储成本,但优化时必须保留 identity consumption、重放与冲突校验。 + +当前本地 facade 在 managed-runtime retry 内复用生成的 id。两次独立 CLI 调用不会 +自动视为同一尝试:claim 提供 `--claim-operation-id`,create 和 text/note update +目前没有等价的跨进程恢复 key。这是 caller recovery 的限制,不证明业务效果重复, +也不能宣称通用 exactly-once。扩展前应先定义重试边界、区分 retry 与新 intent,再 +决定是否需要 key 或耐久 attempt tracking。用丢响应与中间插入其他写入来验证, +而不是用禁止 UUID 构造的源码扫描代替语义测试。 + 对每个 request,authority 执行以下顺序: 1. load aggregate 与 provider generation; diff --git a/tests/control_plane_ts/todo_update.test.ts b/tests/control_plane_ts/todo_update.test.ts index b2b12c21dd..98bfb81983 100644 --- a/tests/control_plane_ts/todo_update.test.ts +++ b/tests/control_plane_ts/todo_update.test.ts @@ -130,12 +130,23 @@ test(`provider-first update fails closed without a hard-lease execution proof ($ test("provider-first update records no-change identity without state mutation", async () => { const {store, request} = await seeded(); const before = await store.loadAuthority(); - const result = await executeCoordinationTodoUpdate(store, {...request, - patch: {text: "Old text"}, clear_fields: [], operation_id: "no-change"}); + const noChangeRequest = {...request, + patch: {text: "Old text"}, clear_fields: [], operation_id: "no-change"}; + const result = await executeCoordinationTodoUpdate(store, noChangeRequest); assert.equal(result.status, "no_change"); const after = await store.loadAuthority(); assert.equal(after.status, "loaded"); if (before.status !== "loaded" || after.status !== "loaded") return; assert.deepEqual(after.head, before.head); assert.notEqual(after.provider_revision, before.provider_revision); + // A consumed no-change identity must not overwrite a later edit on retry. + assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "applied"); + const later = await store.loadAuthority(); + const replay = await executeCoordinationTodoUpdate(store, noChangeRequest); + assert.equal(replay.status, "replayed"); + assert.equal(replay.changed, false); + assert.deepEqual(await store.loadAuthority(), later); + // Identical parameters under a new attempt are new work, not stale replay. + assert.equal((await executeCoordinationTodoUpdate(store, {...noChangeRequest, + operation_id: "independent-reset"})).status, "applied"); });