fix: preserve command effect certainty across retries

Separate per-attempt physical state from the logical execution history. The
executor now keeps one monotonic certainty accumulator joined through
joinMutationEffectCertainty, records MAYBE_APPLIED at dispatch, and reads the
accumulator from every retry-loop fence, final-invariant, cancellation and
timeout return.

A retry-time scope fence landing between the loop-entry check and the
pre-dispatch invariant can no longer downgrade an already dispatched command to
NOT_STARTED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-13 23:01:48 +09:00
co-authored by Claude Opus 5
parent 4e87bacdf3
commit e06e4377ca
6 changed files with 158 additions and 62 deletions
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.ts";
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
import { joinMutationEffectCertainty } from "../../src/adapters/http/http-effect-certainty.ts";
import {
entityQueryKeys,
TEST_HTTP_CONTRACT,
@@ -49,6 +50,22 @@ function testClient(options: HttpDependencies) {
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
}
describe("mutation effect certainty lattice", () => {
it.each([
["NOT_STARTED", "NOT_APPLIED", "NOT_APPLIED"],
["NOT_APPLIED", "NOT_STARTED", "NOT_APPLIED"],
["NOT_STARTED", "MAYBE_APPLIED", "MAYBE_APPLIED"],
["MAYBE_APPLIED", "NOT_STARTED", "MAYBE_APPLIED"],
["MAYBE_APPLIED", "NOT_APPLIED", "MAYBE_APPLIED"],
["NOT_APPLIED", "MAYBE_APPLIED", "MAYBE_APPLIED"],
["MAYBE_APPLIED", "APPLIED_CONFIRMED", "APPLIED_CONFIRMED"],
["APPLIED_CONFIRMED", "NOT_STARTED", "APPLIED_CONFIRMED"],
["APPLIED_CONFIRMED", "MAYBE_APPLIED", "APPLIED_CONFIRMED"],
] as const)("joins %s with %s as %s", (current, observed, expected) => {
expect(joinMutationEffectCertainty(current, observed)).toBe(expected);
});
});
describe("HTTP operation execution contract", () => {
it("permits a keyless command intent but rejects an unexpected key before dispatch", async () => {
const attachCredentials = vi.fn(() => ({