From e06e4377ca236c70b9133cb44fded5e5f744376d Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 13 Aug 2026 23:01:48 +0900 Subject: [PATCH] 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) --- ...-transport-selection-and-rest-execution.md | 16 +++ docs/operations/adapter-remediation-ledger.md | 2 +- src/adapters/http/http-effect-certainty.ts | 24 ++++ src/adapters/http/http-execution-v3.ts | 103 +++++++----------- .../http-execution-contract.test.ts | 17 +++ tests/unit/http-execution-v3.test.ts | 58 ++++++++++ 6 files changed, 158 insertions(+), 62 deletions(-) diff --git a/docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md b/docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md index c7c7acc..f9c7fd6 100644 --- a/docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md +++ b/docs/architecture/decisions/VD-23-api-transport-selection-and-rest-execution.md @@ -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을 한 번 설치하고 diff --git a/docs/operations/adapter-remediation-ledger.md b/docs/operations/adapter-remediation-ledger.md index e4039b2..fa9ad1b 100644 --- a/docs/operations/adapter-remediation-ledger.md +++ b/docs/operations/adapter-remediation-ledger.md @@ -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 | — | diff --git a/src/adapters/http/http-effect-certainty.ts b/src/adapters/http/http-effect-certainty.ts index 824c3a3..d008b70 100644 --- a/src/adapters/http/http-effect-certainty.ts +++ b/src/adapters/http/http-effect-certainty.ts @@ -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> = + 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 = Readonly<{ status: number; problem: Problem; diff --git a/src/adapters/http/http-execution-v3.ts b/src/adapters/http/http-execution-v3.ts index 1b80185..a02c662 100644 --- a/src/adapters/http/http-execution-v3.ts +++ b/src/adapters/http/http-execution-v3.ts @@ -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 => + 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( transportFailure( "RESPONSE_STREAM_FAILURE", false, - attemptState, - isCommand, + certaintyForAbandonedAttempt(attemptState, isCommand), ), probe.code, ); @@ -990,8 +984,7 @@ async function admitResponse( : 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( effect: string, isCommand: boolean, @@ -1365,10 +1348,8 @@ function cancelled( function transportFailure( kind: HttpTransportFailure["kind"], retryable: boolean, - attemptState: PhysicalAttemptState, - isCommand: boolean, + effect: MutationEffectCertainty, ): HttpExecutionOutcome { - const effect = certaintyForAbandonedAttempt(attemptState, isCommand); return Object.freeze({ kind: "TRANSPORT_FAILURE" as const, failure: Object.freeze({ kind, retryable }), diff --git a/tests/integration/http-execution-contract.test.ts b/tests/integration/http-execution-contract.test.ts index 1079ca5..d975beb 100644 --- a/tests/integration/http-execution-contract.test.ts +++ b/tests/integration/http-execution-contract.test.ts @@ -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(() => ({ diff --git a/tests/unit/http-execution-v3.test.ts b/tests/unit/http-execution-v3.test.ts index e9b165e..1b90e52 100644 --- a/tests/unit/http-execution-v3.test.ts +++ b/tests/unit/http-execution-v3.test.ts @@ -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 = { + ...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;