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(() => ({
+58
View File
@@ -474,6 +474,64 @@ describe("descriptor-driven HTTP execution lifetime", () => {
});
});
it("keeps a dispatched command MAYBE_APPLIED when a retry-time fence lands between scope checks", async () => {
// Attempt 1 dispatches an idempotent command and receives 429. The retry
// sleep resolves, the loop-entry scope check is still current, and only the
// pre-dispatch final invariant observes the fence.
let armed = false;
let checksAfterArming = 0;
const idempotentCommand: InstalledHttpContract<unknown, unknown, unknown> = {
...createInstalled,
contract: {
...createInstalled.contract,
retrySemantics: "IDEMPOTENT" as const,
},
frontend: { ...createInstalled.frontend, retryBudget: 1 as const },
};
const racingScope = Object.freeze({
...scope,
isCurrent: () => {
if (!armed) return true;
checksAfterArming += 1;
// The retry loop entry still observes a current scope; the pre-dispatch
// final invariant is the first observation of the fence.
return checksAfterArming <= 1;
},
});
const fetcher = vi.fn(async () =>
Response.json({ type: "about:blank", title: "slow down", status: 429 }, {
status: 429,
}),
);
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 1,
attachCredentials: () => ({ kind: "READY", headers: {} }),
fetcher,
sleep: async () => {
armed = true;
},
random: () => 0,
});
const outcome = await executor.execute(
idempotentCommand,
{ name: "created" },
{
routeId: ROUTE_ID,
scope: racingScope,
intent: mutationIntent({ idempotencyKey: null }),
},
);
expect(fetcher).toHaveBeenCalledTimes(1);
expect(outcome).toMatchObject({
kind: "CONTRACT_VIOLATION",
violation: { kind: "SCOPE_FENCED" },
effect: "MAYBE_APPLIED",
});
});
it("settles a credential hang at the total operation deadline", async () => {
vi.useFakeTimers();
let settled = false;