治理(08-11-internal-oa-finance-backoffice-positioning-and-navigation-reduction): 固化财务后台定位与写入定性

这个提交包含在:
2026-08-13 21:27:53 +08:00
父节点 08c9d42e15
当前提交 71e68c8ddf
共修改 12 个文件,包含 747 行新增和 24 行删除
@@ -2014,6 +2014,72 @@ node scripts/qa/payment-batch-flag-snapshot.mjs restore --snapshot-file "$REPORT
<Link to="/mobile/payment-batches">进入付款</Link>
```
## Scenario: Production Configuration Write Observation And Uncertainty
### 1. Scope / Trigger
- Trigger: an approved production runner changes feature flags or another versioned configuration through an API, then uses browser/network observation, response parsing or local counters to decide whether the write landed or whether rollback is needed.
- Purpose: prevent an observer defect from turning a known successful write into an unnecessary compensating write, while preserving zero retry and exact audit evidence.
### 2. Signatures
- Forward write: the approved mutation endpoint, for example `POST /api/system/feature-flags/batch`, with frozen `expectedVersion` values.
- Authoritative readback: GET the changed resources and compare `version`, configured value and effective state with both the PRE snapshot and the expected POST snapshot.
- Independent evidence: action-specific audit total and per-target changelog deltas.
- Observer sources must be named separately: page requests, browser-context requests, `APIRequestContext`, server response and audit/readback.
### 3. Contracts
- A local request counter is evidence only for the transport it actually observes. A Playwright `page.on("request")` listener does not observe writes sent through `context.request.fetch` or another `APIRequestContext`.
- Missing observer events, contradictory counters, missing output, non-zero transport status or invalid JSON enters `APPLY_UNCERTAIN`. The first action is read-only resource readback; audit and changelog are the second independent source.
- Before readback classifies the result, prohibit every further mutation: no forward retry, rollback, compensation, single-item repair or workspace/config cleanup. Rollback is a state change and follows the same uncertainty rule as retry.
- Readback equals expected POST (`version=PRE+1`, desired value/effective state on every target) -> classify the write as applied and continue POST verification. A zero local counter cannot override authoritative state.
- Readback equals the PRE snapshot -> classify as not applied and stop. A later attempt requires a fresh approval/version lock unless the original approval explicitly defines another attempt.
- Partial or mixed readback -> return `STOP_FOR_MANUAL_DECISION`, record a contract/product finding and stop. Do not continue POST, auto-rollback or conceal a broken atomicity contract with manual repair.
- Execute an approved rollback only after the forward result is known and a defined POST acceptance condition actually fails. Capture rollback response, final readback and audit deltas separately from the forward evidence.
### 4. Validation & Error Matrix
- Response `count` is correct but local observed-write count is zero -> `APPLY_UNCERTAIN`; GET versions/state, do not rollback from the counter alone.
- Response missing or JSON invalid -> `APPLY_UNCERTAIN`; GET versions/state and audit, never retry blindly.
- All targets at expected POST -> continue read-only POST checks; rollback only for a real approved acceptance failure.
- All targets at PRE -> stop with zero compensating writes.
- Some targets changed -> `STOP_FOR_MANUAL_DECISION`; stop, preserve evidence and request an explicit human decision. Neither continue nor rollback is automatically correct.
- Readback itself fails -> state remains unknown; stop with zero further mutation.
### 5. Good/Base/Bad Cases
- Good: `APIRequestContext` writes a three-item batch while a page listener reports zero; GET shows all three at `PRE+1`, audit is `+3`, so the runner continues POST verification without rollback.
- Base: the batch response and observer agree, readback confirms the expected state, and the audit delta matches target count.
- Bad: treat a page-only counter as authoritative for a context request and immediately restore the PRE values before checking current versions.
- Bad: call rollback because it is pre-authorized while the forward result is still unknown.
### 6. Tests Required
- Local integration/harness case where the write is sent through `APIRequestContext`, the page listener observes zero, the server returns success and readback shows `PRE+1`; assert no rollback and no retry occur.
- Cases for response success/counter agreement, response missing with POST state, response missing with PRE state, readback failure and partial/mixed state.
- Assert mutation ordering: forward at most once -> uncertain readback/audit only -> either POST verification or stop -> rollback at most once only after a concrete approved acceptance failure.
- Assert final evidence reconciles response count, readback versions, global audit delta and per-target changelog delta; no one source may silently override contradictions.
### 7. Wrong vs Correct
#### Wrong
```text
forward response count=3 + page observer count=0
-> assume failure -> rollback immediately
```
#### Correct
```text
forward response/counter disagree -> APPLY_UNCERTAIN
-> GET every target version and state -> reconcile audit/changelog
-> expected POST means continue verification
-> PRE means stop
-> rollback only after a real approved POST failure
```
## Scenario: Unique Production Administrator Break-Glass Reset
### 1. Scope / Trigger
+25 -18
查看文件
@@ -76,37 +76,44 @@ otherwise -> defer, hide, freeze or keep as a manual SOP
**Related**: the evidence-backed baseline and initial retirement candidates are
recorded in task `07-26-small-team-internal-oa-review`.
### Design Decision: Adoption Mode Before Product Expansion
### Design Decision: Finance-Backoffice Scope And Focused Adoption
**Context**: the 2026-08-11 production read-only census found extremely low
team adoption across the system. This does not prove that missing features are
the cause; several workflows intentionally live in Enterprise WeChat or manual
processes.
**Decision**: management must choose one operating mode before any new product
feature task starts:
**Decision**: separate product scope from adoption intensity. hyb-oa is the
company's finance and low-frequency ledger backoffice, not its general daily
collaboration suite. Reimbursement, invoices/OCR, approval, payment and AP are
OA-primary workflows and should be used for real work. Contract, procurement
and asset ledgers remain available but frozen until real events occur. HR,
attendance and daily collaboration stay Enterprise-WeChat-primary. Project is
currently a frozen zero-use domain and must not be disabled when that would
degrade an OA-primary finance flow.
1. **Enforced use**: designate a real near-term workflow, require the team to
complete it in OA, and use observed blockers—not guessed needs—as product
input.
2. **Ledger fallback**: keep OA as a low-frequency record, control and recovery
system; reduce navigation and maintenance surface instead of expanding it.
Payroll, performance, ERP/integration, budget and cutover capabilities are
retirement-review candidates, not approved removals. Authentication, least
privilege, payment maker-checker, audit, backup/restore and leaver revocation
remain mandatory regardless of frequency.
Until the mode is recorded in the business fact source, freeze new product
features. Real production defects and the security/recovery baseline remain
eligible. Reversible navigation reduction is valid under either mode, but it
still requires its own Trellis task and approval for production configuration
writes. Do not create that follow-up task as a side effect of a census or review
task.
Do not implement technical coercion to manufacture adoption. Requiring the
next reimbursement to use OA is a management rule; notification expansion,
onboarding guides, channel blocking or reminder machinery need observed user
friction before they can become product work. The old R63-R66 expansion
schedule is superseded.
```text
adoption mode undecided -> no new product feature task
mode A + observed team blocker -> consider one small product task
mode B -> prefer hide, freeze, retire review, manual SOP or necessary upkeep
OA-primary finance flow + observed blocker -> consider one small product task
low-frequency ledger + no real event -> keep available and frozen
Enterprise-WeChat-primary or cheaper reliable substitute -> do not duplicate
outside the focused scope -> hide, freeze or start a separate retirement review
```
**Related**: task `08-11-internal-oa-usage-census-and-simplification` contains
the evidence boundary and module decision matrix.
the evidence boundary and module decision matrix. Task
`08-11-internal-oa-finance-backoffice-positioning-and-navigation-reduction`
records the adopted scope and first reversible navigation reduction.
## Stop Conditions
@@ -286,7 +286,7 @@
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
- [ ] 本任务未导入杨俊和两个既有测试成员之外的其他成员、未创建法体、未补银行资料;唯一测试业务数据已按受控生命周期清理/归档。
- [ ] 若只完成系统就绪而真实报销尚未提交,任务保持未完成并明确剩余用户操作,不用合成测试替代。
- [ ] 完成后归档本任务,返回 R63–R66 产品主线;不从本任务继续长出新的运维加固范围。
- [ ] 完成后归档本任务;后续按“财务与低频台账后台 + 核心流程真实采用”定位处理真实报销摩擦,不再返回已失效的 R63–R66 扩建排期,也不从本任务继续长出新的运维加固范围。
## 8. 已完成的脱敏证据
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,102 @@
# 财务后台定位与主导航减法:设计
## 1. 设计结论
本任务只有一个可交付阶段:**定位固化 + 三个固定 feature flag 的受控关闭**。`project_domain_v1` 原为唯一条件目标,但 2026-08-11 本地真实浏览器回归证明关闭后会让报销项目关联控件产生误导性降级,因此已从生产目标剔除。任务不引入导航层级,不修改产品代码,不部署候选。合同、采购、资产的“二级入口”是延后事项,不是本轮第二个实现阶段。
首次生产尝试前的“零代码”定义为:`client/src`、`server/src`、`scripts`、migration、依赖和部署配置零 diff;只允许 task、事实源、治理 spec 和必要的当前计划纠偏。生产变化由已部署功能管理页调用既有 batch API 完成。首次尝试因观察缺陷安全恢复后,用户另行批准一个最小本地例外:只可新增/修改 `scripts/qa` 纯状态机和对应单元测试,产品运行代码、API、schema、依赖与部署配置继续零 diff。
## 2. 产品边界模型
| 域 | 定位 | 当前动作 |
| --- | --- | --- |
| 报销、票据/OCR、审批、付款、AP | OA 主办且要求真实使用 | 保持开启;真实摩擦出现后才开小产品 task |
| 合同、采购、资产 | OA 低频台账 | 保持可用与冻结;暂留一级入口 |
| 认证、权限、审计、备份恢复、离职撤权 | 治理底线 | 不按使用频率削减 |
| 通讯录、请假、考勤、员工日常协作 | 企微主办、OA 留存/接管 | 不与企微争夺日常入口 |
| 公告、普通待办、周报 | 当前无使用证据、非核心 | 三个 flag `force_off`,代码与数据保留 |
| 项目 | 当前无使用证据,但报销等保留域会查询项目 | 本地跨域回归未通过,维持现状 |
| 绩效、薪资、ERP/集成、预算、Cutover | 超出当前规模的候选 | 只登记退役审查,不执行退役 |
## 3. 现有运行路径
```text
管理员 /system/feature-flags
-> GET /api/system/feature-flags(读取现场 version/effective)
-> 选择恰好三项 + force_off + 统一 note
-> POST /api/system/feature-flags/batch(一次)
-> 一个 Prisma transaction
-> 每项 expectedVersion 校验
-> 每项 updateMany version+1
-> 每项 FEATURE_FLAG_UPDATED audit
-> GET flags/navigation/changelog(只读验证)
```
batch 端点保留每项既有 `rolloutType` 和 `departmentIds`,任一 version conflict 会回滚整个事务。它比三次单项 PUT 更适合作为“严格一次”的配置写入。
## 4. 影响合同
| Flag | 导航消失 | 后端/聚合副作用 | 保留边界 |
| --- | --- | --- | --- |
| `announcements_v1` | 公告 | 公告 API(含写接口)disabled;Portal/搜索不再聚合公告 | 通知记录和通知中心不在此 flag 范围;这是关闭而非仅隐藏,临时发公告前须先重新开启 |
| `todo_board_v1` | 普通待办 | todos API disabled;Portal/搜索不再聚合普通事项 | `待我处理` 审批任务不是普通待办,必须保留 |
| `weekly_report_v1` | 周报 | weekly-reports API disabled;Portal/搜索不再聚合周报 | 历史表保留 |
| `project_domain_v1`(已排除) | 项目 | `/api/projects*` disabled;Portal/搜索和其他页面的项目候选为空/失败 | 本地证明未关联控件无关闭说明、已关联控件丢失当前项目并回落到“不关联项目”,不得进入生产 target |
三个最终目标的直接路由 disabled 是预期结果,因此这不是“仅 CSS 隐藏”。计划仍称为导航减法,是因为用户可见目标是收缩入口;技术实现同时冻结了三个无数据模块的后端入口,且完全可逆。项目 flag 明确保留开启,不属于本轮生产变化。
## 5. 执行门禁
### 5.1 本地门禁
- 只修改文档/spec/task,不运行产品构建或发布。
- 校正 `docs/产品口径.md` 中 OA 主办域及 P0-10,避免“项目仍是活跃 OA 主办主线”与新定位冲突。
- 替换 governance 中的 A/B 未决门为双轴结论:范围收缩、核心流程采用。
- 搜索当前非归档任务中的 R63–R66 前向引用,只纠正会驱动未来扩建的说明;不改历史归档材料。
### 5.2 项目 flag 本地准入
- 使用本地服务、真实路由和浏览器,不修改产品代码。
- 准备或复用两张本地报销:一张 `projectId=null`,一张关联现有项目;记录原始显示。
- 只在本地将 `project_domain_v1` 临时设为 `force_off`,验证两张详情:页面加载只是基础条件;还必须检查项目控件的可见/禁用/错误状态,以及已关联项目名称、编号和关联事实是否仍正确。
- 网络证据确认 `/api/projects` 的 disabled 响应如何被 UI 消费,不能只看 DOM 终态。
- 恢复本地 flag snapshot。任一可见错误、误导性空列表、已关联项目丢失或恢复失败,生产方案固定为三项。
### 5.3 生产 PRE(另批)
- 确认唯一 trialrun、运行 SHA 与已知已部署候选一致、migration 无 pending;本地文档领先不得误判为产品漂移。
- 复用已审查的只读普查能力现场确认三个最终目标模块仍为零数据/零真人活动;有效只读查询严格一次、零 retry。
- GET flags 保存三个最终目标的完整 snapshot 与审计基线;断言 target 恰好存在、effective on、无并发 version 变化。
- GET impact 断言返回的导航和后端影响与本设计一致。
- 审计粒度以实际 batch 路由为准:每个 target 各写一条 `FEATURE_FLAG_UPDATED`,没有独立 batch 审计;三个目标的预期全局增量是 3,不是 4。
### 5.4 生产 APPLY(再次明确批准)
- 使用既有管理员凭据现场登录;凭据不得进入 argv、env、历史、日志或证据。
- 在现有功能管理页选择恰好三个最终目标,批量“强制关闭”,填写统一说明;网络证据必须显示 `POST /api/system/feature-flags/batch` 恰好一次。
- 禁止单项保存、刷新后重试、workspace PATCH、其他配置或业务页面 mutation。
### 5.5 POST
- 三个最终 target 全部 effective off、reason `flag_force_off`、version 各 +1、audit 各 +1。
- 管理员 `/api/system/navigation` 的所有 section 均不含公告、普通待办、周报;项目及其他保留导航仍存在。
- GET 三个最终 target 的直达模块得到 409/disabled,不得 401、5xx 或意外业务数据。
- 只读打开 Portal、报销、合同、采购、资产;项目 flag 保持开启,报销等保留域的项目选项不得因本轮变化而降级;不允许页面整体失败或产生业务 mutation。
## 6. APPLY_UNCERTAIN 与回滚
- POST 通道中断、输出缺失、响应不可解析或本地观察器与响应矛盾:进入 `APPLY_UNCERTAIN`。在任何重试、回滚或补偿写入之前,第一动作必须只读 GET 三项当前 version、overrideMode 和 effective 状态,再用审计/changelog 交叉验证;未定性前所有配置 mutation 都被禁止。
- 最终 target 均 version +1 且 off:定性为已落地,继续 POST 检查,禁止重试。
- 最终 target 均保持 PRE snapshot:定性为未落地,停止并重新申请,不在现场重试。
- 出现部分更新:违反事务合同,记录为产品/运行发现并停止;不得用手工单项修补掩盖。
- 若生产批准明确包含回滚,且前向结果已经只读定性为已落地、随后保留域页面或项目关联显示合同真实失败,才可用一次 batch 将最终 target 恢复为 PRE 的 overrideMode;预授权不允许跳过写入定性。rollout/departments 本来未变,rollback note 明确原因。否则停下请求批准。
### 6.1 下一次前向写入的本地准入
- 修正执行器的观察范围:`page.on("request")` 只统计页面发出的请求,不能用于判断 `context.request.fetch` / `APIRequestContext` 的 batch 次数;要么让 batch 由页面真实发出,要么直接围绕调用函数、响应和服务端 readback 计数。
- 增加本地回归:batch 实际成功、response `count=3`、页面观察为 0 时,执行器必须进入只读定性;GET 为 POST 状态则继续验证,rollback 次数必须为 0。
- 同时覆盖响应缺失但 GET 为 POST、响应缺失但 GET 为 PRE、GET 失败、部分状态四条分支。该准入通过前不得申请新的生产前向写入。
## 7. 延后设计
合同、采购、资产二级入口需要改变扁平导航模型,是产品代码与部署工作。当前只有极低真人使用证据,引入层级的维护成本高于收益,因此不设计 schema、不建子 task、不列为本轮 AC。触发条件是:核心财务链有多名真人持续使用,并出现“一级导航过载”的明确反馈。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,109 @@
# 财务后台定位与主导航减法:执行计划
## 0. 规划与启动门禁
- [x] 用户批准创建 task 并进入规划。
- [x] 核验最初四个候选导航项、flag key、batch transaction、后端副作用和生产普查证据。
- [x] 明确本轮只有“定位固化 + 三个固定 flag 关闭”;原条件目标 `project_domain_v1` 已因本地准入失败排除,二级入口不实施。
- [x] 用户只读复核 `prd.md`、`design.md`、`implement.md` 并明确批准启动;已运行 `task.py start`。
## 1. 本地定位固化(启动批准后)
- [x] 更新 `docs/产品口径.md`:将 A/B 未决门替换为“范围收缩为财务/台账后台,核心 OA 主办流程要求真实使用”。
- [x] 对齐 P0-1/P0-3/P0-10 中 OA 主办域,明确项目主域当前冻结候选,合同/采购/资产仍为低频台账。
- [x] 更新 `project-governance.md` 的 Adoption Mode decision,禁止按失效 R63–R66 排期继续扩建。
- [x] 只纠正当前活跃任务中会把执行带回 R63–R66 的前向说明;未批量改归档历史。
- [x] `git diff --name-only` 断言没有 `client/src`、`server/src`、`scripts`、migration、依赖或部署配置;不新增执行脚本。
## 2. 本地验证与生产审批停点
- [x] 启动本地服务并复用两张本地草稿;一张保持未关联,另一张仅在验证窗口内临时关联现有合成项目;记录 flag on 基线。
- [x] 本地临时 `force_off project_domain_v1`,用真实浏览器验证页面、控件、错误提示、已关联项目名称/编号和 `/api/projects` 网络响应。
- [x] 恢复本地 flag 完整 snapshot 并复核;准入失败,生产 target 固定为 3,`project_domain_v1` 不进入本轮生产写入。
- [x] `python3 ./.trellis/scripts/task.py validate 08-11-internal-oa-finance-backoffice-positioning-and-navigation-reduction`
- [x] `node scripts/qa/check-trellis-governance.mjs`
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`
- [x] `node scripts/qa/audit-docs.mjs --check-registry`(退出 0;保留基线 warning:22 个未登记 docs 文件,本轮未新增 docs 文件)
- [x] 敏感扫描与 `git diff --check` PASS。
- [x] 形成生产批准包:PRE、三个固定 target、完整副作用、一次 batch、APPLY_UNCERTAIN、可选一次回滚;已在用户批准前停止。
### 2.1 本地真实浏览器证据(2026-08-11)
- 环境:本地 PostgreSQL、真实 Fastify/Vite、仓库已安装的 Playwright Chromium;无 `page.route`、无组件 mock、无产品代码或测试代码改动。Playwright CLI 因系统 Chrome 通道缺失且 CLI Firefox 构建不支持当前环境而未能启动,故只在浏览器驱动层回退到仓库 Playwright,验证对象和网络路径不变。
- 合成样本:本地原有 1 个项目;从原有草稿中取 2 张,一张保持 `project_id=null`,另一张临时关联该项目。窗口前后全库已关联报销数均为 0。
- 开启基线:两张详情的 `/api/projects` 均为 200;未关联控件有“不关联项目 + 现有项目”两项,已关联控件选中现有项目,项目名称和编号显示一致。
- 关闭观察:两张详情的 `/api/projects` 均为 409;页面无红色可见错误,但未关联控件仍启用且只剩“不关联项目”,没有“项目能力已关闭/不可用”的解释;已关联详情的事实卡仍显示原项目名称和编号,控件却丢失该项目选项并回落到“不关联项目”。这会把“关联事实仍存在”呈现成“当前未关联”,不满足明确且不误导的准入合同。
- 请求边界:关闭阶段自动出现 2 次 `POST /api/reimbursements/:id/precheck`,代码和现场均确认它是只读预检;其他业务 mutation 为 0。
- 恢复:flag 的 override/rollout/departments/note/version/创建与更新时间/更新人 snapshot 精确一致;已关联报销数恢复为 0;专用本地浏览器 session 已删除。本地第一次恢复尝试曾把 JSON `[]` 通过 raw 参数写成 `{}`,门禁立即识别并恢复为原数组;最终有效 witness 的恢复路径只改 `override_mode`,完整 snapshot PASS。
- 判定:`project_domain_v1` **不通过**本地准入,不得进入生产 batch。最终生产目标恰好为 `announcements_v1`、`todo_board_v1`、`weekly_report_v1`。
## 3. 生产 PRE(需单独批准)
- [x] 只读确认 trialrun identity、运行 SHA、migration 和当前产品差异;不因本地文档提交领先误报运行漂移。
- [x] 复用既有只读普查能力严格一次确认三个最终 target 模块仍为零数据/零真人活动;任一非零即 PAUSED。
- [x] 管理员登录一次,只读保存三个最终 target 的 override/rollout/departments/note/version/effective snapshot 和审计基线;不输出账号或凭据。
- [x] GET 三项 impact,确认副作用与批准合同一致。
- [x] 断言 `project_linking_v1` 与合同、采购、资产相关配置不在目标集合。
### 3.1 生产 PRE 证据(2026-08-12)
- 身份与运行:唯一 trialrun 身份 PASS;既有部署聚合体检 `PASS=13 FAIL=0`;运行候选与 `origin/main` 已部署提交一致,当前本地领先的 8 个提交在 `client/src`、`server/src` 和部署 compose 上零差异。数据库三方身份 PASS,migration `pending=none`,回滚窗口 `OPEN / terminatedRows=0 / basis=terminated_status_count`。
- preflight 执行边界:第一次 detached 临时 worktree 在 provenance 阶段因无 upstream 被拒,尚未进入迁移查询;随后使用跟踪 `origin/main` 的 clean 临时分支完成唯一有效 preflight。临时 worktree/branch 已删除。
- 使用度:复用已审查的只读事务查询器,通过 stdin 送入运行容器且不落盘;公告、普通待办、周报 `totalRows=0`,30/90 天真人 actor/event 与真人审计 actor/event 均为 0,`queryStatus=PASS`,`effectiveQueryExecutions=1`、`zeroRetry=true`。第一次本地路径拦截发生在 stdin 重定向前,远端查询执行数为 0。
- 管理员只读 API:一次登录、零 retry;三项均为 `overrideMode=force_on`、`rolloutType=global`、`departmentIds=[]`、`version=47`、`envEnabled=true`、`effectiveEnabled=true`、`effectiveReason=flag_force_on_global`。三项现有 note 均非空且 SHA-256 相同;未保存或输出 note 原文。正常 logout 已用于撤销本次 session。
- 导航与影响:公告 `/announcements`、普通待办 `/todos`、周报 `/weekly-reports` 当前均存在。公告 impact 为公告 API 与 Portal;普通待办 impact 为 todos API、Portal、搜索;周报 impact 为 weekly-reports API、Portal、搜索。公告是“关闭”而非仅隐藏:flag off 后公告 API(含写接口)硬拒,临时发公告前必须先重新开启;Portal/搜索消费者按条件省略,不构成项目 flag 的跨域矛盾。
- 审计基线:全局 `FEATURE_FLAG_UPDATED total=1`;三项目标 changelog total 均为 0。源码确认 batch 事务对每个 target 各写 1 条 `FEATURE_FLAG_UPDATED`,没有独立 batch 审计;因此预期写后为全局 `auditDelta=3`、三项 changelog 各 `+1`、batch response `count=3`,不得按 4 条判断。
- 最终写入输入:目标恰好为 `announcements_v1@47`、`todo_board_v1@47`、`weekly_report_v1@47`;`project_domain_v1`、`project_linking_v1` 及其他 flag 不在集合。生产 batch 尚未批准、尚未执行。
## 4. 生产 APPLY(需单独批准)
- [x] 使用既有 batch API 选择恰好三个固定目标;写前重新确认三项均为 `force_on/global/version=47/effective=true`,审计与回滚窗口基线未漂移。
- [x] 一次前向 batch 将三项设为 `force_off`,response `count=3`;单项 PUT=0、workspace PATCH=0、其他业务 mutation=0。
- [x] 执行器随后因只监听页面网络、没有监听同一 browser context 的 `APIRequestContext`,把已成功的 batch 误计为 0;该本地 witness 缺陷触发了用户预授权的最多一次恢复 batch。恢复 response `count=3`,没有重试前向 batch。
- [x] 请求通道和 JSON 响应始终明确,没有进入 `APPLY_UNCERTAIN`;实际配置 mutation 精确为一次前向 batch + 一次预授权恢复 batch。
### 4.1 生产写入与恢复证据(2026-08-12)
- 写前现场锁:三个目标仍为 `force_on/global/departmentIds=[]/version=47/effective=true`;全局 `FEATURE_FLAG_UPDATED total=1`、各目标 changelog `0`,回滚窗口 `OPEN/0`。
- 前向写入:batch HTTP 成功且 `count=3`,三项实际进入 `force_off@48`。执行器的页面级 request listener 没有观察到 `context.request.fetch`,本地计数断言错误报 `forward_batch_count_0`;这不是服务器拒绝或通道不确定。
- 预授权恢复:失败分支只读确认三项确已统一进入 `force_off@48` 后,严格一次 batch 恢复 `force_on`,response `count=3`;终态三项均为 `force_on/global/departmentIds=[]/version=49/effective=true/reason=flag_force_on_global`。
- 独立交叉验证:全局 feature-flag 审计从 `1` 到 `7`,增量 `6`;三个目标 changelog 均从 `0` 到 `2`,各增量 `2`,精确对应一次前向和一次恢复。公告、普通待办、周报及项目导航均已恢复,Portal 只读为 200,回滚窗口仍为 `OPEN / terminatedRows=0 / basis=terminated_status_count`。
- 边界与清理:没有单项 PUT、workspace PATCH、其他 flag 或业务数据写入;没有第二次前向 batch。CLI wrapper 权限位、系统 Chrome 缺失、一次缺设备指纹的 400 和一次导航响应形状误判均发生在配置写入之外;最终有效只读核验正常 logout,浏览器/API context 和 token 均销毁。导航响应形状误判的只读核验在 logout 前退出,留下 1 个无人持有的服务端 session;本任务不追加未批准的认证状态写入,记录为自然过期残留。
- 结论:生产安全回到 PRE 功能状态,但“关闭三项并保持关闭”的业务目标未达成。原 `expectedVersion=47` 已消耗,任何后续前向写入必须以现场 `version=49` 重新规划并再次获得明确批准,不能沿用本次批准。
## 5. POST 与回滚判断
- [ ] 最终 target 均 `force_off/effective=false/reason=flag_force_off`,version 各 +1;本次因预授权恢复而未达到该终态。
- [ ] 单次前向落地的审计增量恰好为 3、各 target +1;本次最终审计包含前向与恢复,实际为全局 +6、各 target +2。
- [ ] 导航所有 section 不含公告、普通待办、周报入口;本次恢复后这些入口按 PRE 状态重新出现。
- [ ] 三个最终 target 直达 GET 返回可解释 disabled;本次在进入完整 disabled/Portal witness 前被执行器计数断言中止并恢复,未形成可接受的保持关闭 POST 证据。
- [x] 恢复终态下其他 flag、`project_linking_v1`、导航、Portal 和回滚窗口保持 PRE 可用状态;业务 mutation 为 0。
- [x] 只执行用户预授权的最多一次恢复 batch;恢复后停止,没有重试前向写入。
## 6. 收口
- [ ] 更新 task 证据,明确产品代码零改动、部署 0、最终 target=3、batch 次数、审计增量、项目本地准入失败和二级入口延后。
- [ ] 重跑 task/governance/process/docs/sensitive/diff 门禁。
- [ ] 中文 commit 引用 task 名;不自动推送。
- [ ] 用户审查后归档,不创建二级入口或退役子 task。
## 7. 恢复后再次准入(任何新生产批准之前)
- [x] 新增纯本地 `feature-flag-apply-uncertainty.mjs`:真实发送次数在 `sendMutation` 包装层计数,`page.on("request")` 只作为可选辅助观察;响应和服务端 readback 分开记录,高层入口固定“发送至多一次 → 只读定性”,没有 rollback 回调入口。
- [x] 本地覆盖“batch 成功但页面观察为 0”:readback 为 POST 时返回 `CONTINUE_POST`,发送次数=1、前向重试=0、rollback=0。
- [x] 本地覆盖响应缺失但 GET 为 POST、响应缺失但 GET 为 PRE、GET 失败、部分状态;分别得到 `CONTINUE_POST`、`STOP_NOT_APPLIED`、`STOP_UNKNOWN`、`STOP_FOR_MANUAL_DECISION`。部分状态不自动继续或恢复,必须停止并申请人工决策。
- [x] `node --check scripts/qa/feature-flag-apply-uncertainty.mjs` PASS;focused Vitest `5/5` PASS。
- [x] `npm run prisma:validate` PASS。完整 server Vitest 发现既有付款单导出用例 1 条稳定失败(期望 200、实际 409);在干净 `HEAD` 临时 worktree 中单独运行同一标题仍失败,证明不是本次 QA 状态机/测试引入。本任务不修改付款业务代码,focused 状态机用例保持 5/5 PASS。
- [ ] 重新执行生产 PRE,以现场 `version=49`(或届时新值)冻结三项目标和审计基线;不得沿用 `version=47`。
- [ ] 形成新的生产批准包并等待明确批准;本次一次前向与一次恢复的批准均已消耗。
## 风险与停止条件
- 三个最终 flag 不是纯导航开关;若影响合同/采购/资产/报销页面整体可用性,停止并按已批准边界决定回滚。
- 现场出现任何业务数据或真人活动:停止,不关闭对应模块。
- 目标、version、运行身份或结果不确定:停止,禁止盲重试。
- 发现需要产品代码才能完成:返回规划并重新批准;不得把阶段二偷渡进本轮。
## Residual Register
- `OPEN / OUT_OF_SCOPE`:付款批次 BIFF8 导出单测“exports a reviewed CCB batch as BIFF8 and records the first export”稳定得到 409、预期 200;完整 server suite 与干净 `HEAD` 临时 worktree 的单标题复现结果一致,证明不是本任务 QA 状态机改动引入。后续应独立定性运行依赖、模板门禁或产品行为,不得长期把该红灯当作可忽略背景噪音;本任务不修改付款业务代码或测试断言。
@@ -0,0 +1,114 @@
# 财务后台定位与主导航减法
## Goal
将 hyb-oa 的正式定位从“通用内部 OA”收敛为本公司 5–10 人使用的**财务与低频台账后台**:范围上只保留 OA 真正应主办的财务、台账和治理能力,采用力度上只要求团队在这些核心流程中真实使用,不与企业微信争夺请假、考勤和日常协作。随后利用现有运行时 feature flag,一次性、可逆地关闭公告、普通待办和周报;项目是否一并关闭,必须先通过本地跨域回归。
## Background
- `08-11-internal-oa-usage-census-and-simplification` 的生产只读普查覆盖 20 个模块:聚合 70 行(其中系统配置 26),13 个模块零行,近 90 天只有 13 条真人归属/创建记录证据;唯一可证明的业务链是“报销 → 审批 → 付款/AP”。
- 请假、考勤等日常员工流程按既有事实源由企业微信主办。零 OA 使用不代表这些业务没有发生,而是操作端在别处。
- 用户已正式选择“范围收缩 + 核心流程采用”的组合定位,不再沿用 A/B 二选一:
- OA 主办并推动真实使用:报销、票据/OCR、审批、付款批次、AP、必要财务交接。
- OA 保留冻结:合同、采购、资产等低频高失败成本台账,以及认证、权限、审计、备份恢复、离职撤权等治理底线。
- 企业微信主办:通讯录、请假、考勤及适合在企微完成的员工日常协作。
- 超出当前范围、仅列退役审查候选:绩效、薪资、ERP/集成、预算、Cutover 等;本任务不删除它们。
- 旧 R63–R66 产品排期中的通知中心、入职向导等扩建方向已被用户判定失效;不得再把它当后续产品主线。
## Confirmed Technical Facts
- `server/src/services/workspaceSettings.js:79-82` 将周报、待办、公告、项目分别绑定到 `weekly_report_v1`、`todo_board_v1`、`announcements_v1`、`project_domain_v1`;`getSystemNavigation` 在 flag 非 effective 时过滤导航项(`:310-357`)。
- 2026-08-11 生产快照中四个模块均为 0 行、近 90 天真人活动 0,且导航均为 `main`、effective flag 均为 on;执行前仍须现场复核,不能用该快照替代现场状态。
- `POST /api/system/feature-flags/batch` 使用单个数据库事务、逐项 `expectedVersion` 乐观锁、逐项 `FEATURE_FLAG_UPDATED` 审计;任一目标缺失或版本冲突会整批回滚(`server/src/routes/featureFlags.js:586-665`)。
- `force_off` 不只是隐藏导航,还会使对应后端能力返回关闭状态:公告 `/api/announcements`,周报 `/api/weekly-reports`,普通待办 `/api/todos`,项目主域 `/api/projects`;Portal 和全局搜索会停止聚合相应内容。项目 flag 关闭后,报销、合同和采购页面中的“项目选项”查询也会不可用,但 `project_linking_v1` 不在目标清单,合同台账 API 本身不得被关闭。
- 合同、采购、资产当前没有统一 feature flag 可单独变为“二级入口”;现有导航模型只有 `main/admin/backup` section 和扁平 item,不具备业务二级分组。本轮不新增导航层级。
## Scope
- 固化“财务与低频台账后台 + 核心流程真实采用”的正式产品定位,并纠正会继续驱动失效 R63–R66 扩建计划的当前前向口径。
- 在零产品代码、零部署前提下,先完成本地事实源与治理规则收敛。
- 在另获生产写入批准后,通过既有 batch feature flag 能力一次性关闭公告、普通待办和周报;`project_domain_v1` 只有本地证明报销详情项目关联不降级时才能加入同一批次。
- 合同、采购、资产二级入口及所有模块退役只登记延后条件,不实施、不创建子 task。
## Requirements
### R1. 固化正式产品定位
- 更新 `docs/产品口径.md` 和 `.trellis/spec/hyb-oa/project-governance.md`,用“财务与低频台账后台 + 核心流程真实采用”替换尚未决的 A/B 门。
- 明确 OA 主办、企微主办、低频保留和退役审查候选四个边界;强推报销走 OA 是管理动作,不实现强制提醒、渠道封堵、引导页或新通知功能。
- 只纠正仍会驱动未来工作的 R63–R66 前向引用;历史归档证据不批量改写。
### R2. 定位阶段保持零产品代码;恢复后允许最小 QA harness 修复
- 定位固化、项目准入和首次生产尝试阶段不修改 `client/src`、`server/src`、`scripts/`、migration、依赖、构建或部署配置,也不新增 task-scoped 可执行脚本。
- 首次生产尝试安全恢复后,用户明确批准在再次申请生产前先修复本地观察机制并补五个失败分支。该局部例外只允许新增/修改 `scripts/qa` 的纯状态机与对应本地单元测试;仍不得改产品运行代码、API、schema、依赖或部署配置。
- 允许修改当前 task、既有业务事实源、治理 spec,以及确有误导性的当前任务前向说明。
- 不构建、不部署、不改变 build SHA。生产只使用已部署的功能管理页面和既有 batch API。
### R3. 三个固定 flag 与一个条件 flag 的精确原子关闭
- 固定目标是 `announcements_v1`、`todo_board_v1`、`weekly_report_v1`,目标集合至少且恰好包含这三项;不得包含 `project_linking_v1` 或任何其他 flag。
- `project_domain_v1` 是唯一条件目标。实施前必须在本地关闭它,分别验证未关联项目和已关联项目的报销详情:页面无可见查询错误,未关联控件具有明确且不误导的状态,已关联单据仍显示原项目名称/编号和关联事实。任一项不满足即从生产 target 剔除,项目入口本轮继续保留。
- 本地准入结论已将目标固定为三项;生产变更方式固定为一次 batch mutation,将这三项 `overrideMode` 设为 `force_off`;保留各自既有 `rolloutType` 和 `departmentIds`,填写统一、可审计的定位说明。
- 执行前读取现场 version、override、rollout、effective 状态和三个目标模块当前计数;任一目标不唯一、版本变化、已有真人/业务数据、影响范围超出本合同或生产身份不清楚即停止。
- 生产登录会产生认证 session/audit;除这类认证状态、三条 feature flag 更新及三条对应审计外,不允许业务写入、workspace PATCH、数据清理或其他配置变更。
### R4. 分段批准与不确定性处理
- 当前批准只允许创建 task 和规划,不允许 `task.py start`、事实源改写或生产操作。
- 用户批准本规划并 `task.py start` 后,只先实施本地文档/spec 收敛与只读门禁;本地准入完成后确定的生产 batch(现已固定为三个 flag)仍需再次明确批准。
- 生产批准合同最多包含一次前向 batch;只有批准文本明确包含预定义回滚分支时,才允许在 POST 检查失败时执行最多一次回滚 batch。
- 若请求通道中断、响应缺失或 JSON 不可解析,进入 `APPLY_UNCERTAIN`:只允许重新 GET flag、导航和审计定性,禁止盲目重试。
- 本地观察器计数与 batch 响应或现场状态矛盾同样属于 `APPLY_UNCERTAIN`。未通过只读 GET 三项目标的 version/override/effective 并以审计交叉定性前,不得重试、回滚或执行任何补偿写入;回滚预授权不改变该顺序。
### R5. 写后验证与保留域保护
- 最终 target 均须满足 `overrideMode=force_off`、`effectiveEnabled=false`、`effectiveReason=flag_force_off`、version 相对 PRE 恰好 `+1`,且新增审计条数恰好等于 target 数、每项各一条。
- 管理员导航必须不再出现 `announcements`、`todos`、`weeklyReports`;只有 `project_domain_v1` 通过本地门禁并进入生产 target 时才要求 `projects` 消失。对应直达只读入口应得到明确的 disabled/409,而不是登录失效或 5xx。
- Portal、报销、合同、采购、资产的既有只读入口必须仍可加载,不得出现被动业务 mutation。若 `project_domain_v1` 入选,生产 POST 继续复核本地已证明的项目关联显示合同;“空下拉或隐藏错误”不算通过。
- `project_linking_v1`、合同/采购/资产数据、回滚窗口和其他 feature flag 的 snapshot 必须保持不变。
### R6. 阶段二明确延后
- 合同、采购、资产继续保留现有一级入口和最小能力,本轮不实现“二级入口”。
- 导航层级、分组或二级入口只有在核心财务流程出现更多真人用户、一级导航确有可观察摩擦时,才允许另建产品 task。
- 绩效、薪资、ERP/集成、预算、Cutover 本轮只标记退役审查候选,不关闭 flag、不删入口、不删代码、schema 或历史数据。
## Acceptance Criteria
- [ ] AC1:产品口径和治理 spec 统一写明“财务与低频台账后台 + 核心流程真实采用”,不再保留 A/B 未决状态或 R63–R66 扩建主线。
- [ ] AC2:git diff 证明产品运行代码、migration、依赖和部署配置零改动;首次恢复后的唯一代码例外是 `scripts/qa` 状态机及其 focused 单元测试,无构建、部署或 build SHA 变化。
- [ ] AC3:本地关闭 `project_domain_v1` 后,分别验证未关联和已关联项目的报销详情;只有控件状态明确、已关联项目名称/编号仍正确且无可见错误才允许生产 target=4,否则固定 target=3。
- [ ] AC4:生产 PRE 现场确认三个最终目标模块仍为零数据/零真人活动,三项 flag 目标恰好、effective on、版本已冻结,且完整记录原始 snapshot 与影响范围。
- [ ] AC5:仅在生产写入另获批准后,通过既有页面/API 严格执行一次最终 target batch `force_off`;无单项 PUT、无 workspace PATCH、无其他 flag 或业务数据写入。
- [ ] AC6:POST 证明最终 target 均 effective off、version 各 `+1`、审计增量等于 target 数;对应导航消失,直达得到可解释的 disabled 结果。
- [ ] AC7:Portal、报销、合同、采购、资产只读入口仍可加载;若项目 flag 入选,已关联项目显示仍正确;`project_linking_v1` 与其他 flag 不变,非认证业务 mutation 为 0。
- [ ] AC8:阶段二和退役动作均未实施;合同、采购、资产继续保持可用,二级入口只作为事件触发的后续候选。
- [ ] AC9:task validate、Trellis governance、process contract、docs registry、敏感扫描和 `git diff --check` 通过;生产证据不含账号、Secret、token 或原始业务 payload。
## Out of Scope
- 新功能、UI/导航层级代码、组件改造、路由删除、schema/data migration、模块物理退役。
- 用技术手段“强制”员工报销、禁用微信沟通、增加提醒/推送或制造生产业务记录。
- 关闭合同、采购、资产、付款、AP、票据、认证、权限、审计、备份恢复能力。
- 部署、公网候选发布、企业微信写入、真实付款或任何非本合同生产写入。
## Test Plan
- 静态核对三个最终目标 flag、已排除的项目 flag、batch transaction、后端影响范围和保留域依赖。
- 本地文档阶段运行 task validate、governance、process contract、docs registry、敏感扫描和 `git diff --check`;用 `git diff --name-only` 断言零产品运行代码。恢复后的 QA harness 阶段另跑 Node 语法与五分支 focused test,并继续断言 `client/src`、`server/src`、schema、依赖和部署配置零 diff。
- 本地先建立含项目关联和不含项目关联的报销样本,在 `project_domain_v1=force_off` 下用真实浏览器验证控件与显示;不以组件单测或“页面未崩”代替。
- 生产批准后:身份与运行 SHA 只读 PRE → 现场最终 target 模块计数和 flag snapshot → 管理员登录 → 一次 batch → 只读 flag/audit/navigation/direct-route/保留域 POST。
- 任一生产状态不确定进入 `APPLY_UNCERTAIN`;先只读定性,不连续提交。
## Approval State
用户已于 2026-08-11 完成只读复核并批准 `task.py start`,同时要求先本地验证 `project_domain_v1` 对报销项目关联的影响。该批准允许本地定位固化和合成数据验证,不包含生产 feature flag 写入。
## Local Admission Result
2026-08-11 本地真实浏览器 on/off/restore 验证已完成:`project_domain_v1=force_off` 时,未关联报销的项目控件仍启用但没有关闭说明;已关联报销的事实卡保留项目信息,项目控件却丢失当前选项并回落到“不关联项目”。因此 AC3 的条件未满足,生产 target 已固定为三个:`announcements_v1`、`todo_board_v1`、`weekly_report_v1`。项目 flag 继续保持现状,不允许在本轮生产 batch 中出现。
## Retry Admission After Restored Attempt
2026-08-12 获批的一次前向 batch 已成功落地,但本地页面观察器漏掉 `APIRequestContext` 写入并误触发预授权恢复;生产最终安全回到 `force_on@49`。下一次生产前向写入不是本次批准的延续:必须以现场 version 重新锁定、先在本地覆盖“写入成功但页面观察为 0”及全部不确定分支,并另获明确批准。本任务不得以修正文档为由自动再次写生产。
@@ -0,0 +1,33 @@
{
"id": "internal-oa-finance-backoffice-positioning-and-navigation-reduction",
"name": "internal-oa-finance-backoffice-positioning-and-navigation-reduction",
"title": "财务后台定位与主导航减法",
"description": "固化 OA 为财务与低频台账后台的正式定位,并在另获生产批准后使用既有 batch feature flag 能力可逆关闭公告、普通待办和周报;项目须先通过本地报销关联回归。产品运行代码零改动、无部署;恢复后仅补本地 QA 状态机与测试,不实现二级导航或模块退役。",
"status": "in_progress",
"dev_type": "configuration",
"scope": "task-docs,docs,spec,scripts-qa-local-harness,production-feature-flags",
"package": "hyb-oa",
"priority": "P1",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-11",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"docs/产品口径.md",
".trellis/spec/hyb-oa/project-governance.md",
"server/src/services/workspaceSettings.js",
"server/src/services/featureFlags/index.js",
"server/src/routes/featureFlags.js",
".trellis/tasks/archive/2026-08/08-11-internal-oa-usage-census-and-simplification/research/production-usage-snapshot.json"
],
"notes": "定位与 PRE 已完成,project_domain_v1 已排除。2026-08-12 获批的一次三 flag batch 成功写入 force_off@48,但本地执行器漏计 APIRequestContext 请求并误触发预授权恢复;三项安全恢复为 force_on@49。最终审计增量全局 +6、各目标 +2,Portal 200、导航恢复、回滚窗口 OPEN/0、业务写入 0。一次只读核验因导航响应形状误判在 logout 前退出,遗留 1 个无人持有、自然过期的服务端 session。保持关闭的业务目标未完成;不得沿用 version=47 或本次批准重试。再次申请生产前必须本地覆盖观察矛盾及不确定分支、重新锁定现场 version 并另获批准。",
"meta": {}
}
+6 -5
查看文件
@@ -10,7 +10,7 @@
### P0-1 企微优先 + OA 镜像/接管
- WFOC 起,能用企业微信成熟能力的 HR/考勤员工日常审批优先使用企业微信;OA 做按域镜像、人工接管、恢复回写、财务闭环和审计中台。
- DBWFOC 后固定数据边界:企业微信成熟功能可作为流程、字段、交互和规则参考,但不默认成为高敏业务数据托管处。
- 报销、采购、费用、合同、项目、财务付款、AP 台账、代账交接、审计和敏感附件仍由 OA 主办,在 OA 内提交、审批、留痕、归档。
- OA 正式定位为本公司 5–10 人使用的财务与低频台账后台:报销、费用、票据/OCR、审批、财务付款、AP 台账和代账交接由 OA 主办并推动真实使用;采购、合同、资产作为低频高失败成本台账保留冻结;审计和敏感附件继续由 OA 留痕。项目主域当前无真实使用证据,保持冻结,不作为日常主流程扩建。
- 财务主办口径限定为付款准备、付款批次、回单留痕、AP 状态、票据/OCR 状态和代账交接摘要;真实付款继续在线下银行或既有财务/代账流程完成,OA 不做银行直连、银行流水自动导入、ERP/总账、凭证生成、自动对账或税务申报。
- 请假、补卡、外出、加班等 HR/考勤员工日常审批优先在企业微信原生审批中提交和处理,OA 同步按域镜像;企业微信不可用时由管理员人工开启 OA 应急模式。
- 企业微信作为手机工作台入口、免登/绑定、通讯录身份、消息通知、打卡、人事助手和 HR/考勤员工日常审批主操作端;OA 主办审批通过企业微信个人应用消息发送脱敏提醒并 deeplink 回 OA 处理。
@@ -27,7 +27,7 @@
### P0-3 按域镜像定义
- 企微主办域镜像到 OA:通讯录/人事助手、打卡、请假、补卡、外出、加班、假期余额和 HR/考勤规则。
- OA 主办域不反向全量镜像到企微:报销、采购、费用、合同、项目、付款批次、AP、代账交接和审计只发送脱敏消息、状态和 OA 链接。
- OA 主办域不反向全量镜像到企微:报销、采购、费用、合同、资产、付款批次、AP、代账交接和审计只发送脱敏消息、状态和 OA 链接;项目主域冻结期间不新增企微镜像或日常入口。
- “无痛接管”长期目标只针对必要业务连续性,不承诺把所有 OA 高敏附件和财务资料复制到企业微信。
### P0-4 WFDG 设计门禁
@@ -77,9 +77,10 @@
- 2026-08-11 生产只读普查覆盖 20 个业务模块:聚合总行数 70(其中系统配置 26),13 个模块为零行;近 90 天只有 13 条可归属真人的创建/拥有记录证据,认证成功审计近 30/90 天均只观察到 1 个真人 actor。该证据说明 OA 整体采用度极低,不能描述为 5–10 人团队已经稳定使用。
- `13` 是记录归属证据,不等于 13 次页面点击;认证审计 actor 也不能排除代理提交或覆盖外使用。工作台/通知没有真人交互证据,但本次未统计通知阅读字段,不据此声称“已证明没人看”。完整证据与口径见归档 task `08-11-internal-oa-usage-census-and-simplification`。
- 管理层必须在两种定位中明确选择:A 为要求团队把指定真实流程迁入 OA;B 为不强推日常使用,将 OA 收缩成低频台账和治理兜底。在定位明确前停止新增产品功能 task,真实线上缺陷以及认证、权限、审计、付款职责分离、备份恢复和离职撤权等底线维护除外。
- 两种定位下都先做可逆导航减法:公告、普通待办、周报、项目不再占主导航;合同、采购、资产保留最小能力并优先收进二级入口。任何 workspace、feature flag 或生产配置变更仍须独立 Trellis task 和生产写入批准。
- 选择 A 后,先由管理规则推动一次团队真实流程(优先下一轮报销统一从 OA 提交),再从实际阻塞中选择最多一个小型产品任务;选择 B 后默认只做减法、必要维护与安全恢复,不再追求通用 OA 功能完整度。
- 产品范围和采用力度分开裁决:范围收缩为财务与低频台账后台;在报销、票据/OCR、审批、付款和 AP 等 OA 主办核心流程中要求团队真实使用。请假、考勤和适合在企微完成的日常协作不迁回 OA,也不以 OA 零使用判断业务未发生。
- 公告、普通待办、周报属于可逆关闭目标。这里的“关闭”不是仅隐藏导航:各自 API 同时停用,Portal/搜索按条件省略聚合;例如临时需要发布公告时,管理员须先重新开启公告 flag。项目主域同为零使用冻结候选,但本地验证已证明关闭会破坏报销详情的既有项目关联显示,因此继续保持开启;合同、采购、资产继续保留现有最小入口。任何 workspace、feature flag 或生产配置变更仍须独立 Trellis task 和生产写入批准。
- 绩效、薪资、ERP/集成、预算和 Cutover 只进入退役审查候选,不因本次定位直接关 flag、删入口、删代码或数据。二级导航也不预先建设;只有多名真人持续使用核心财务链并出现明确导航摩擦时才重新评估。
- 报销统一走 OA 是管理规则,不实现渠道封堵、强制提醒、入职向导或新通知功能。后续产品任务只能来自真实核心流程的最高优先级摩擦点;旧 R63–R66 扩建排期不再作为开发主线。
---
@@ -0,0 +1,166 @@
export const FEATURE_FLAG_APPLY_DECISION = Object.freeze({
CONTINUE_POST: "CONTINUE_POST",
STOP_NOT_APPLIED: "STOP_NOT_APPLIED",
STOP_UNKNOWN: "STOP_UNKNOWN",
STOP_FOR_MANUAL_DECISION: "STOP_FOR_MANUAL_DECISION",
});
function normalizeTargets(targets) {
if (!Array.isArray(targets) || targets.length === 0) {
throw new TypeError("targets must be a non-empty array");
}
const keys = targets.map((target) => String(target?.flagKey ?? "").trim());
if (keys.some((key) => !key) || new Set(keys).size !== keys.length) {
throw new TypeError("targets must contain unique non-empty flagKey values");
}
return targets.map((target, index) => {
const preVersion = Number(target.preVersion);
if (!Number.isInteger(preVersion) || preVersion < 1) {
throw new TypeError(`invalid preVersion for ${keys[index]}`);
}
const preOverrideMode = String(target.preOverrideMode ?? "").trim();
const postOverrideMode = String(target.postOverrideMode ?? "").trim();
if (!preOverrideMode || !postOverrideMode) {
throw new TypeError(`missing override mode for ${keys[index]}`);
}
return Object.freeze({
flagKey: keys[index],
preVersion,
preOverrideMode,
postVersion: preVersion + 1,
postOverrideMode,
});
});
}
function normalizeReadback(readback) {
if (!Array.isArray(readback)) {
throw new TypeError("readback must be an array");
}
return new Map(
readback.map((item) => [
String(item?.flagKey ?? "").trim(),
{
version: Number(item?.version),
overrideMode: String(item?.overrideMode ?? "").trim(),
},
]),
);
}
function matchesState(actual, version, overrideMode) {
return actual?.version === version && actual?.overrideMode === overrideMode;
}
export function classifyFeatureFlagApplyReadback({ targets, readback }) {
const normalizedTargets = normalizeTargets(targets);
const actualByKey = normalizeReadback(readback);
const observations = normalizedTargets.map((target) => {
const actual = actualByKey.get(target.flagKey);
if (matchesState(actual, target.postVersion, target.postOverrideMode)) {
return { flagKey: target.flagKey, state: "POST" };
}
if (matchesState(actual, target.preVersion, target.preOverrideMode)) {
return { flagKey: target.flagKey, state: "PRE" };
}
return { flagKey: target.flagKey, state: "OTHER" };
});
if (observations.every((item) => item.state === "POST")) {
return Object.freeze({
decision: FEATURE_FLAG_APPLY_DECISION.CONTINUE_POST,
classification: "APPLIED",
observations: Object.freeze(observations),
});
}
if (observations.every((item) => item.state === "PRE")) {
return Object.freeze({
decision: FEATURE_FLAG_APPLY_DECISION.STOP_NOT_APPLIED,
classification: "NOT_APPLIED",
observations: Object.freeze(observations),
});
}
return Object.freeze({
decision: FEATURE_FLAG_APPLY_DECISION.STOP_FOR_MANUAL_DECISION,
classification: "PARTIAL_OR_UNEXPECTED",
observations: Object.freeze(observations),
});
}
export async function resolveFeatureFlagApplyUncertainty({ targets, readCurrent }) {
if (typeof readCurrent !== "function") {
throw new TypeError("readCurrent must be a function");
}
try {
const readback = await readCurrent();
return classifyFeatureFlagApplyReadback({ targets, readback });
} catch (error) {
return Object.freeze({
decision: FEATURE_FLAG_APPLY_DECISION.STOP_UNKNOWN,
classification: "READBACK_FAILED",
errorCode: error instanceof Error && error.message ? error.message : "readback_failed",
observations: Object.freeze([]),
});
}
}
export function createSingleAttemptMutation(sendMutation) {
if (typeof sendMutation !== "function") {
throw new TypeError("sendMutation must be a function");
}
let attemptCount = 0;
return Object.freeze({
get attemptCount() {
return attemptCount;
},
async execute(payload) {
if (attemptCount !== 0) {
throw new Error("mutation_attempt_already_consumed");
}
attemptCount += 1;
return sendMutation(payload);
},
});
}
export async function executeFeatureFlagApplyAttempt({
targets,
payload,
sendMutation,
readCurrent,
observedRequestCount = null,
}) {
const mutation = createSingleAttemptMutation(sendMutation);
let response = null;
let responseError = null;
try {
response = await mutation.execute(payload);
} catch (error) {
responseError = error instanceof Error && error.message ? error.message : "mutation_result_unknown";
}
const resolution = await resolveFeatureFlagApplyUncertainty({ targets, readCurrent });
const normalizedObservedCount = Number.isInteger(observedRequestCount) && observedRequestCount >= 0
? observedRequestCount
: null;
return Object.freeze({
...resolution,
sendAttemptCount: mutation.attemptCount,
responseKnown: responseError === null,
response,
responseErrorCode: responseError,
observedRequestCount: normalizedObservedCount,
observerMismatch: normalizedObservedCount !== null && normalizedObservedCount !== mutation.attemptCount,
});
}
@@ -0,0 +1,123 @@
const {
FEATURE_FLAG_APPLY_DECISION,
executeFeatureFlagApplyAttempt,
} = require("../../../../scripts/qa/feature-flag-apply-uncertainty.mjs");
const TARGETS = Object.freeze([
{ flagKey: "announcements_v1", preVersion: 49, preOverrideMode: "force_on", postOverrideMode: "force_off" },
{ flagKey: "todo_board_v1", preVersion: 49, preOverrideMode: "force_on", postOverrideMode: "force_off" },
{ flagKey: "weekly_report_v1", preVersion: 49, preOverrideMode: "force_on", postOverrideMode: "force_off" },
]);
function state(kind) {
return TARGETS.map((target, index) => {
if (kind === "partial" && index === TARGETS.length - 1) {
return { flagKey: target.flagKey, version: target.preVersion, overrideMode: target.preOverrideMode };
}
if (kind === "pre") {
return { flagKey: target.flagKey, version: target.preVersion, overrideMode: target.preOverrideMode };
}
return { flagKey: target.flagKey, version: target.preVersion + 1, overrideMode: target.postOverrideMode };
});
}
describe("feature flag apply uncertainty", () => {
it("continues POST when APIRequestContext succeeds although the page observer sees zero", async () => {
const sendBatch = vi.fn().mockResolvedValue({ count: 3 });
const readCurrent = vi.fn().mockResolvedValue(state("post"));
const rollback = vi.fn();
const result = await executeFeatureFlagApplyAttempt({
targets: TARGETS,
payload: { items: TARGETS },
sendMutation: sendBatch,
readCurrent,
observedRequestCount: 0,
});
expect(result).toMatchObject({
decision: FEATURE_FLAG_APPLY_DECISION.CONTINUE_POST,
classification: "APPLIED",
sendAttemptCount: 1,
responseKnown: true,
response: { count: 3 },
observedRequestCount: 0,
observerMismatch: true,
});
expect(sendBatch).toHaveBeenCalledTimes(1);
expect(readCurrent).toHaveBeenCalledTimes(1);
expect(rollback).not.toHaveBeenCalled();
});
it("continues POST when the response is missing but readback proves POST state", async () => {
const rollback = vi.fn();
const result = await executeFeatureFlagApplyAttempt({
targets: TARGETS,
payload: { items: TARGETS },
sendMutation: vi.fn().mockRejectedValue(new Error("transport_result_missing")),
readCurrent: vi.fn().mockResolvedValue(state("post")),
});
expect(result).toMatchObject({
decision: FEATURE_FLAG_APPLY_DECISION.CONTINUE_POST,
classification: "APPLIED",
sendAttemptCount: 1,
responseKnown: false,
});
expect(rollback).not.toHaveBeenCalled();
});
it("stops without retry or rollback when the response is missing and readback remains PRE", async () => {
const retry = vi.fn();
const rollback = vi.fn();
const result = await executeFeatureFlagApplyAttempt({
targets: TARGETS,
payload: { items: TARGETS },
sendMutation: vi.fn().mockRejectedValue(new Error("transport_result_missing")),
readCurrent: vi.fn().mockResolvedValue(state("pre")),
});
expect(result).toMatchObject({
decision: FEATURE_FLAG_APPLY_DECISION.STOP_NOT_APPLIED,
classification: "NOT_APPLIED",
sendAttemptCount: 1,
responseKnown: false,
});
expect(retry).not.toHaveBeenCalled();
expect(rollback).not.toHaveBeenCalled();
});
it("stops unknown with zero mutation when readback fails", async () => {
const retry = vi.fn();
const rollback = vi.fn();
const result = await executeFeatureFlagApplyAttempt({
targets: TARGETS,
payload: { items: TARGETS },
sendMutation: vi.fn().mockRejectedValue(new Error("transport_result_missing")),
readCurrent: vi.fn().mockRejectedValue(new Error("readback_unavailable")),
});
expect(result).toMatchObject({ decision: FEATURE_FLAG_APPLY_DECISION.STOP_UNKNOWN, classification: "READBACK_FAILED" });
expect(retry).not.toHaveBeenCalled();
expect(rollback).not.toHaveBeenCalled();
});
it("stops for manual decision with zero mutation when readback is partial", async () => {
const continuePost = vi.fn();
const rollback = vi.fn();
const result = await executeFeatureFlagApplyAttempt({
targets: TARGETS,
payload: { items: TARGETS },
sendMutation: vi.fn().mockRejectedValue(new Error("transport_result_missing")),
readCurrent: vi.fn().mockResolvedValue(state("partial")),
});
expect(result).toMatchObject({
decision: FEATURE_FLAG_APPLY_DECISION.STOP_FOR_MANUAL_DECISION,
classification: "PARTIAL_OR_UNEXPECTED",
});
expect(result.observations.map((item) => item.state)).toEqual(["POST", "POST", "PRE"]);
expect(continuePost).not.toHaveBeenCalled();
expect(rollback).not.toHaveBeenCalled();
});
});