diff --git a/.superpowers/sdd/onboarding-cache-fix-report.md b/.superpowers/sdd/onboarding-cache-fix-report.md new file mode 100644 index 00000000..1ff6448b --- /dev/null +++ b/.superpowers/sdd/onboarding-cache-fix-report.md @@ -0,0 +1,152 @@ +# Onboarding cache identity final-review fix + +## Outcome + +- Status: `DONE_WITH_CONCERNS` +- Base SHA: `308e5d532ae10846ef196cf21eea3efc49a843dd` +- Commit: `fix: bind onboarding cache to profile identity` +- Resulting commit SHA: reported in the task handoff. A Git commit cannot embed its own resulting SHA without changing that SHA. + +The onboarding cache now derives deterministic SHA-256 ready and pending versions from every +profile field used by the route's completeness/generation decision. The database version contains +no raw name, birth date/time, or location value. A ready value is accepted only for the current +profile identity, a fresh pending value blocks only that same identity, and a completion can update +the cache only while its exact pending identity still owns the row. + +The claim write compares both the observed old version and the observed +`onboarding_generated_at`. The timestamp predicate preserves the existing two-minute TTL while +ensuring concurrent reclaimers cannot both acquire an expired deterministic pending identity. + +The requested candidate-completion negative matrix exposed one real policy gap: the pure validator +accepted any nonempty stored owner. It now receives the authenticated user ID and requires exact +owner equality in addition to the route's owner-scoped query. No confidence, status, or billing +policy changed. + +## Files + +- `frontend/src/app/api/onboarding/route.ts` +- `frontend/src/lib/onboarding-cache-policy.ts` +- `frontend/tests/onboarding-cache-policy.test.ts` +- `frontend/src/app/api/birth-time-candidate-completion/route.ts` +- `frontend/src/lib/birth-time-candidate-completion.ts` +- `frontend/tests/birth-time-candidate-completion.test.ts` +- `.superpowers/sdd/onboarding-cache-fix-report.md` + +Unrelated `.superpowers/sdd/task-1-report.md` and `.omo/` changes were preserved and excluded from +the staged commit. + +## TDD evidence + +### RED — profile-aware cache policy + +The shell's bare `node` command first failed with exit 127, so it was not counted as behavioral +evidence. Using the installed Node 24.14.0 runtime: + +```text +/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/onboarding-cache-policy.test.ts +``` + +Result before the policy module existed: exit 1, `ERR_MODULE_NOT_FOUND` for +`src/lib/onboarding-cache-policy.ts`; 0 passed, 1 failed. The missing public policy seam was the +expected RED. + +### RED — candidate owner matrix + +```text +/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-candidate-completion.test.ts +``` + +Result before owner binding: exit 1; 13 passed, 2 failed. Both failures returned `"04:53"` instead +of `null` for (1) a case owned by another user and (2) a request authenticated as another user. + +### GREEN — focused behavior + +```text +/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/onboarding-cache-policy.test.ts tests/birth-time-candidate-completion.test.ts +``` + +Result: exit 0; 20 passed, 0 failed. Coverage includes cached A to B, active-pending A to B, stale A +completion after B claims, current ready/pending behavior, TTL reclaim, wrong case/owner, all three +result-ID positions, missing winner/time, nonterminal action, and illegal status/action pairings. + +## Final verification + +Full frontend tests: + +```text +/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts +``` + +Result: exit 0; 444 passed, 0 failed. + +Changed-file ESLint: + +```text +/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node node_modules/eslint/bin/eslint.js src/app/api/onboarding/route.ts src/lib/onboarding-cache-policy.ts tests/onboarding-cache-policy.test.ts src/app/api/birth-time-candidate-completion/route.ts src/lib/birth-time-candidate-completion.ts tests/birth-time-candidate-completion.test.ts +``` + +Result: exit 0; zero diagnostics. + +Production build with the repository's CI public placeholders and the webpack fallback: + +```text +NEXT_PUBLIC_SUPABASE_URL=https://ci-placeholder.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=ci-placeholder PATH=/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin:$PATH ./node_modules/.bin/next build --webpack +``` + +Result: exit 0. Next 16.2.10 compiled successfully, finished TypeScript, generated 22/22 pages, +and listed `/api/onboarding` and `/api/birth-time-candidate-completion` as dynamic routes. + +Direct TypeScript diagnostic: + +```text +PATH=/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin:$PATH ./node_modules/.bin/tsc --noEmit +``` + +Result: exit 2 only for eight pre-existing ES2018 regexp-flag diagnostics in unrelated +`tests/consultation-entrypoint.test.ts` and `tests/profile-persistence.test.ts`. No changed file was +reported. The successful Next production build separately completed its TypeScript phase. + +Quality checks: + +```text +git diff --check +``` + +Result: exit 0, no output. Pure LOC counts are 182, 82, 107, 62, 38, and 173 for the six changed +TypeScript files respectively; every file is below the 200-line healthy ceiling. + +Staged audit: + +```text +git diff --cached --check +git diff --cached --name-status +git diff --cached --stat +``` + +Result: the diff check passed and the staged set contained exactly this report plus the six +TypeScript implementation/test files listed above (`539 insertions, 39 deletions`). The unrelated +modified `.superpowers/sdd/task-1-report.md` and untracked `.omo/` tree remained unstaged. + +## Self-review + +- Single responsibility: the new module owns only onboarding cache identity and transition policy. +- Boundary purity: the route continues parsing cache payloads with Zod before returning them; the + policy receives the parsed payload or `null`. +- Variant discrimination: the route exhaustively switches over ready, pending, and claim. +- Privacy: persisted identities contain a version prefix plus SHA-256 only. +- Atomicity: claims compare the observed version/timestamp; completions compare the exact pending + identity and cannot overwrite a newer profile claim. +- Inputs: the route selects all eight fields included in the fingerprint. +- Candidate policy: only the newly exposed owner mismatch changed; existing terminal/confidence + pairings and representative-time rules remain intact. + +## Concerns + +- The mandatory repository pre-work command remains blocked by known host issues: system Python + 3.9 cannot import a PEP 604 annotation, system Python has no pytest, and terminal remote visibility + is blocked. No remote-synchronization claim is made. +- Default Turbopack rejects this worktree's externally pointed `frontend/node_modules` symlink. The + webpack production build with the exact CI public placeholders passed completely. +- The programming skill's standalone no-excuse checker could not resolve its own `typescript` + dependency from the external skill cache. Changed-file ESLint, direct pattern audit, full tests, + and the production TypeScript build were run instead. diff --git a/frontend/src/app/api/birth-time-candidate-completion/route.ts b/frontend/src/app/api/birth-time-candidate-completion/route.ts index 90b4099b..d86196bb 100644 --- a/frontend/src/app/api/birth-time-candidate-completion/route.ts +++ b/frontend/src/app/api/birth-time-candidate-completion/route.ts @@ -33,7 +33,7 @@ export async function POST(request: Request) { .eq("id", parsed.data.caseId) .eq("user_id", user.id) .maybeSingle(); - const time = candidateWorkingTime(stored, parsed.data); + const time = candidateWorkingTime(stored, { ...parsed.data, userId: user.id }); if (caseError || !time) { return NextResponse.json( { error: "候选结果已变化", message: "请使用当前评估结果继续。" }, diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts index ed6b1a11..16941986 100644 --- a/frontend/src/app/api/onboarding/route.ts +++ b/frontend/src/app/api/onboarding/route.ts @@ -1,5 +1,10 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { + createOnboardingCacheIdentity, + createOnboardingCompletionTransition, + decideOnboardingCache, +} from "@/lib/onboarding-cache-policy"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getOnboardingAgent } from "@/mastra"; @@ -8,10 +13,6 @@ import { defaultLanguageModel } from "@/mastra/model"; export const runtime = "nodejs"; export const maxDuration = 30; -const ONBOARDING_VERSION = "ayanam-onboarding-v3"; -const ONBOARDING_PENDING_VERSION = `${ONBOARDING_VERSION}:pending`; -const ONBOARDING_CLAIM_TTL_MS = 2 * 60 * 1000; - const onboardingSchema = z.object({ greeting: z.string().trim().min(8).max(180), suggestions: z.tuple([ @@ -95,34 +96,55 @@ export async function POST() { ); } - if (profile.onboarding_version === ONBOARDING_VERSION) { - const cached = onboardingSchema.safeParse(profile.onboarding_payload); - if (cached.success) { - return NextResponse.json({ ...cached.data, source: "cache" }); - } - } - - const generatedAt = typeof profile.onboarding_generated_at === "string" + const identity = createOnboardingCacheIdentity({ + name: profile.name, + birthDate: profile.birth_date, + birthTime: profile.birth_time, + activeBirthTime: profile.active_birth_time, + birthTimeStatus: profile.birth_time_status, + countryCode: profile.country_code, + provinceCode: profile.province_code, + cityCode: profile.city_code, + }); + const cached = onboardingSchema.safeParse(profile.onboarding_payload); + const generatedAtMs = typeof profile.onboarding_generated_at === "string" ? Date.parse(profile.onboarding_generated_at) : 0; - const activeClaim = profile.onboarding_version === ONBOARDING_PENDING_VERSION - && Number.isFinite(generatedAt) - && Date.now() - generatedAt < ONBOARDING_CLAIM_TTL_MS; - if (activeClaim) { - return NextResponse.json({ ...fallbackPayload, source: "pending" }); + const cacheDecision = decideOnboardingCache({ + identity, + observedVersion: profile.onboarding_version, + generatedAtMs, + nowMs: Date.now(), + cachedPayload: cached.success ? cached.data : null, + }); + + switch (cacheDecision.kind) { + case "ready": + return NextResponse.json({ ...cacheDecision.payload, source: "cache" }); + case "pending": + return NextResponse.json({ ...fallbackPayload, source: "pending" }); + case "claim": + break; + default: { + const exhaustiveDecision: never = cacheDecision; + throw exhaustiveDecision; + } } const claimTime = new Date().toISOString(); let claim = admin .from("profiles") .update({ - onboarding_version: ONBOARDING_PENDING_VERSION, + onboarding_version: cacheDecision.pendingVersion, onboarding_generated_at: claimTime, }) .eq("id", user.id); - claim = profile.onboarding_version === null + claim = cacheDecision.expectedVersion === null ? claim.is("onboarding_version", null) - : claim.eq("onboarding_version", profile.onboarding_version); + : claim.eq("onboarding_version", cacheDecision.expectedVersion); + claim = profile.onboarding_generated_at === null + ? claim.is("onboarding_generated_at", null) + : claim.eq("onboarding_generated_at", profile.onboarding_generated_at); const { data: claimedProfile, error: claimError } = await claim.select("id").maybeSingle(); if (claimError) { return NextResponse.json( @@ -160,15 +182,16 @@ export async function POST() { } } + const completionTransition = createOnboardingCompletionTransition(identity); const { error: cacheError } = await admin .from("profiles") .update({ onboarding_payload: payload, - onboarding_version: ONBOARDING_VERSION, + onboarding_version: completionTransition.readyVersion, onboarding_generated_at: new Date().toISOString(), }) .eq("id", user.id) - .eq("onboarding_version", ONBOARDING_PENDING_VERSION); + .eq("onboarding_version", completionTransition.expectedVersion); if (cacheError) { console.warn("[onboarding] unable to cache generated content", cacheError.message); diff --git a/frontend/src/lib/birth-time-candidate-completion.ts b/frontend/src/lib/birth-time-candidate-completion.ts index e13b54e5..a018bf2d 100644 --- a/frontend/src/lib/birth-time-candidate-completion.ts +++ b/frontend/src/lib/birth-time-candidate-completion.ts @@ -1,4 +1,5 @@ type CandidateCompletionRequest = { + readonly userId: string; readonly caseId: string; readonly resultId: string; readonly time: string; @@ -29,8 +30,7 @@ export function candidateWorkingTime( && assessment?.status === "candidate"; return assessment?.id === request.caseId - && typeof assessment?.user_id === "string" - && assessment.user_id.length > 0 + && assessment?.user_id === request.userId && terminalStatusMatches && assessment.candidate_result_id === request.resultId && action?.resultId === request.resultId diff --git a/frontend/src/lib/onboarding-cache-policy.ts b/frontend/src/lib/onboarding-cache-policy.ts new file mode 100644 index 00000000..5cc73ac9 --- /dev/null +++ b/frontend/src/lib/onboarding-cache-policy.ts @@ -0,0 +1,94 @@ +import { createHash } from "node:crypto"; + +const ONBOARDING_VERSION = "ayanam-onboarding-v3"; +export const ONBOARDING_CLAIM_TTL_MS = 2 * 60 * 1000; + +type OnboardingProfileInput = { + readonly name: string | null; + readonly birthDate: string | null; + readonly birthTime: string | null; + readonly activeBirthTime: string | null; + readonly birthTimeStatus: string | null; + readonly countryCode: string | null; + readonly provinceCode: string | null; + readonly cityCode: string | null; +}; + +export type OnboardingCacheIdentity = { + readonly readyVersion: string; + readonly pendingVersion: string; +}; + +export type OnboardingCompletionTransition = { + readonly expectedVersion: string; + readonly readyVersion: string; +}; + +type OnboardingCacheObservation = { + readonly identity: OnboardingCacheIdentity; + readonly observedVersion: string | null; + readonly generatedAtMs: number; + readonly nowMs: number; + readonly cachedPayload: Payload | null; +}; + +type OnboardingCacheDecision = + | { readonly kind: "ready"; readonly payload: Payload } + | { readonly kind: "pending" } + | { + readonly kind: "claim"; + readonly expectedVersion: string | null; + readonly pendingVersion: string; + }; + +export function createOnboardingCacheIdentity( + profile: OnboardingProfileInput, +): OnboardingCacheIdentity { + const fingerprint = createHash("sha256") + .update(JSON.stringify([ + profile.name, + profile.birthDate, + profile.birthTime, + profile.activeBirthTime, + profile.birthTimeStatus, + profile.countryCode, + profile.provinceCode, + profile.cityCode, + ])) + .digest("hex"); + + return { + readyVersion: `${ONBOARDING_VERSION}:${fingerprint}`, + pendingVersion: `${ONBOARDING_VERSION}:pending:${fingerprint}`, + }; +} + +export function decideOnboardingCache( + observation: OnboardingCacheObservation, +): OnboardingCacheDecision { + if (observation.observedVersion === observation.identity.readyVersion + && observation.cachedPayload !== null) { + return { kind: "ready", payload: observation.cachedPayload }; + } + + if (observation.observedVersion === observation.identity.pendingVersion + && Number.isFinite(observation.generatedAtMs) + && observation.nowMs - observation.generatedAtMs < ONBOARDING_CLAIM_TTL_MS) { + return { kind: "pending" }; + } + + return { + kind: "claim", + expectedVersion: observation.observedVersion, + pendingVersion: observation.identity.pendingVersion, + }; +} + +export function createOnboardingCompletionTransition( + identity: OnboardingCacheIdentity, +): OnboardingCompletionTransition { + return { + expectedVersion: identity.pendingVersion, + readyVersion: identity.readyVersion, + }; +} diff --git a/frontend/tests/birth-time-candidate-completion.test.ts b/frontend/tests/birth-time-candidate-completion.test.ts index c8a17661..1adaa7c8 100644 --- a/frontend/tests/birth-time-candidate-completion.test.ts +++ b/frontend/tests/birth-time-candidate-completion.test.ts @@ -35,25 +35,27 @@ const lowTerminalCase = { }, }; +const completionRequest = { + userId: terminalCase.user_id, + caseId: terminalCase.id, + resultId: terminalCase.candidate_result_id, + time: "04:53", +} as const; + test("candidate completion only accepts the persisted terminal representative time", () => { assert.equal(candidateWorkingTime(terminalCase, { - caseId: terminalCase.id, - resultId: terminalCase.candidate_result_id, - time: "04:53", + ...completionRequest, }), "04:53"); assert.equal(candidateWorkingTime(terminalCase, { - caseId: terminalCase.id, - resultId: terminalCase.candidate_result_id, + ...completionRequest, time: "04:54", }), null); }); test("accepts a matching low-confidence result from the rectifying state", () => { assert.equal(candidateWorkingTime(lowTerminalCase, { - caseId: terminalCase.id, - resultId: terminalCase.candidate_result_id, - time: "04:53", + ...completionRequest, }), "04:53"); }); @@ -68,9 +70,7 @@ test("does not accept a medium terminal action from the rectifying state", () => }, }, }, { - caseId: terminalCase.id, - resultId: terminalCase.candidate_result_id, - time: "04:53", + ...completionRequest, }), null); }); @@ -79,8 +79,105 @@ test("non-terminal cases cannot be adopted for consultation", () => { ...terminalCase, turn_state: { nextAction: { kind: "ask_dynamic_choice" } }, }, { - caseId: terminalCase.id, - resultId: terminalCase.candidate_result_id, - time: "04:53", + ...completionRequest, }), null); }); + +const rejectedCompletions = [ + { + name: "case owned by another user", + stored: { ...terminalCase, user_id: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" }, + request: completionRequest, + }, + { + name: "request from another user", + stored: terminalCase, + request: { ...completionRequest, userId: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" }, + }, + { + name: "wrong case ID", + stored: terminalCase, + request: { ...completionRequest, caseId: "c84052ca-bcea-40a8-a32a-56980bbf7b22" }, + }, + { + name: "wrong persisted result ID", + stored: { ...terminalCase, candidate_result_id: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" }, + request: completionRequest, + }, + { + name: "wrong action result ID", + stored: { + ...terminalCase, + turn_state: { + nextAction: { + ...terminalCase.turn_state.nextAction, + resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0", + }, + }, + }, + request: completionRequest, + }, + { + name: "wrong requested result ID", + stored: terminalCase, + request: { ...completionRequest, resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" }, + }, + { + name: "missing winning segment", + stored: { ...terminalCase, candidate_result: { confidence: "medium" } }, + request: completionRequest, + }, + { + name: "missing representative time", + stored: { + ...terminalCase, + candidate_result: { confidence: "medium", winningSegment: {} }, + }, + request: completionRequest, + }, + { + name: "low-result action paired with candidate status", + stored: { + ...terminalCase, + turn_state: { + nextAction: { + kind: "present_low_result", + resultId: terminalCase.candidate_result_id, + }, + }, + }, + request: completionRequest, + }, + { + name: "medium-result action paired with rectifying status", + stored: { + ...lowTerminalCase, + turn_state: { + nextAction: { + kind: "present_medium_result", + resultId: terminalCase.candidate_result_id, + }, + }, + }, + request: completionRequest, + }, + { + name: "candidate-saved action paired with rectifying status", + stored: { + ...lowTerminalCase, + turn_state: { + nextAction: { + kind: "candidate_saved", + resultId: terminalCase.candidate_result_id, + }, + }, + }, + request: completionRequest, + }, +] as const; + +for (const scenario of rejectedCompletions) { + test(`rejects candidate completion with ${scenario.name}`, () => { + assert.equal(candidateWorkingTime(scenario.stored, scenario.request), null); + }); +} diff --git a/frontend/tests/onboarding-cache-policy.test.ts b/frontend/tests/onboarding-cache-policy.test.ts new file mode 100644 index 00000000..d5ed6914 --- /dev/null +++ b/frontend/tests/onboarding-cache-policy.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createOnboardingCacheIdentity, + createOnboardingCompletionTransition, + decideOnboardingCache, +} from "../src/lib/onboarding-cache-policy.ts"; + +const profileA = { + name: "林遥", + birthDate: "1990-06-15", + birthTime: "12:30", + activeBirthTime: "12:30", + birthTimeStatus: "confirmed", + countryCode: "CN", + provinceCode: "110000", + cityCode: "110100", +} as const; + +test("changed profile cannot reuse a ready cache from the previous profile", () => { + // Given: profile A has a valid ready cache. + const identityA = createOnboardingCacheIdentity(profileA); + const identityB = createOnboardingCacheIdentity({ ...profileA, name: "周宁" }); + + // When: profile B observes A's ready version. + const decision = decideOnboardingCache({ + identity: identityB, + observedVersion: identityA.readyVersion, + generatedAtMs: Date.parse("2026-07-19T10:00:00.000Z"), + nowMs: Date.parse("2026-07-19T10:00:01.000Z"), + cachedPayload: {}, + }); + + // Then: B must claim its own pending identity from the exact observed version. + assert.deepEqual(decision, { + kind: "claim", + expectedVersion: identityA.readyVersion, + pendingVersion: identityB.pendingVersion, + }); + assert.notEqual(identityA.readyVersion, identityB.readyVersion); + assert.doesNotMatch(identityB.readyVersion, /周宁|1990|12:30|110000/); +}); + +test("changed profile cannot wait on the previous profile's active pending claim", () => { + // Given: profile A has a fresh pending claim. + const identityA = createOnboardingCacheIdentity(profileA); + const identityB = createOnboardingCacheIdentity({ ...profileA, activeBirthTime: "12:45" }); + + // When: profile B observes A's pending version within the claim TTL. + const decision = decideOnboardingCache({ + identity: identityB, + observedVersion: identityA.pendingVersion, + generatedAtMs: Date.parse("2026-07-19T10:00:00.000Z"), + nowMs: Date.parse("2026-07-19T10:00:01.000Z"), + cachedPayload: null, + }); + + // Then: B claims immediately instead of returning A's pending response. + assert.deepEqual(decision, { + kind: "claim", + expectedVersion: identityA.pendingVersion, + pendingVersion: identityB.pendingVersion, + }); +}); + +test("stale profile completion loses ownership after the current profile claims", () => { + // Given: B has replaced A's pending identity in the row. + const identityA = createOnboardingCacheIdentity(profileA); + const identityB = createOnboardingCacheIdentity({ ...profileA, cityCode: "310100" }); + const rowVersionAfterBClaims = identityB.pendingVersion; + + // When: each completion prepares an exact compare-and-set transition. + const completionA = createOnboardingCompletionTransition(identityA); + const completionB = createOnboardingCompletionTransition(identityB); + + // Then: A cannot match the row, while B can commit its own ready identity. + assert.notEqual(rowVersionAfterBClaims, completionA.expectedVersion); + assert.equal(rowVersionAfterBClaims, completionB.expectedVersion); + assert.equal(completionB.readyVersion, identityB.readyVersion); +}); + +test("current profile accepts only valid ready content and an active current pending claim", () => { + // Given: one current profile identity and a valid cached payload. + const identity = createOnboardingCacheIdentity(profileA); + const payload = { greeting: "current" }; + const nowMs = Date.parse("2026-07-19T10:01:00.000Z"); + + // When/Then: exact ready and fresh pending identities retain their existing behavior. + assert.deepEqual(decideOnboardingCache({ + identity, + observedVersion: identity.readyVersion, + generatedAtMs: nowMs - 60_000, + nowMs, + cachedPayload: payload, + }), { kind: "ready", payload }); + assert.deepEqual(decideOnboardingCache({ + identity, + observedVersion: identity.pendingVersion, + generatedAtMs: nowMs - 60_000, + nowMs, + cachedPayload: null, + }), { kind: "pending" }); +}); + +test("invalid ready content and expired pending claims are reclaimed", () => { + // Given: the current profile sees unusable ready content or an expired pending claim. + const identity = createOnboardingCacheIdentity(profileA); + const nowMs = Date.parse("2026-07-19T10:03:00.000Z"); + const observations = [ + { + identity, + observedVersion: identity.readyVersion, + generatedAtMs: nowMs - 1_000, + nowMs, + cachedPayload: null, + }, + { + identity, + observedVersion: identity.pendingVersion, + generatedAtMs: nowMs - 180_000, + nowMs, + cachedPayload: null, + }, + ] as const; + + // When/Then: each stale state becomes a compare-and-set claim for the current identity. + for (const observation of observations) { + assert.deepEqual(decideOnboardingCache(observation), { + kind: "claim", + expectedVersion: observation.observedVersion, + pendingVersion: identity.pendingVersion, + }); + } +});