Files
form-center-service/docs/answer-reconcile-v1.md
T
chenxing 6ff39b0cec Define authoritative reconciliation acknowledgements
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:16 +08:00

377 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 答卷差异对账 v1(TASK_3 · FC 统一发起,SH 执行修复并回传可追踪结果)
## 2026-10-22代码精修修订(优先于本文初版描述)
- 指令签名方是FC,目标消费方是SH,两种appKey分开核验。SH必须设置 `svchall.answer-reconcile.source-app-key` 为FC实际签名身份;空值拒收。信封sourceAppKey也与该配置核对;不能要求FC使用SH身份签名。
- 派发不可用同样先领取尝试次数,再进入有界退避/FAILED;不在QUEUED状态调用只匹配DISPATCHED的更新。
- RUNNING心跳可多次刷新,绑定当前attempt;同attempt的RUNNING记录可以推进为终态结果。COMPLETED必须同时声明两侧扫描完成且中心manifest已物化完整。终态结果重复提交只作身份核验与幂等确认,不重新执行;终态任务不再提供清单/快照/缺席证明,返回TASK_TERMINAL。
- SH同/旧attempt重复指令不重开终态行;更高attempt才可重新排队,work_version防止旧Worker回写新指令。执行/结果上报失败使用独立retry_count,默认6次、指数退避至上限;结果上报失败只重报,不重扫。新增本地字段升级脚本add_reconcile_work_recovery.sql未执行;新库使用answer_reconcile_work.sql,二者择一。
- 修复冲突后的中心重读穿透缓存,缓存按页清理;DEFERRED保留计数/错误/候选但不推进水位。权威答复及信封逐项核对task/consumer/school/form/answer/version/grant,不把合法签名等同任意坐标合法。
- 授权决策复用TASK_1的requireRead/requireSubscribe;资源校域校验已补齐,但应用可服务学校集合事实源仍未实现,不能宣称解决Task2 §9#1的全部问题。
- 本轮仅编译/跳过测试打包;历史36+36测试结果不构成精修后状态机、数据库并发或签名互调验证。Task4已交付独立文档草案,不实施候选schema功能。
日期:2026-10-21。适用范围:`form-center`(中心/发起方)与 `svc-hall`(消费方/执行方)之间的答卷集合对账协议。
前置:TASK_1 消费授权 v3(`docs/consumer-access-v3.md`)、TASK_2 快照投递 v2 及其前置修正
(`docs/answer-delivery-v2.md` §8/§8.1、`docs/tasks/TASK_2_PREFIX_CHECK.md`)。
本文是**协议与边界的事实源**;TASK_1/2 的文件一律只调用、不修改。
---
## 1. 这套东西解决什么、不解决什么
游标增量拉取(`GET /oapi/form/answers`)回答的是"最近变了哪些",它**不能**回答三个问题:
两侧集合到底一不一致、不一致时以谁为准、修复之后有没有可追踪的回报。本任务补的就是这三件事。
| 它是什么 | 它不是什么 |
|---|---|
| 一轮**双边集合验证** + 有界修复 + 可追踪回报 | 不是第二份增量拉取(一致的行不会去中心取值) |
| 中心签发的**任务级权威结论**(清单/当前快照/缺席证明) | 不是消费方本地判断的背书(本地永远不能自证"这行该删") |
| 投递链(TASK_2)之外的**独立修复通道** | 不是重投、不是建件、不是流程补偿 |
| `answer_reconcile_*` 三张中心表 + 一张消费表 | **不是** `answer_op_log`(不得拿它当任务/答卷/投递主表) |
明确不在范围内:`svc-hall` 的 `FlowReconcileJob` 仍是流程恢复域,本任务不碰它;
对账修复不生成虚假的 MQ 消费成功回执,不重放历史 intent,不自动发起办件或流程命令。
---
## 2. 任务坐标:一行任务 = 一个「消费方 appKey + 消费租户域 + 表单」
`consumerSchoolId` 与投递行同口径,**就是答卷的归属校**(中心侧 `findByUuidAndSchId(uuid, consumerSchoolId)`
与 `listEnabledSubscriptions(formUuid, answer.getSchId())` 都是这个语义)。
任务范围只到**一张表单**,不做"一行任务覆盖全部表单"的设计,理由不是审美:
1. 清单游标与消费端本地扫描游标都是 `answerUuid`(32 位小写十六进制,单字段升序)。
跨表单就必须复合游标,两侧无法锁步比较,多余行判定会失去依据;
2. **全量 = 按生效订阅裂变成多行任务**,每行仍然单表单。裂变性受 `max-tasks-per-run` 约束,
因此不存在"一次人工触发把整库变成一个无界大任务"的形态;
3. 单并发、取消、租约、结果幂等这些语义都落在"一行任务"上才有清晰定义。
`scope=ANSWER`(单答卷)是同一坐标下的**一行清单**特例:清单里就这一行(或零行=缺席候选)。
### 2.1 简单表单不强建消费者
建任务的第一道闸是 TASK_1 的生效订阅(`enabled=1 AND subscribe_allowed=1 AND read_allowed=1`)。
没有订阅就没有任务——本任务**不创建** grant 行,也不因为"这张表看起来有人要"而代填授权。
读权与订阅权必须同时开着:清单答复与权威快照都走 `requireRead`,而消费端修复前的新鲜核验走
`requireSubscribe`,缺一条就会造出一轮"能建、必失败"的任务。
`scope=ANSWER`(单答卷)**同样过这道闸**:答卷归属校就是它的消费租户域坐标,该域上没有生效订阅时
直接以 `GRANT_REVOKED` 拒建——否则"指定一份答卷"会变成撤权后的后门。
---
## 3. 不可变清单(manifest)
### 3.1 物化
`AnswerReconcileReadRepository`(本任务新增的只读仓库)按 `(form_uuid, sch_id)` 的 `uuid` 升序 keyset
分页读 `answer`,在**一个一致读事务内**把摘要写进 `answer_reconcile_manifest_row`:
```
(answer_uuid, revision, content_hash, is_deleted, form_uuid, form_version, seq_no)
```
- **含墓碑**:`is_deleted=1` 的行照样入清单。"缺席"与"存在但已删"是两个不同结论,
后者必须走权威修复保留本地业务扩展,不能被当成前者;
- `content_hash` 由 `AnswerSnapshotService#contentHash` 现算(`fc.canon.v1`),
与投递行同一条规范化实现。**本任务不复制任何规范化逻辑**;
- `form_version` 取该行 `answer.version`(答卷钉住的 schema 版本),不是表单当前版本;
- 清单行的 JPA 列全部 `updatable=false`,仓库层不提供 update/delete——它是这一轮的冻结事实源;
- 事务回滚时**清单行与检查点一起消失**,因此崩溃后是从头重物化,不存在"半截清单 + 半截检查点"
这种会把差异判没的形态;
- 修订号为 0 的存量行原样入清单,但**不可被权威修复**(消费端 `validate` 要求 revision ≥ 1),
Worker 按 `DIFF_REVISION_ZERO` 只报告。
### 3.2 分页与完整性
```
GET /oapi/form-sync/tasks/{taskId}/manifest?cursor={answerUuid}&size={n}
→ { taskId, rows[], nextCursor, completed, totalRows, totalPages, taskState }
```
- `size` 默认 `page-size`(200),上限 500;游标非 32 位十六进制即拒(页码与库内 id 都不是合法游标);
- **`completed=true` 是唯一的结束标记**。某一页空了不构成结束证据——中心清单物化完就有
`total_rows/total_pages`,读侧只需把 `completed` 当判据;
- 清单未完整(`manifestComplete=0`)时该端点直接拒答分页,避免"读到空=集合空"的误判;
- 消费端把每页的 `taskState` 也当判据:中心已把任务落成 `CANCELLED` 时立刻停止剩余修复。
### 3.3 资源上限(超限=拒绝,不是硬扛)
| 键 | 默认 | 越界处置 |
|---|---|---|
| `max-rows` | 20000 | 建任务前按 `count(answer)` 预检;越界→不落清单,任务 `FAILED` + `RESOURCE_LIMIT` + 原因 |
| `max-pages` | 200 | 双保险(页大小被调大时仍兜底);物化途中越界→整笔回滚 + `FAILED` |
| `max-tasks-per-run` | 20 | 一次调度/一次 ALL 人工触发最多裂变这么多行 |
| `max-error-items` / `max-error-chars` | 50 / 8000 | 结果明细截断存,计数不丢 |
| SH `max-pending-extras` | 500 | 多余行候选越界→**本轮一行都不退役**,只报告 |
拒绝建任务会**留下一行 `FAILED` 任务**并带稳定码与原因:拒绝必须是可查的事实,
而不是操作人屏幕上闪过的一行字。这一行永远不会被派发(`manifest_state=FAILED`)。
---
## 4. 任务状态机
```
物化完成 派发 2xx 结果回报
QUEUED ──────────────────────────► QUEUED ────────────► DISPATCHED ────────────┬─► COMPLETED
│ (manifest PENDING→COMPLETE) (lease 起算) ├─► PARTIAL
│ ├─► FAILED
└── 超限/撤权/跨校 → FAILED(带原因,永不派发) └─► CANCELLED
RUNNING ← 消费端进度回报(只续租,不落终态)
```
七个状态是本任务的验收闭集,语义钉死:
| 状态 | 判据 | 明确不代表 |
|---|---|---|
| `QUEUED` | 清单已完整(或尚未物化),等派发 | — |
| `DISPATCHED` | 对端 2xx 收下指令并落库 | **不代表**任何修复;不代表消费方本地数据已对 |
| `RUNNING` | 消费端进度回报刷新过租约 | 不是终态,不推进计数结论 |
| `COMPLETED` | 清单与本地扫描都完整,且无未决差异 | 不代表"修好了很多行"(可能是本来就一致) |
| `PARTIAL` | 跑完了,但仍有只报告项/暂停项/重试耗尽项 | 不等于失败,也不等于成功 |
| `FAILED` | 资源拒绝、物化中断、派发或租约预算耗尽 | 不猜对端状态 |
| `CANCELLED` | 人工取消,或核验时发现撤权/授权纪元进位 | — |
推进规则:**只有 result 回报能推终态**;`attempt` 是幂等键的一部分。
- 派发领取与租约续期都是**条件更新**(`claimForDispatch` 带 `state='QUEUED' AND manifest_complete=1
AND (next_dispatch_at IS NULL OR <= now)`),两个扫描线程同时看到同一行时只有一个能刷新租约;
- 同一消费方存在 `DISPATCHED/RUNNING` 在途任务时**不派下一条**(单并发);
- 租约超期:`attempt < max-dispatch-attempts` → 回 `QUEUED` 并按 `base×2^n`(封顶 `backoff-cap-seconds`)
重派,同 `taskId` 复用检查点;到上限 → `FAILED`,留可查记录,不无限重投。
- 消费端收到**更大 attempt** 的指令时:本地行若已是终态或已停摆,就按新 attempt **重新排队并从水位续读**
(只回 `duplicate` 吸掉会让中心一路重派到预算耗尽,最后落成 `FAILED` 假象);
若本地仍在跑且租约有效,则只刷新身份与坐标,**不打断正在执行的这一轮**。
更旧 attempt 一律不动 `attempt`、不动检查点、不动已提交结论。
---
## 5. 协议端点与身份纪律
| 方向 | 端点 | 用途 |
|---|---|---|
| FC | `GET /oapi/form-sync/tasks/{taskId}/manifest` | 读这一轮的不可变清单分页 |
| FC | `GET /oapi/form-sync/tasks/{taskId}/answers/{answerUuid}[?expectedRevision=]` | 权威当前快照信封,或权威缺席证明 |
| FC | `POST /oapi/form-sync/tasks/{taskId}/result` | 结果回报(`(taskId, attempt)` 幂等) |
| FC | `POST` 派发 → SH `POST /oapi/form-sync/reconcile` | 指令下发(服务端登记地址 + 平台签名) |
| FC 管理面 | `/api/form/admin/sync/**` | 建任务/列表/详情/结果/取消/单答卷重放/投递失败查看 |
五条不可绕的纪律:
1. **身份只来自平台验签**。中心读 `request.getAttribute(AppAuthFilter.APP_AUTH_ATTR)` 经
`ConsumerPrincipalResolver` 换 `ConsumerPrincipal`;请求体里自报的 `consumerAppKey` 只用于
**比对是否冒充**,与验签身份不符即 403。消费端同理:指令的 `consumerAppKey` 与验签来的 appKey
必须都等于本应用消费身份,否则整条指令拒收。
2. **每一次**清单/快照/结果调用都重新核验:任务存在 → 身份等于任务目标 → 未取消 →
最新 grant 仍生效(`enabled`/`subscribe`/`read` 三条)且**纪元未进位**。
撤权或纪元进位时中心把任务落成 `CANCELLED` 并返回稳定码,消费端据此停止剩余投递与修复——
绝不因"建任务时有权"永久放行。
3. **派发地址只能来自服务端登记表** `fc.answer.reconcile.targets.{consumerAppKey}`。
指令载荷里没有 callbackUrl 字段,浏览器传入的任何 URL 无从采信;未登记 →
`TARGET_NOT_REGISTERED`,任务退回 `QUEUED` 等人工处置,不改数据。
4. 缺席证明与权威快照都由中心构造,`formUuid` 取任务行、`formVersion` 取中心**当前答卷行**
(不从旧清单猜版本),`eventId` 是服务端 UUID(≤36),`proofId` ≤128。
`proofId` 只是可追踪引用,**不是身份凭证**;消费端严禁把 `repairAuthoritative` 直接绑到浏览器可提交的入口上。
5. 敏感数据:答卷 `values` 只出现在签名任务端点按授权下发的快照里;错误明细与日志里
只有 `consumer / task / range / answer / revision`,逐字段搬运并截断,绝不整包序列化请求体。
---
## 6. 差异分类与修复规则(消费侧)
Worker 每轮从**同一水位**读两侧一页(中心清单 + `AnswerProjectionStore#manifestPage`),
取两侧页末键的较小者作本轮边界,比较 `(answerUuid, revision, contentHash, isDeleted)`:
| 方向 | 稳定码 | 处置 |
|---|---|---|
| 中心有、本地无 | `MISSING` | 取中心**当前**快照 → `repairAuthoritative(env, null, null, null, proofId)`(期望三元组全 null=观察到缺失) |
| 本地 revision 更低 | `STALE` | 取中心当前快照 → 按本地扫描三元组条件更新 |
| 同 revision 异 hash | `HASH_CONFLICT` | 先记冲突,再取中心当前值走**同一权威入口**;不走普通 `applySnapshot` 强盖 |
| `isDeleted` 不符 | `TOMBSTONE` | 同一权威修复原语;本地业务扩展列(`biz_ext_json`/`apply_id`/`flow_guid`/`link_state`)由条件更新排除在写集之外 |
| 本地 revision 更高 | `LOCAL_AHEAD` | 向中心复核**当前值**:中心更大→按 `STALE` 修到新值;否则只报告,**禁止降级覆盖** |
| 本地有、清单里没有 | `EXTRA_ROW` | 只登记候选,见 §7 |
| 修订号为 0 | `DIFF_REVISION_ZERO` | 只报告(契约要求 revision ≥ 1 才受理修复) |
**一致的行不向中心取快照**:完整扫描不是第二份全量拉取,只有检出差异才发起一次权威读取。
### 6.1 RepairResult 四分支
| 返回值 | 含义 | Worker 处置 |
|---|---|---|
| `APPLIED` | 数据面已按中心当前值落定 | 计数 +1;**不代表**建件或流程成功 |
| `LOCAL_CHANGED` | 本地期望三元组已变化(含条件更新未命中) | 重读本地行与中心当前值后**有界重试** |
| `LOCAL_AHEAD` | 本地 revision 更高 | 停止该条,向中心复核,不降级 |
| `DEFERRED` | 新鲜订阅核验不可达/撤权/授权纪元不等 | 暂停修复,本轮按任务授权状态收尾(`PARTIAL`) |
### 6.2 并发与回滚(本轮最关键的一条)
- `projection_version` 乐观锁冲突、`uk_projection_answer` 并发插入的唯一键异常,都必须使
**当笔事务整笔回滚**,然后在**事务外**重读后再有界重试(`max-repair-retries`,默认 3)。
Worker 的执行循环本身**不开事务**:领取、检查点落库、单次修复各自是独立短事务,
这样才可能在"已标 rollback-only 的事务"之外续跑。
- 缺失插入撞唯一键后重读若已存在,则本轮该条从 `MISSING` 退化为按本地三元组比较,不硬插第二条。
- **运行联调前必须确认 `svc-hall/server/sql/add_fc_projection_version.sql` 已应用**(已有库),
或新库直接用含该列的建表脚本。TASK_3 不得绕开这一列。
### 6.3 禁止事项
对账修复**禁止调用 `applySnapshot`**。它仍是普通 MQ 消费入口,保持"同版异 hash 不覆盖"的既有规则,
并可能关联意图/建件/发流程。修复入口只更新中心快照列与修复留证(`last_event_id`/`conflict_detail`),
不碰 `inbox`/`intent`/`apply`/`flow`,不生成 MQ 消费成功回执。
---
## 7. 多余行与权威缺席证明
`retireAbsent(AuthoritativeAbsenceProof)` 的前置条件**全部**成立才允许调用:
1. 中心清单整轮完整结束(`manifestComplete=1` 且本轮读到 `completed=true`);
2. 本地图像也扫完(`localCompleted=1`);
3. 该行属于本任务的 `(sch_id, form_uuid)`;
4. 该行 `createTime` **不晚于**任务起点 `windowStart` 且**非空**;
晚于起点=期间新增,为空或归属不确定=不可判,一律只报告;
5. 中心对同一 `answerUuid` 答复 `presence=ABSENT` 且证明的
`taskId / formUuid / schId / answerUuid / expectedRevision` 与本地候选逐字一致;
6. 多余行候选总数未越 `max-pending-extras`。
`expectedRevision` 由消费端扫描观察值提供,作用是与 `retireAbsent` 内部的"版本不符即不退役 + 标冲突"
形成并发护栏;**它不是中心对本地版本的断言**。
任何跨页失败、撤权、范围改变、候选超限的情况:未见到的行绝不当作"应删除",
已经登记为候选的行也**整体不退役**(宁缺勿滥),只把差异报回去。
---
## 8. 与 TASK_2 服务能力的接触面(只调用)
| 能力 | 中心侧 | 消费侧 |
|---|---|---|
| 权威当前快照 | `AnswerSnapshotService#currentAuthorized(caller, consumerSchoolId, answerUuid)` | — |
| 修复入口 | 签发信封 | `AnswerProjectionStore#repairAuthoritative(env, expRev, expHash, expDel, proofId)` |
| 本地集合扫描 | — | `AnswerProjectionStore#manifestPage(schId, formUuid, cursor, size)` |
| 缺席处置 | 签发证明 | `AnswerProjectionStore#retireAbsent(proof)` |
| 投递重放 | `AnswerDeliveryService#replayAuthorized(...)`(管理面单答卷重放) | — |
| 投递失败查看 | `AnswerDeliveryService#failedPage(...)` | — |
| 授权判据 | `FormConsumerAccessService#requireRead/requireSubscribe/listEnabledSubscriptions`、`FormConsumerGrantRepository` | 修复前 `verifyGrants` 新鲜核验(TASK_2 内部) |
任务书 §59 的停止线本次未触发:**没有**在 `OapiFormService` / `AnswerRepository` /
`FcEventPublisher` / `AnswerProjectionStore` 里补快捷查询。摘要取数走本任务新增的
`AnswerReconcileReadRepository`;哈希复用中心既有实现,未复制规范化代码。
---
## 9. 配置键(默认值即交付形态,本任务不改两个应用的 `application.yml`)
中心 `fc.answer.reconcile.*`(`AnswerReconcileConfiguration.AnswerReconcileProperties`):
| 键 | 默认 | 说明 |
|---|---|---|
| `schedule-enabled` | **false** | 每日建任务总闸;关时一行任务都不建 |
| `schedule-cron` | `0 0 2 * * ?` | 拟配置时点 |
| `schedule-zone` | `Asia/Shanghai` | 营业日按此时区取 |
| `dispatch-enabled` | **false** | 物化 + 派发 + 租约回收总闸;关时仍可建任务、查结果、手动重放 |
| `page-size` / `max-rows` / `max-pages` | 200 / 20000 / 200 | 见 §3.3 |
| `max-tasks-per-run` | 20 | 裂变性上限 |
| `lease-seconds` | 900 | 租约 |
| `max-dispatch-attempts` | 4 | 含首派的派发预算 |
| `backoff-base-seconds` / `backoff-cap-seconds` | 60 / 3600 | 退避 |
| `targets.{appKey}` | 空 | **必填**才能派发:已登记的消费方后台服务地址(含 context-path) |
| `dispatch-path` | `/oapi/form-sync/reconcile` | 派发路径 |
| `source-app-key` | 空→回落 `yh.app-auth.app-key` | 信封 `sourceAppKey` |
| `scan-interval-ms` / `scan-initial-delay-ms` | 15000 / 45000 | 中心扫描闹钟 |
消费侧 `svchall.answer-reconcile.*`:
| 键 | 默认 | 说明 |
|---|---|---|
| `enabled` | **false** | 执行总闸(接收落库不受它管——落库只是事实,执行才动数据) |
| `lease-seconds` | 900 | 本地执行租约(超租约的 `RUNNING` 可被重新领取并从水位续读) |
| `max-repair-retries` | 3 | 单条差异的有界重试预算 |
| `max-pending-extras` | 500 | 多余行候选上限(越界=整轮不退役) |
| `max-error-items` | 50 | 明细上限 |
| `max-work-per-scan` | 1 | 每 tick 执行任务数(单并发) |
| `progress-report-rounds` | 10 | 每 N 轮向中心回报一次 `RUNNING` 以续租 |
| `backoff-base-seconds` / `backoff-cap-seconds` | 60 / 1800 | 中断/回报不可达的退避 |
| `scan-interval-ms` / `scan-initial-delay-ms` | 30000 / 20000 | 消费侧扫描闹钟 |
### 9.1 部署顺序(硬约束)
1. 中心:`sql/answer_reconcile_v1.sql`(三张表;已有库先确认表不存在,脚本非幂等);
2. 消费侧:确认 `fc_answer_projection.projection_version` 已存在
(新库用 `fc_projection_and_apply_intent.sql`,已有库用 `add_fc_projection_version.sql`,二选一),
再执行 `sql/answer_reconcile_work.sql`;
3. 密钥与白名单:消费方必须已登记进中心 `yh.app-auth.trusted-apps`(本机为 `100018`),
中心 `100021` 必须已登记进消费方 `trusted-apps` —— 否则签名互验 401;
4. 服务目录/派发地址:中心侧配 `fc.answer.reconcile.targets.100018`,
消费侧配 `YH_OPEN_SERVICES_FORMCENTER`(`yh.open.services.form-center` 不在本轮可改文件内,需运行时注入);
5. 先有生效 grant,再开 `schedule-enabled` / `dispatch-enabled`。
**本轮没有开启任何调度**,也没有对真实数据做过派发或修复。
---
## 10. 验收证据与未验收项(诚实边界)
已取证的(本机桩级,规则单测):
- `FcReconcileTaskTest`:36 项 —— 调度唯一键幂等、未授权订阅不建任务、资源超限拒绝并留原因、
单并发不叠任务、跨校范围拒绝、裂变上限、清单物化含墓碑且哈希同源自 TASK_2、
物化越界整笔不落、UUID 游标跨页/整页到底/非法游标、清单未完整拒答分页、
信封坐标取中心已核验值(版本来自当前答卷行而非旧清单)、墓碑形态、
缺席证明三前置、期间新增不签证明、撤权/纪元进位即取消、跨消费方读取 403、
已取消任务不再答复、重复回报幂等吸收、旧 attempt 不倒退新 attempt、
假 result(超派发 attempt / 身份不符)拒、`COMPLETED` 与 `PARTIAL` 与 `RUNNING` 三种推进区别、
回报状态闭集外即拒、明细逐字段规整且有上限、派发 2xx 只标 `DISPATCHED`、
同消费方在途不派下一条、未登记地址不猜 URL、派发预算耗尽即 `FAILED`、
租约超期重派与耗尽落败、重放先重过 ACL 再执行、跨域寻址按不存在、取消幂等。
- `ShReconcileTaskTest`:36 项 —— 接收与执行解耦(2xx 只落 `QUEUED`)、三类身份不符拒收、
坐标/起点/attempt 校验、重复指令复用 taskId 与检查点、开关未开或出站未就绪不动数据、
缺失/陈旧/同版错 hash/墓碑四类走权威入口且 `applySnapshot` 永不被调、
本地更高不降级(含中心已推进到新值时修到新值)、一致行完全不向中心取快照、
修订号 0 只报告、`DEFERRED` 即暂停、`LOCAL_AHEAD` 分支、`LOCAL_CHANGED` 事务外重读后有界重试、
乐观锁与唯一键竞争整笔回滚后重试且预算耗尽只报告、多余行需完整扫描 + 匹配证明才退役、
证明坐标不符/只报告口径不退役、期间新增与 `createTime` 为空永不退役、候选越界整轮不退役、
UUID 游标锁步跨页、空页未到底即停滞且不回报假结果、跨页失败保检查点且不退役、
断线重启从水位续读并计 `resumed`、中心取消即停、中心不可达不写终态、
结果送不到时留 `QUEUED` 等重报、旧 attempt 回报被中心判陈旧时不覆盖结论、进度续租不落终态、
错误明细带全四坐标且不含 values、租约有效时新 attempt 只刷新身份不打断在跑的一轮。
门禁命令与退出码原文、首跑 7 红的根因表、以及门禁全绿后自查补的三处缺陷
(`ANSWER` 范围漏过订阅核验、一致行也向中心取快照、终态行被重派时不重新排队)见
`docs/tasks/evidence/task3/gate-commands.md`;Surefire 原文同目录。
**未取得、留给联调的证据**(不得被上述绿灯冒充):
1. 真实 MySQL:一致读快照的实际隔离效果、`uk_manifest_row`/`uk_result_attempt`/`uk_reconcile_work_task`
的真冲突、`projection_version` 乐观锁与 `uk_projection_answer` 并发插入的整笔回滚、
条件更新在并发下的实际原子性;两个 DDL 脚本本轮**未执行**;
2. 真实调度:本轮没有触发过 `@Scheduled`,02:00 时点与租约超期都是配置形态 + 桩级验证;
3. 真实签名互调:中心↔消费方的 `/oapi/**` 往返、密钥互登、服务目录解析都未在本机跑通
(本机 `yh.open.services.form-center` 需运行时注入);
4. 前端视觉证据:`form-center/front` 只到构建级验证;`/admin/sync` 的**菜单模块行尚未在 UserBase 登记**
(本任务禁改 `UserBase/**`),当前只能以 hash 地址直达;
5. TASK_2 §9 的五条待修(尤其跨校授权缺"调用方属于该租户域"的事实源)与 TASK_1 的四条审计问题
各自保留。本任务的应对是**拒绝建跨校任务**(`SCOPE_MISMATCH`),不是绕过它。
---
## 11. 修订记录
| 日期 | 变更 | 依据 |
|---|---|---|
| 2026-10-21 | v1 落地:任务坐标到表单为止(全量=按订阅裂变)、清单不可变物化 + `completed` 唯一结束判据、双流锁步水位、缺席证明六前置、`RepairResult` 四分支与整笔回滚后事务外重读、派发 2xx 与终态严格分离、调度与派发双闸默认关 | 任务书 TASK_3 改动点 1–8 与 `TASK_2_PREFIX_CHECK.md` 接入清单 |
| 2026-10-21 | 明确不接触 `application.yml`:全部键以 `@ConfigurationProperties`/`@Value` 默认值交付,env 注入生效 | 约束「配置在本任务独立 `@Configuration` 中,不争用 TASK_2 配置」 |
## 2026-10-23 R3/R4 收口
`ResultAck.decision` 采用 CURRENT_ACCEPTED / CURRENT_DUPLICATE / STALE_RETRY / CENTER_TERMINAL。
`reportAttempt` 回声本次回报,既有 `attempt` 为中心派发轮次;`resultState` 表示被采纳或首次保存的结果。
旧轮不推进新轮;只有显式 CENTER_TERMINAL 可以在 stale=true 时令 SH 镜像中心终态并停止重试。
缺少有效确认、身份/轮次不匹配、网络/不可路由保持重报,不得记成功。
完整状态矩阵以 [answer-delivery-v2 §11](answer-delivery-v2.md#11-r3r4-对账结果回执裁决2026-10-23-收口) 为准。
MQ publisher confirm/return 与 B-09 仍是独立投递状态机,本批没有修改。
有效租约中的 SH RUNNING 行保留执行 attempt 与 work_version;过期或终态再接纳重派。
行锁领取、领取时二次到期判定、work_version 过期执行者保护共同防止旧执行清除新租约。