# PROGRESS · 状态下沉第二批:synastry / profile / session 三簇 + 稳住外壳 setter(2026-09-16) 工作树:`.worktrees/home-state-lowering-batch2-20260916` 分支:`codex/home-state-lowering-batch2-20260916` 任务书基线写 `4f643aa0`;开工时 `origin/staging` = **`37e6c519`**(`docs(tasks): 校正状态板 + 状态下沉第二批 + 后门单 + 对账清单`)。 本机 Linux,有 Node 与 npm,无 Docker、无登录态、无 Chrome。本单不占 BUG 号,不改 `CHANGELOG.md` / `docs/BUG_HISTORY.md` / `docs/tasks/README.md`。未 `git push origin HEAD:staging`。未改数据库、API 路由、`frontend/DESIGN.md`(无可见样式变化)、`frontend/docs/VOICE.md`(无文案变化)。 ## 开工实测(不要抄任务书 52/39/1931) 数法与合同测试相同:`Home()` 切片上 `\buseState[<(]` / `\buseRef[<(]`。 | 指标 | 实测 | | ---: | ---: | | `Home()` `useState` | **52** | | `Home()` `useRef` | **39** | | `page.tsx` 行数 | **1931** | | `npm run lint` | **0 error / 120 warning** | | `useSessionManagement` 参数(type 字段) | **41**(任务书约 40) | | `useProfileOnboarding` 参数(type 字段) | **38** | | 全量 `npx tsx --test tests/*.test.ts tests/*.test.tsx` | **3329** tests / 3283 pass / **31 fail** / 15 skipped | 31 条失败全是环境缺口(无 Docker、无本地 PostgreSQL、无外网),清单见文末,与收尾实测逐条相同。 ## 分类表(20 个,一个不漏) 「谁在读」按改前的 `page.tsx` 实测,不是记忆。 ### synastry 簇(4 个,全部搬下去) | 状态 | 类别 | 谁在读 | 处置 | | --- | --- | --- | --- | | `synastryRelationshipType` | 只服务子树 | 只有 `ChartLibraryPanel`(关系类型下拉) | 搬进新建 `useSynastry` | | `synastryPendingId` | 只服务子树 | `ChartLibraryPanel`(按钮 disabled / 「正在计算合盘...」)、`draftSynastryQuestionFromChart` 自己的重入闸 | 搬进 `useSynastry` | | `synastryReportCard` | 只服务子树 | `ChartLibraryPanel`(本次合盘卡) | 搬进 `useSynastry` | | `synastryHistory` | 只服务子树,但**外壳的星盘库 effect 要写** | 值只给 `ChartLibraryPanel`;写在 `Home` 的星盘库 effect 里(切账号清空、云端历史落库)与合盘成功后 | 状态搬进 `useSynastry`;外壳只拿到两个 `useCallback` 稳定句柄 `clearSynastryHistory` / `applyCloudSynastryHistory`,并列进该 effect 的 deps(身份恒定,不多跑一次) | `draftSynastryQuestionFromChart` 整个搬进 hook;`Home` 里 `const [synastry` 出现次数 = **0**。六个子树字段打成一个 `synastryPanel`,JSX 里 `{...synastryPanel}` 展开。 ### profile 簇(6 个,全部搬下去) | 状态 | 类别 | 谁在读 | 处置 | | --- | --- | --- | --- | | `profile` | 外壳也要读 | `Home`:`profileComplete`、`birthTimeRoute`、`dailyStarlanguage*`、`rectificationDeclaredTime`、侧栏头像首字母、`ProfilePanel` / `ChartLibraryPanel`;`useSessionManagement`、`useRectificationSurface`、`useSynastry`、`useConsultationRun` 的入参 | 状态搬进 `useProfileOnboarding`,`Home` 从它的 return 读回;hook 调用上移到 `useBirthTimeGuidedJourney` 之前,保证第一个读者之前已定义 | | `profileDraft` | 外壳也要读 | `Home`:引导三步的题面与提交按钮、`BirthTimeIntakeFields` / `BirthLocationFields`、`ChartLibraryPanel` | 同上 | | `profileNotice` | 外壳也要读(只透传) | `ChartLibraryPanel`;`setProfileNotice` 还要交给 `useRectificationSurface` 与 `useConsultationRun` | 状态在 hook;`Home` 只读回值与 setter | | `avatarNotice` | 只服务子树 | 只有 `ProfilePanel` | 搬进 hook,只读回值 | | `avatarSaving` | 只服务子树 | 只有 `ProfilePanel`;写只在 hook 的 `persistAvatar` | 搬进 hook,只读回值 | | `profileSaving` | 外壳也要读 | `Home`:引导三步按钮 disabled / 「保存中」、重新评估按钮、`ChatComposer` 的 `inputDisabled` / `submitBlocked`、`ChartLibraryPanel` | 搬进 hook,只读回值 | `Home` 里散装 `profile*` state = **0**(低于任务书 ≤1)。 ### session 簇(10 个,搬走 6 个,留 4 个) | 状态 | 类别 | 谁在读 | 处置 | | --- | --- | --- | --- | | `sessionsCursor` | 外壳也要读 | `Home` 侧栏 `hasMore`;写在 hook 的 `loadMoreSessions` **和**外壳的 bootstrap effect | 搬进 `useSessionManagement`,值与 setter 都读回 | | `showArchivedSessions` | 外壳也要读 | `Home`:`visibleSessions` 过滤、侧栏 `showingArchived`;写在 hook 的 `toggleArchivedView` | 搬进 hook;`visibleSessions` 一并搬进 hook 计算后返回 | | `sessionMenuId` | 只服务子树 | 只有侧栏(`menuSessionId` / `onMenuSessionChange` / 切归档视图时清菜单) | 搬进 hook,值与 setter 读回 | | `pendingSessionDeletion` | 只服务子树 | 只有删除确认弹窗 | 搬进 hook,值与 setter 读回 | | `sessionDetailLoadingId` | 只服务 hook 自己 | `Home` 里**没有任何读者**(改前 lint 就报 `assigned a value but never used`);只有 hook 的 `ensureSessionMessages` 读写 | 整个搬进 hook,不返回 | | `sessionFullPrompt` | 只服务 hook 自己 | `Home` 里没有读者;写在 `useSessionManagement` 与 `useConsultationRun` | 状态搬进 hook,只把 setter 返回给 `useConsultationRun` 用 | | `sessions` | **外壳也要读** | `Home`:`activeSession`、bootstrap effect 首屏落库、侧栏统计与三处 `sessions.find`;`sessionsRef` 初值;`useSessionManagement` / `useRectificationSurface` / `useConsultationRun` 的入参与 `setSessions` | **留在外壳**:它是三个 hook 的共同输入,且 bootstrap effect 在任何 hook 之外写它 | | `activeSessionId` | **外壳也要读** | `Home`:`activeSession`、`visibleSessions` 的「当前会话不过滤」分支、`activeSessionIdRef` 同步 effect、`ensureSessionMessages` effect、`birthTimeRoute`、`useConsultationRun` 入参、侧栏 | **留在外壳**:两个 effect 的依赖项,且 `useRectificationSurface` 也写它 | | `creatingSession` | **外壳也要读** | `Home`:`productEntrypointsDisabled`、自动打开校正面的守卫、新对话按钮 disabled、`StarterHome`、底部按钮;`useSessionManagement` 与 `useRectificationSurface` 双方入参 | **留在外壳**:两个 hook 都要读,谁持有都得回传给另一个 | | `pendingSessionId` | **外壳也要读** | `Home`:`isLoading`、`productEntrypointsDisabled`、`ensureSessionMessages` effect deps、两条恢复 effect、`chooseSuggestedQuestion` 闸、`ChatComposer` `submitBlocked`、侧栏 | **留在外壳**:它其实属于「咨询运行」簇(`useConsultationRun`),不是会话管理簇;本单不动咨询簇 | `Home` 里散装 `session*` state = **4**(任务书要 ≤2,见下「让步说明」)。 ## 收尾实测 | 指标 | 改前 | 改后 | | ---: | ---: | ---: | | `Home()` `useState` | 52 | **36**(= 任务书 ≤36 的上限;搬走 16) | | `Home()` `useRef` | 39 | **37**(未上升,反而少 2) | | `page.tsx` 行数 | 1931 | **1846** | | `npm run lint` | 0 error / **120** warning | 0 error / **118** warning | | `useSessionManagement` 参数 | 41 | **34** | | `useProfileOnboarding` 参数 | 38 | **26** | | 散装 `synastry*` / `profile*` / `session*` state | 4 / 6 / 10 | **0 / 0 / 4** | 新建 `frontend/src/hooks/use-synastry.ts`(141 行)。`use-profile-onboarding.ts` 与 `use-session-management.ts` 现在各自 `useState`。 ## 5.1:为什么 warning 只能靠 `useMemo`,不能靠塞 deps `createRectificationShellSetters(setRectification)` 改前在 render 体里裸调,每帧返回四个新函数身份,入口摘要 effect 因此多一条 `exhaustive-deps`(119 → 120)。 改法:`const {...} = useMemo(() => createRectificationShellSetters(setRectification), [setRectification]);`,再把 `setRectificationEntrySummary` / `setRectificationEntrySummarySettled` 补进该 effect 的 deps。`setRectification` 是 React 的 `useState` setter,身份恒定,所以四个包装函数跨帧稳定。 **不是靠肉眼**的证明:入口摘要 effect 的 deps 现在是 `[accountId, bootstrapPhase, setRectificationEntrySummary, setRectificationEntrySummarySettled]`。后两项由 `useMemo(..., [setRectification])` 产出,而 `setRectification` 来自 `useState`,React 保证同一 mount 内引用不变 → `useMemo` 不重算 → 两个 setter 引用不变 → effect 仍然只在 `accountId` / `bootstrapPhase` 变化时触发。若有人删掉 `useMemo`,eslint 会立刻把这条 warning 报回来(这正是改前的状态),所以这条不变量由 lint 数字守着:**warning 必须是 118,回到 119/120 就是有人把记忆化拆了**。 ## 既有断言改动 | 文件 | 原值 | 新值 | 原因 | | --- | --- | --- | --- | | `tests/home-shell-growth-contract.test.ts` 三门 | `HOME_USE_STATE_CAP = 52` / `HOME_USE_REF_CAP = 39` / `PAGE_LINE_COUNT_BASELINE = 1931` | **36** / **37** / **1846** | 本单下沉后的新基线,三项全部往紧里收 | | `tests/chat-navigation-a11y-contract.test.ts` 的 `signOut` 切片终点 | `sourceBetween(pageSource, "async function signOut()", "return {\n refreshAccount,")` | 终点改成 `"return {\n profile,"` | 六个 profile 状态搬进 `use-profile-onboarding.ts` 后,该 hook 的 return 以 `profile` 开头,`refreshAccount` 不再是第一个字段。**切的仍是 `signOut` 函数体,断言主语(退出后 `window.location.assign("/login")` 硬跳)一字未改** | 没有其它既有断言被改动或弱化;没有删除任何断言。新增 1 条断言(见下)。 ## 新增断言 `tests/home-shell-growth-contract.test.ts` 加一条 `useSessionManagement still runs before useRectificationSurface`:钉住 `rectificationSessionOpenerRef` 先声明、`useSessionManagement` 调用在 `useRectificationSurface` 之前、并且 opener 是通过 `openRectificationSession: (exactSessionId) => rectificationSessionOpenerRef.current(exactSessionId)` 递过去的。这是第一批留下的调用顺序约束,本单挪动了 hook 调用位置,所以补钉。 ## 反向验证(增长冻结真的会变红) 在 `Home()` 里插入 `const [_growthContractProbe, _setGrowthContractProbe] = useState(false);` 后: ``` not ok 1 - Home() useState count must not grow error: 'Home() has 37 useState calls; cap is 36. Extracted hooks and child components should own their own state.' # tests 4 / pass 3 / fail 1 ``` `useRef`、行数粗护栏、调用顺序三条仍绿。随后删掉探针,文件回到 36/37/1846,四条全绿。 ## lint warning 逐条比对(不只是比数字) 按「规则 + 归一化信息」排序后 diff,`120 → 118`,**没有任何新增 warning**,三处差异: 1. 少一条(本单目标):`React Hook useEffect has missing dependencies: 'setRectificationEntrySummary' and 'setRectificationEntrySummarySettled'` —— 5.1 消掉的就是它。 2. 少一条(本单副产物):`'_result' is defined but never used` —— 来自被删掉的 `guidedBirthTimeReadyRef = useRef((_result: JourneyClientResponse) => {})`,见下节。 3. 有一条既有 warning 的**信息变长**(规则、文件、effect 位置都没变): - 原文:`React Hook useEffect has a missing dependency: 'restoreConsultationRecovery'.` - 现文:`React Hook useEffect has missing dependencies: 'restoreConsultationRecovery', 'setProfile', 'setProfileDraft', and 'setSessionsCursor'.` - 原因:那是 mount-once 的 bootstrap effect,它写 `setProfile` / `setProfileDraft` / `setSessionsCursor`,改前这三个是 `Home` 自己的 `useState` setter(eslint 认得,不要求进 deps),改后来自 hook 的 return(eslint 认不出是 setter)。 - **为什么不把它们补进 deps**:试过,`}, [setProfile, setProfileDraft]);` 会打红两条既有断言——`tests/consultation-recovery.test.ts` 用 `source.indexOf("}, []);")` 当切片路标,`tests/chat-navigation-a11y-contract.test.ts` 直接断言 `\n \}, \[\]\);`。这两条断言的主语是「bootstrap 是 mount-once」,为了一条 warning 的措辞去改它们不划算,**所以选择不动业务代码、不动既有断言,只如实记录这处措辞漂移**。三个都是 `useState` setter 原样透传,身份恒定,effect 仍然只跑一次。 ## 为什么删掉了两个 ref(`useRef` 39 → 37) 把 `useProfileOnboarding` 改成持有状态后,React 编译器的 lint 规则才开始检查这个文件(改前它一个 hook 都不调),一次性冒出 **5 条 error**(§7.1 要求 0 error): | error | 位置 | 处置 | | --- | --- | --- | | `react-hooks/immutability` ×3 | `dialogReturnTarget.current = ...`、`birthTimeRevisionPending.current = ...` ×2 | 按规则自带的 hint 把两个 ref 入参改名为 `dialogReturnTargetRef` / `birthTimeRevisionPendingRef`(纯改名) | | `react-hooks/refs` ×2 | render 期写 `guidedBirthTimeReadyRef.current = completeGuidedBirthTime` / `editDeclaredBirthTimeDetailsRef.current = editDeclaredBirthTimeDetails` | 删掉这两个 ref | 这两个 ref 当初存在,只是因为 `useProfileOnboarding` 排在 `useBirthTimeGuidedJourney` **之后**、函数还没定义就要交给它。本单把 profile hook 上移之后,`completeGuidedBirthTime` / `editDeclaredBirthTimeDetails` 可以直接传: ``` onReady: (result) => completeGuidedBirthTime(result), onEditBirthTimeDetails: () => editDeclaredBirthTimeDetails(), ``` **零行为变化的依据**:`useBirthTimeGuidedJourney` 只在 render 作用域的闭包里用这两个回调(`acknowledgeReady` 里 `onReady(journey)`,`editBirthTimeDetails: onEditBirthTimeDetails` 直接原样返回),没有把它们塞进 `useEffect`/`useRef` 里缓存。改前每帧读 `.current`、改后每帧拿当帧的函数,调用到的都是同一个「最新一帧的实现」。(已核对 `src/hooks/use-birth-time-guided-journey.ts` 全文,`onReady` / `onEditBirthTimeDetails` 只出现在第 183、255 行。) `useRef` 数从 39 降到 37,没有把任何 state 改写成 ref。 ## 让步说明(任务书 §6) 1. 5.1、5.2、5.3 全做,无让步。 2. 5.4 **部分让步**:任务书要求 `Home()` 里散装 `session*` state ≤ 2,实际留了 **4**(`sessions` / `activeSessionId` / `creatingSession` / `pendingSessionId`),理由在分类表里逐个写明——前三个是三个 hook 的共同输入或 bootstrap effect 在 hook 之外的写入点,第四个属于咨询运行簇而不是会话管理簇。把它们合成一个外壳对象是可行的,但要同时改 `useConsultationRun`、`useRectificationSurface`、bootstrap effect 三处写入口,属于「咨询簇」的活,不是本单的零行为变化预算,**留给下一批**。 3. 5.5 的收口指标**没有放松**:`useState` 36 正好等于任务书上限 ≤36,`useRef` 37 低于上限 39,行数基线 1846 低于 1931,三条冻结门全部往紧里收。 4. 5.5、5.6 未砍。 ## 验收命令与结果 | 命令 | 结果 | | --- | --- | | `./node_modules/.bin/tsc --noEmit` | **0 错** | | `npm run lint` | **0 error / 118 warning**(基线 120;逐条比对见上节) | | `npx tsx --test tests/home-shell-growth-contract.test.ts tests/rectification-*.test.ts tests/chat-session-*.test.ts tests/session-*.test.ts` | 见下 | | `npx tsx --test tests/*.test.ts tests/*.test.tsx` | 见下 | | `npm run build` | 见下 | (定向、全量、build 的实测数字见文件末尾「收尾复核」一节。) ## 真人走查 `docs/testing/home-state-lowering-batch2-20260916.md`:合盘三条路径、资料引导与头像、会话列表七件事。本仓无登录态、无 Chrome,未测。 ## 发现的交互缺陷(本单不修) 无新增。`sessionDetailLoadingId` 与 `sessionFullPrompt` 这两条 state 在 `Home()` 里**从来没有读者**(改前 lint 已经在报 `assigned a value but never used`,改后同样两条 warning 原封不动出现在 `use-session-management.ts`)。`sessionFullPrompt` 由 `useConsultationRun` 写入却无人渲染,像是没接完的功能;本单零行为变化,只记录不动。 ## 未做(范围外) - 其余分散状态(`account*`、`birthTime*`、`consultation*`、`streaming*` 等) - 咨询运行簇(`pendingSessionId` / `pendingRequestId` / `streamingReply` / `replyOutcome` …) - Context Provider 或外部 store - 推 `origin HEAD:staging` - 改 `docs/tasks/README.md` 状态列 --- ## 收尾复核(最终工作树,四条验收命令的实测) | 命令 | 实测 | | --- | --- | | `./node_modules/.bin/tsc --noEmit` | **0 错** | | `npm run lint` | **0 error / 118 warning**(基线 120) | | `npx tsx --test tests/home-shell-growth-contract.test.ts tests/rectification-*.test.ts tests/chat-session-*.test.ts tests/session-*.test.ts` | **1369** tests / 1360 pass / **0 fail** / 9 skipped | | `npx tsx --test tests/*.test.ts tests/*.test.tsx` | **3330** tests / 3284 pass / **31 fail** / 15 skipped | | `npm run build` | 退出码 0,`/` 仍是 **`○ Static`** | 测试总数 3329 → **3330**(只增不减,多的那条是新增的调用顺序断言)。全量失败清单与开工基线 **31 条逐条相同**(`diff` 空)。 ## 全量失败清单(与开工基线逐条相同,全是环境缺口) 无 Docker、无本地 PostgreSQL、无外网,因此 database / staging-sync / backup / 远端 oracle 这几类整组红。 `Better Auth supports shared user OTP/password sessions for admins`;`Owner recovery grants only one currently loginable synced identity admin`;`admin code functions reject immutable codes, revoked redemption, and roll back on audit failure`;`admin customer reset clears only rebuildable application state`;`billing order adjustments and redemption reasons are atomic and audited`;`billing, subscriptions, usage authorization, RBAC, and model publication remain transactional`;`cached appendix returns markdown without model or engine calls`;`database drops secret refs, generates immutable codes, and invalidates evidence after key rotation`;`database env validator rejects Compose-compatible duplicate selectors without printing values`;`database roles have no cluster privileges`;`ephemeris natal and transit chart requests skip VedAstro overview`;`ephemeris_events failure degrades instead of failing the page`;`foundation grants no direct runtime table DML and exposes only reviewed functions`;`live staging sync preserves env, state, incoming files, and encrypted backups`;`live staging sync repairs nested deploy-tree drift without preserving foreign ownership`;`local PostgreSQL applies the reviewed business schema and serves authenticated business calls`;`migration runner is serialized, atomic, drift-safe, and read-only in check mode`;`missing appendix is a retired report, not an on-demand generation`;`panchanga still returns without a birth profile, and natal-relative stays empty`;`personal report job migration is atomic, lease-bound, recoverable, and owner read-only`;`personal report sections enforce owner-read RLS and service-owned durable transitions`;`rectification agent maps setup failures without calling the rest of the handler`;`rectification case refresh maps setup failures without calling the rest of the handler`;`redeem security: case-sensitive hashing, rate limiting, idempotency and order ownership`;`saving a published product forks a draft and delete retires or removes it`;`self-hosted identity migration creates Better Auth tables with least privilege`;`server compose defaults to local images without removing either build`;`service and restricted admin database identities stay separated`;`staging backups are encrypted, atomic, private, and retain the newest three`;`staging env validator rejects selector drift, duplicates, and unsafe permissions`;`unauthenticated ephemeris requests are 401`。 不是本单回归。 ## `next build` 与首屏 gzip Linux 上 Turbopack 构建正常,两次都退出 0: - 改后:路由表里 `┌ ○ /`,即 `/` 仍是 **Static**。 - 基线对照:把 `frontend/src` 与 `frontend/tests` 临时 `git checkout origin/staging --` 回去重建了一次,路由表同样是 `┌ ○ /`,随后 `git checkout HEAD --` 还原。 首屏 gzip 量法(与第一批同一种):读 `.next/server/app/index.html`,取它引用的全部 `/_next/static/**.js`,逐个 `gzip -9` 后求和。 | | 文件数 | gzip 合计 | | --- | ---: | ---: | | 基线 `origin/staging` @ `37e6c519` | 20 | **585,817 B** | | 本单 | 20 | **585,868 B** | 变化 **+0.009 %**,远在 ±2 % 内(+51 B)。