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

27 KiB
Raw Blame History

答卷差异对账 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 为准。 MQ publisher confirm/return 与 B-09 仍是独立投递状态机,本批没有修改。 有效租约中的 SH RUNNING 行保留执行 attempt 与 work_version;过期或终态再接纳重派。 行锁领取、领取时二次到期判定、work_version 过期执行者保护共同防止旧执行清除新租约。