fix: restore production birth-time rectification

This commit is contained in:
Jesse_Chen
2026-07-22 00:07:42 +08:00
parent 74cc8dba0b
commit dc0077eb89
8 changed files with 120 additions and 96 deletions
+12
View File
@@ -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.
-3
View File
@@ -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; }
+35 -81
View File
@@ -208,19 +208,6 @@ const rectificationCardLabels = {
} as const satisfies Record<RectificationCardAction, string>;
const rectifyBeforeConsultationSuggestion = "先完成生时校正";
function RectificationLoadingState() {
return (
<div className="rectification-loading-state" role="status" aria-live="polite">
<div className="app-loading-symbol" aria-hidden="true">
<span className="app-loading-orbit" />
<span className="app-loading-mark" />
</div>
<strong></strong>
<span></span>
</div>
);
}
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() {
<div ref={conversationEnd} />
</div>
))}
{rectificationSurfaceOpen && (
{rectificationSurfaceOpen && visibleRectificationTurn && (
<section className="rectification-session-surface" aria-label="生时校正">
{rectificationLoading ? (
<RectificationLoadingState />
) : rectificationError ? (
<div role="alert">
<p className="form-error">{rectificationError}</p>
<button
className="button-primary"
disabled={rectificationLoading || rectificationMutationPending}
type="button"
onClick={() => void openBirthTimeRectification(rectificationPendingQuestion)}
>
</button>
</div>
) : !visibleRectificationTurn ? (
<RectificationLoadingState />
) : (
<ConversationalBirthTimeRectification
initialTurn={visibleRectificationTurn}
pendingConsultationQuestion={rectificationPendingQuestion}
continuationPending={rectificationContinuationPending}
onPendingChange={setRectificationMutationPending}
onTurn={handleConversationalRectificationTurn}
onContinueOriginalQuestion={(question) => void continueRectificationOriginalQuestion(question)}
/>
)}
<ConversationalBirthTimeRectification
initialTurn={visibleRectificationTurn}
pendingConsultationQuestion={rectificationPendingQuestion}
continuationPending={rectificationContinuationPending}
onPendingChange={setRectificationMutationPending}
onTurn={handleConversationalRectificationTurn}
onContinueOriginalQuestion={(question) => void continueRectificationOriginalQuestion(question)}
/>
</section>
)}
</div>
@@ -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"));
@@ -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;
+10 -9
View File
@@ -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, /返回并恢复原问题|返回首页/);
});
@@ -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);
});
@@ -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/);