fix: restore production birth-time rectification
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user