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
@@ -262,6 +262,22 @@ auth-required operation은 session state가 `authenticated`가 아니면 fetch
`integration-failed`, `unauthenticated`와 credential attach rejection을 anonymous
request로 downgrade하지 않는다.
#### 5-0. Logical effect certainty는 단조 증가한다
`PhysicalAttemptState`는 현재 attempt만 설명한다. logical execution 전체에는
별도의 monotonic accumulator를 두고 `joinMutationEffectCertainty`로 join한다.
join 순서는 보수적이다.
```text
NOT_STARTED < NOT_APPLIED < MAYBE_APPLIED < APPLIED_CONFIRMED
```
`fetch()` dispatch 시점에 command는 즉시 `MAYBE_APPLIED`를 기록한다. 이후 retry
loop entry, pre-dispatch final invariant, scope fence, cancellation, timeout
return은 모두 accumulator를 읽는다. 아직 보내지 않은 새 retry가 있다는 이유로
전체 logical operation을 `NOT_STARTED`로 되돌리지 않는다. query operation은
`NOT_APPLICABLE`로 남고 이 lattice를 쓰지 않는다.
#### 5-1. Installed auth profile registry (V3 집행)
`installRestAuthProfileRegistry()`가 composition 시점에 profile을 한 번 설치하고
@@ -74,7 +74,7 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at
| --- | --- | --- | --- | --- | --- | --- |
| N-01 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-observability.test.ts` | `fix: restore V3 HTTP observability` | `FIXED_NOT_RELEASED` | diagnostics/telemetry producer gate regression | Red 5/5 failed → green 5/5; `check:diagnostics` PASS (8 diagnostics, 5 telemetry producers); `check:types` PASS; `check:architecture` PASS |
| N-02 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-auth-profile.test.ts` | `fix: enforce installed HTTP auth profiles` | `FIXED_NOT_RELEASED` | authenticated request 4xx spike after profile enforcement | Red suite failed to load (`installRestAuthProfileRegistry` absent) → green 7/7; `check:types` PASS; `check:architecture` PASS; `lint` PASS; unit+integration+features 1560 passed with only the pre-existing environmental `ci-artifact-contract` failures |
| N-03 | Live V3 path | `corepack pnpm exec vitest run tests/unit/http-execution-v3.test.ts` | — | `NOT_STARTED` | command effect verdict regression | — |
| N-03 | Live V3 path | `corepack pnpm exec vitest run tests/unit/http-execution-v3.test.ts -t "retry-time fence"` | `fix: preserve command effect certainty across retries` | `FIXED_NOT_RELEASED` | command effect verdict regression | Red reproduced `SCOPE_FENCED` with `NOT_STARTED` after one dispatched attempt → green `MAYBE_APPLIED`; lattice table 9/9; `check:types` PASS; `lint` PASS |
| N-04 | Live composition teardown | `corepack pnpm exec vitest run tests/unit/telemetry.test.ts` | — | `NOT_STARTED` | telemetry delivery loss after teardown change | — |
| N-05 | Rollout blocker (sidecar not composed) | `corepack pnpm exec vitest run tests/unit/conditional-validator-store.test.ts` | — | `NOT_STARTED` | persisted validator key incompatibility | — |
| N-06 | Legacy V2 rollback seam | `corepack pnpm exec vitest run tests/integration/http-client.test.ts` | — | `NOT_STARTED` | legacy keyed command rejection spike | — |
@@ -48,6 +48,30 @@ export function certaintyForAbandonedAttempt(
}
}
/**
* §8.7. The conservative certainty lattice for one logical execution.
*
* `PhysicalAttemptState` describes only the attempt in flight. A new retry that
* has not been sent yet must never lower what an earlier attempt already
* established, so the executor joins observations into a monotonic accumulator.
*/
const CERTAINTY_RANK: Readonly<Record<MutationEffectCertainty, number>> =
Object.freeze({
NOT_STARTED: 0,
NOT_APPLIED: 1,
MAYBE_APPLIED: 2,
APPLIED_CONFIRMED: 3,
});
export function joinMutationEffectCertainty(
current: MutationEffectCertainty,
observed: MutationEffectCertainty,
): MutationEffectCertainty {
return CERTAINTY_RANK[observed] > CERTAINTY_RANK[current]
? observed
: current;
}
export type ProblemEffectInput<Problem> = Readonly<{
status: number;
problem: Problem;
+42 -61
View File
@@ -30,6 +30,8 @@ import {
import {
certaintyForAbandonedAttempt,
classifyProblemEffect,
joinMutationEffectCertainty,
type MutationEffectCertainty,
type PhysicalAttemptState,
} from "./http-effect-certainty.ts";
import { parseRetryAfter } from "./retry-policy.ts";
@@ -413,6 +415,25 @@ export function createContractHttpExecutor(
let attemptState: PhysicalAttemptState = "PREPARING";
let attempts = 0;
/**
* §8.7 / D-01. Per-attempt state stays local to the attempt; this monotonic
* accumulator is the logical execution history. A retry that has not been
* dispatched can never lower what an earlier attempt already established.
*/
let logicalCertainty: MutationEffectCertainty = "NOT_STARTED";
const observeCertainty = (observed: MutationEffectCertainty) => {
logicalCertainty = joinMutationEffectCertainty(logicalCertainty, observed);
return logicalCertainty;
};
/** Pre-dispatch failures read the accumulator, never a fresh attempt. */
const logicalPreDispatchEffect = (): HttpEffectCertainty =>
isCommand ? logicalCertainty : "NOT_APPLICABLE";
const abandonedCertainty = (): MutationEffectCertainty =>
observeCertainty(certaintyForAbandonedAttempt(attemptState, isCommand));
const abandonedTransportFailure = (
kind: HttpTransportFailure["kind"],
): HttpExecutionOutcome<WireOutput, Problem> =>
transportFailure(kind, false, abandonedCertainty());
let terminalCancellation: CancellationOwner | null = null;
const lifetimeController = new AbortController();
const forwardCallerToLifetime = () => {
@@ -571,24 +592,14 @@ export function createContractHttpExecutor(
if (patchResult === ABORTED) {
if (terminalCancellation === "SCOPE_FENCE") {
return finish(
transportFailure(
"ABORTED_BY_SCOPE",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("ABORTED_BY_SCOPE"),
"SCOPE_FENCED",
);
}
return terminalCancellation === "CALLER"
? finish(cancelled("NOT_STARTED"), "CANCELLED")
: finish(
transportFailure(
"TIMEOUT",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
@@ -647,14 +658,14 @@ export function createContractHttpExecutor(
if (callerSignal?.aborted) {
terminalCancellation ??= "CALLER";
return finish(
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
cancelled(abandonedCertainty()),
"CANCELLED",
);
}
if (!context.scope.isCurrent()) {
terminalCancellation ??= "SCOPE_FENCE";
return finish(
scopeFenced(contractViolationEffect(attemptState, isCommand)),
scopeFenced(abandonedCertainty()),
"SCOPE_FENCED",
);
}
@@ -663,7 +674,7 @@ export function createContractHttpExecutor(
const budget = remaining();
if (budget <= 0) {
return finish(
transportFailure("TIMEOUT", false, attemptState, isCommand),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
@@ -710,11 +721,11 @@ export function createContractHttpExecutor(
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
return finish(
invariantFailure === "SCOPE_FENCED"
? scopeFenced(preDispatchEffect(isCommand))
? scopeFenced(logicalPreDispatchEffect())
: violation(
"FINAL_REQUEST_INVARIANT_FAILED",
"REQUEST",
preDispatchEffect(isCommand),
logicalPreDispatchEffect(),
),
"NOT_STARTED",
);
@@ -726,6 +737,9 @@ export function createContractHttpExecutor(
attempts += 1;
const pending = fetcher(projected.request.url, init);
attemptState = "DISPATCHED";
// D-01. Dispatch is the point of no return for the logical execution.
// No later retry may claim the command never started.
observeCertainty(certaintyForAbandonedAttempt("DISPATCHED", isCommand));
response = await pending;
attemptState = "RESPONSE_HEADERS";
} catch {
@@ -734,18 +748,13 @@ export function createContractHttpExecutor(
const owner = terminalCancellation;
if (owner === "CALLER") {
return finish(
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
cancelled(abandonedCertainty()),
"CANCELLED",
);
}
if (owner === "SCOPE_FENCE") {
return finish(
transportFailure(
"ABORTED_BY_SCOPE",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("ABORTED_BY_SCOPE"),
"SCOPE_FENCED",
);
}
@@ -765,18 +774,11 @@ export function createContractHttpExecutor(
if (slept === ABORTED) {
return terminalCancellation === "CALLER"
? finish(
cancelled(
certaintyForAbandonedAttempt(attemptState, isCommand),
),
cancelled(abandonedCertainty()),
"CANCELLED",
)
: finish(
transportFailure(
"TIMEOUT",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
@@ -784,7 +786,7 @@ export function createContractHttpExecutor(
}
}
return finish(
transportFailure(kind, false, attemptState, isCommand),
abandonedTransportFailure(kind),
kind,
);
}
@@ -817,18 +819,11 @@ export function createContractHttpExecutor(
if (slept === ABORTED) {
return terminalCancellation === "CALLER"
? finish(
cancelled(
certaintyForAbandonedAttempt(attemptState, isCommand),
),
cancelled(abandonedCertainty()),
"CANCELLED",
)
: finish(
transportFailure(
"TIMEOUT",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
@@ -843,7 +838,7 @@ export function createContractHttpExecutor(
}
} catch {
return finish(
transportFailure("NETWORK_FAILURE", false, attemptState, isCommand),
abandonedTransportFailure("NETWORK_FAILURE"),
"RUNTIME_FAILURE",
);
} finally {
@@ -945,8 +940,7 @@ async function admitResponse<Input, WireOutput, Problem>(
transportFailure(
"RESPONSE_STREAM_FAILURE",
false,
attemptState,
isCommand,
certaintyForAbandonedAttempt(attemptState, isCommand),
),
probe.code,
);
@@ -990,8 +984,7 @@ async function admitResponse<Input, WireOutput, Problem>(
: transportFailure(
"RESPONSE_STREAM_FAILURE",
false,
attemptState,
isCommand,
certaintyForAbandonedAttempt(attemptState, isCommand),
),
bytes.code,
);
@@ -1323,16 +1316,6 @@ function postDispatchEffect(isCommand: boolean): HttpEffectCertainty {
return isCommand ? "MAYBE_APPLIED" : "NOT_APPLICABLE";
}
function contractViolationEffect(
attemptState: PhysicalAttemptState,
isCommand: boolean,
): HttpEffectCertainty {
if (!isCommand) return "NOT_APPLICABLE";
return attemptState === "PREPARING" || attemptState === "READY_TO_SEND"
? "NOT_STARTED"
: "MAYBE_APPLIED";
}
function unauthenticated<Value, Problem>(
effect: string,
isCommand: boolean,
@@ -1365,10 +1348,8 @@ function cancelled<Value, Problem>(
function transportFailure<Value, Problem>(
kind: HttpTransportFailure["kind"],
retryable: boolean,
attemptState: PhysicalAttemptState,
isCommand: boolean,
effect: MutationEffectCertainty,
): HttpExecutionOutcome<Value, Problem> {
const effect = certaintyForAbandonedAttempt(attemptState, isCommand);
return Object.freeze({
kind: "TRANSPORT_FAILURE" as const,
failure: Object.freeze({ kind, retryable }),
@@ -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;