From dc0077eb8910e1c73af7696c994c89f945157437 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 22 Jul 2026 00:07:42 +0800 Subject: [PATCH] fix: restore production birth-time rectification --- docs/research/pre_work_error_ledger.md | 12 ++ frontend/src/app/globals.css | 3 - frontend/src/app/page.tsx | 116 ++++++------------ .../src/lib/truth-source-runtime-identity.ts | 4 +- ...00_repair_reported_birth_time_revision.sql | 43 +++++++ .../tests/consultation-entrypoint.test.ts | 19 +-- frontend/tests/profile-persistence.test.ts | 16 +++ .../truth-source-runtime-identity.test.ts | 3 +- 8 files changed, 120 insertions(+), 96 deletions(-) create mode 100644 frontend/supabase/migrations/20260721140000_repair_reported_birth_time_revision.sql diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 91e9da69..865e8f76 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -161,3 +161,15 @@ Prevention: do not equate this local host failure with an astrology capability r ## ERR-085 | Public production health cannot prove release identity, database migration, or authenticated workflow | active 2026-07-19 `https://jyotisha.chat` homepage and `/api/health` are reachable and healthy, but those responses do not expose a deployed Git SHA, Supabase migration ledger, evidence-packet TTL policy, or an authorized test-account session. Do not treat HTTP `200` as full release acceptance. Close this only through a deployment-attested SHA plus read-only migration/TTL evidence and an authorized browser acceptance account. + +## ERR-087 | Production rectification stayed dependency-unavailable behind incomplete rollout identity | mitigated 2026-07-21 + +The production web service had no explicit V3 creation/migration/smoke rollout values, while the truth-source fallback pointed at one developer's macOS directory. Health therefore stayed `503` and a homepage rectification start failed as a dependency error even though the Python service and Supabase were reachable. + +Prevention: resolve the packaged repository root by default, deploy only an attested commit, and move creation from `smoke_only` to `public` only after the exact deployed SHA completes the authenticated synthetic smoke contract. + +## ERR-088 | Candidate birth time polluted the reported declaration and blocked profile edits | mitigated 2026-07-21 + +The journey trigger copied `birth_time` into `reported_birth_time` and then raised `reported_birth_time_is_immutable` on a later account edit. This both changed the meaning of the user's original declaration and surfaced as a generic `PATCH /api/account` 500. + +Prevention: keep reported declarations editable, never derive them from active/candidate time, repair impossible `period_only`/`unknown` rows, and enforce the source/time consistency constraint in the database. diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index cc12db54..905d22de 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -455,9 +455,6 @@ button:disabled { cursor: default; opacity: .45; } .birth-time-candidate-terminal { justify-items: start; } .birth-time-candidate-result, .birth-time-candidate-result > *, .birth-time-confirmation-panel > * { min-width: 0; max-width: 100%; } .rectification-session-surface { width: min(720px, 100%); min-width: 0; margin: 0 auto; } -.rectification-loading-state { min-height: min(52dvh, 420px); display: grid; align-content: center; justify-items: center; gap: var(--space-2); padding: var(--space-8); color: var(--color-ink-secondary); text-align: center; } -.rectification-loading-state strong { margin-top: var(--space-2); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 400; } -.rectification-loading-state > span { font-size: var(--type-body-sm); } .conversational-rectification { width: 100%; min-width: 0; max-width: 720px; display: grid; gap: var(--space-5); margin: 0 auto; overflow-wrap: anywhere; color: var(--color-ink); } .conversational-rectification > *, .conversational-rectification form, .conversational-rectification fieldset { min-width: 0; max-width: 100%; } .conversational-rectification button { min-height: 44px; max-width: 100%; overflow-wrap: anywhere; } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ad4a6eba..c75243e6 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -208,19 +208,6 @@ const rectificationCardLabels = { } as const satisfies Record; const rectifyBeforeConsultationSuggestion = "先完成生时校正"; -function RectificationLoadingState() { - return ( -
- - 正在和星星核对校正进度 - 马上回到上次的生时校正 -
- ); -} - const accountDialogTitles = { profile: "个人资料", redeem: "兑换点数", @@ -855,10 +842,11 @@ export default function Home() { }); const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0]; - const rectificationSurfaceOpen = activeSession?.sessionType === "birth_time_rectification"; + const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification"; const visibleRectificationTurn = activeSession?.id === rectificationSessionId ? rectificationInitialTurn : null; + const rectificationSurfaceOpen = activeRectificationSession && visibleRectificationTurn !== null; const visibleSessions = sessions .filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id)) .sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id))); @@ -1971,40 +1959,7 @@ export default function Home() { setRectificationPendingQuestion(requestedQuestion); setRectificationInitialTurn(null); setRectificationError(""); - - if (!reusingRectificationSession) { - setCreatingSession(true); - setSessions((current) => [rectificationSession, ...current]); - setRectificationReturnSessionId(sourceSession.id); - activeSessionIdRef.current = rectificationSession.id; - setActiveSessionId(rectificationSession.id); - setRectificationSessionId(rectificationSession.id); - setRectificationLoading(true); - try { - await persistSession(rectificationSession, "create"); - } catch (caught) { - setSessions((current) => current.filter((session) => session.id !== rectificationSession.id)); - activeSessionIdRef.current = sourceSession.id; - setActiveSessionId(sourceSession.id); - setRectificationSessionId(null); - setRectificationLoading(false); - setRequestError({ - sessionId: sourceSession.id, - message: caught instanceof Error ? caught.message : "生时校正会话未能保存到云端。", - }); - return; - } finally { - setCreatingSession(false); - } - } else { - if (sourceSession.id !== rectificationSession.id) { - setRectificationReturnSessionId(sourceSession.id); - } - activeSessionIdRef.current = rectificationSession.id; - setActiveSessionId(rectificationSession.id); - setRectificationSessionId(rectificationSession.id); - setRectificationLoading(true); - } + setRectificationLoading(true); try { let turn: ConversationalRectificationTurn; if (action !== "resume" || !account.rectificationCase) { @@ -2052,20 +2007,37 @@ export default function Home() { const boundSession = rectificationSession.rectificationCaseId === turn.caseId ? rectificationSession : { ...rectificationSession, rectificationCaseId: turn.caseId, updatedAt: timestamp() }; - setRectificationInitialTurn(turn); - synchronizeRectificationQuestion(turn, sourceSession); - if (boundSession !== rectificationSession) { + let sessionSyncFailed = false; + if (!reusingRectificationSession) { + try { + await persistSession(boundSession, "create"); + } catch { + sessionSyncFailed = true; + } + setSessions((current) => [boundSession, ...current.filter((session) => session.id !== boundSession.id)]); + } else if (boundSession !== rectificationSession) { updateSession(rectificationSession.id, () => boundSession); try { await persistSession(boundSession); } catch { - setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。"); + sessionSyncFailed = true; } } + setRectificationInitialTurn(turn); + synchronizeRectificationQuestion(turn, sourceSession); + if (sourceSession.id !== boundSession.id) { + setRectificationReturnSessionId(sourceSession.id); + } + setRectificationSessionId(boundSession.id); + activeSessionIdRef.current = boundSession.id; + setActiveSessionId(boundSession.id); + setComposerNotice(sessionSyncFailed ? "校正已经开始,但会话关联暂时未同步到云端。" : ""); } catch (caught) { - setRectificationError(caught instanceof Error + const message = caught instanceof Error ? caught.message - : "生时校正暂时无法继续,请稍后重试。"); + : "生时校正暂时无法继续,请稍后重试。"; + setRectificationError(message); + setComposerNotice(message); } finally { setRectificationLoading(false); } @@ -3066,34 +3038,16 @@ export default function Home() {
))} - {rectificationSurfaceOpen && ( + {rectificationSurfaceOpen && visibleRectificationTurn && (
- {rectificationLoading ? ( - - ) : rectificationError ? ( -
-

{rectificationError}

- -
- ) : !visibleRectificationTurn ? ( - - ) : ( - void continueRectificationOriginalQuestion(question)} - /> - )} + void continueRectificationOriginalQuestion(question)} + />
)} diff --git a/frontend/src/lib/truth-source-runtime-identity.ts b/frontend/src/lib/truth-source-runtime-identity.ts index 3297b130..126ae7ba 100644 --- a/frontend/src/lib/truth-source-runtime-identity.ts +++ b/frontend/src/lib/truth-source-runtime-identity.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; type OracleSummary = { ready: string[]; @@ -18,7 +18,7 @@ export type TruthSourceRuntimeIdentity = { claimGateStatus: "ready" | "partial_or_blocked_present" | "not_mounted"; }; -export const DEFAULT_RESEARCH_TRUTH_SOURCE_PATH = "/Users/wuyongnaren/Documents/印度占星"; +export const DEFAULT_RESEARCH_TRUTH_SOURCE_PATH = resolve(process.cwd(), ".."); function readJson(path: string): unknown { return JSON.parse(readFileSync(path, "utf8")); diff --git a/frontend/supabase/migrations/20260721140000_repair_reported_birth_time_revision.sql b/frontend/supabase/migrations/20260721140000_repair_reported_birth_time_revision.sql new file mode 100644 index 00000000..8e9940a3 --- /dev/null +++ b/frontend/supabase/migrations/20260721140000_repair_reported_birth_time_revision.sql @@ -0,0 +1,43 @@ +begin; + +-- The reported declaration belongs to the user and can be revised from the +-- profile editor. Candidate or confirmed application times remain separate. +create or replace function public.guard_birth_time_journey() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + if new.active_birth_time is distinct from old.active_birth_time then + new.birth_time := new.active_birth_time; + elsif new.birth_time is distinct from old.birth_time then + new.active_birth_time := new.birth_time; + end if; + + if new.birth_time_source is null and new.birth_time is not null then + new.birth_time_source := 'legacy_import'; + end if; + if new.birth_time_status is null and new.active_birth_time is not null then + new.birth_time_status := 'confirmed'; + end if; + + return new; +end; +$$; + +-- Earlier trigger behavior copied an applied candidate minute into the +-- reported field even when the user had declared only a period or no time. +update public.profiles +set reported_birth_time = null, + updated_at = pg_catalog.now() +where birth_time_source in ('period_only', 'unknown') + and reported_birth_time is not null; + +alter table public.profiles + drop constraint if exists profiles_reported_birth_time_source_consistency, + add constraint profiles_reported_birth_time_source_consistency check ( + birth_time_source not in ('period_only', 'unknown') + or reported_birth_time is null + ); + +commit; diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 849f9bb6..642e02b8 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -100,21 +100,22 @@ test("homepage birth-time card starts the first rectification turn without a sec assert.match(handler, /sendConversationalRectificationCommand\(\{[\s\S]*?type:\s*"start"/); }); -test("homepage birth-time card creates a dedicated session before starting the first turn", () => { +test("homepage birth-time card waits for the first turn before opening its dedicated session", () => { const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const start = source.indexOf("async function openBirthTimeRectification"); const end = source.indexOf("function handleConversationalRectificationTurn", start); const handler = source.slice(start, end); const create = handler.indexOf('createSession(modelCatalog.defaultModelId, "birth_time_rectification")'); - const persist = handler.indexOf('await persistSession(rectificationSession, "create")'); const request = handler.indexOf("sendConversationalRectificationCommand"); + const reveal = handler.indexOf("setActiveSessionId(boundSession.id)"); assert.ok(create >= 0); - assert.ok(persist > create); - assert.ok(request > persist); - assert.match(handler, /setActiveSessionId\(rectificationSession\.id\)/); + assert.ok(request > create); + assert.ok(reveal > request); + assert.doesNotMatch(handler.slice(0, request), /setActiveSessionId\(/); + assert.doesNotMatch(handler.slice(0, request), /setSessions\(/); assert.match(handler, /setRectificationReturnSessionId\(sourceSession\.id\)/); - assert.match(handler, /setSessions\(\(current\) => current\.filter\(\(session\) => session\.id !== rectificationSession\.id\)\)/); + assert.match(handler, /setRectificationInitialTurn\(turn\);[\s\S]*?setActiveSessionId\(boundSession\.id\)/); }); test("rectification cards render only inside the active rectification session", () => { @@ -136,8 +137,8 @@ test("selecting a rectification session resumes it without an intermediate confi assert.match(selectSession, /nextSession\?\.sessionType === "birth_time_rectification"/); assert.match(selectSession, /resumeRectificationSession\.current\(nextSession\)/); assert.match(source, /resumeRectificationSession\.current\(activeSession\)/); - assert.match(source, /正在和星星核对校正进度/); - assert.doesNotMatch(source, /正在加载生时校正对话|正在恢复账户里的校正进度/); + assert.doesNotMatch(source, /RectificationLoadingState|重试恢复/); + assert.match(source, /setComposerNotice\(message\)/); }); test("homepage reuses the session bound to an unfinished rectification case", () => { @@ -206,7 +207,7 @@ test("rectification mutations report pending state while session-level return co assert.match(source, /onPendingChange=\{setRectificationMutationPending\}/); assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending\}/); - assert.match(source, /disabled=\{rectificationLoading \|\| rectificationMutationPending\}/); + assert.doesNotMatch(source, /重试恢复/); assert.doesNotMatch(source, /返回并恢复原问题|返回首页/); }); diff --git a/frontend/tests/profile-persistence.test.ts b/frontend/tests/profile-persistence.test.ts index 996a2f4a..06f2c471 100644 --- a/frontend/tests/profile-persistence.test.ts +++ b/frontend/tests/profile-persistence.test.ts @@ -79,3 +79,19 @@ test("service role can read every column used by account profile upserts", () => /grant\s+select\s*\(\s*district_code\s*,\s*updated_at\s*\)\s*on\s+table\s+public\.profiles\s+to\s+service_role/i, ); }); + +test("reported birth declarations remain editable and candidate times never overwrite them", () => { + const migration = readFileSync( + new URL( + "../supabase/migrations/20260721140000_repair_reported_birth_time_revision.sql", + import.meta.url, + ), + "utf8", + ); + + assert.match(migration, /create or replace function public\.guard_birth_time_journey\(\)/i); + assert.doesNotMatch(migration, /reported_birth_time_is_immutable/i); + assert.doesNotMatch(migration, /new\.reported_birth_time\s*:=\s*new\.birth_time/i); + assert.match(migration, /update public\.profiles\s+set reported_birth_time\s*=\s*null[\s\S]*?birth_time_source\s+in\s*\(\s*'period_only'\s*,\s*'unknown'\s*\)/i); + assert.match(migration, /profiles_reported_birth_time_source_consistency/i); +}); diff --git a/frontend/tests/truth-source-runtime-identity.test.ts b/frontend/tests/truth-source-runtime-identity.test.ts index d02a8933..606296a9 100644 --- a/frontend/tests/truth-source-runtime-identity.test.ts +++ b/frontend/tests/truth-source-runtime-identity.test.ts @@ -13,7 +13,8 @@ const healthSource = readFileSync( test("truth source identity records research source without pretending local mount is always present", () => { assert.match(identitySource, /DEFAULT_RESEARCH_TRUTH_SOURCE_PATH/); - assert.ok(identitySource.includes("/Users/wuyongnaren/Documents/印度占星")); + assert.doesNotMatch(identitySource, /\/Users\/[^"\n]+/); + assert.match(identitySource, /resolve\(process\.cwd\(\),\s*"\.\."\)/); assert.match(identitySource, /not_mounted/); assert.match(identitySource, /claimGateStatus/); assert.match(identitySource, /evidencePacketCount/);