fix: bind onboarding cache to profile identity

This commit is contained in:
Jesse_Chen
2026-07-19 23:26:58 +08:00
parent 308e5d532a
commit e13d595d6b
7 changed files with 539 additions and 39 deletions
@@ -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.
@@ -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: "请使用当前评估结果继续。" },
+45 -22
View File
@@ -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);
@@ -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
@@ -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<Payload> = {
readonly identity: OnboardingCacheIdentity;
readonly observedVersion: string | null;
readonly generatedAtMs: number;
readonly nowMs: number;
readonly cachedPayload: Payload | null;
};
type OnboardingCacheDecision<Payload> =
| { 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<Payload>(
observation: OnboardingCacheObservation<Payload>,
): OnboardingCacheDecision<Payload> {
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,
};
}
@@ -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);
});
}
@@ -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,
});
}
});