Selaa lähdekoodia

feat(ac15): 断言分「业务 / 基建」,闸门只认业务断言(commit 0,不动 case)

问题:上一道闸"每条 case 至少一条非 Skipped 断言"在活动域会失效 ——
活动链那 8 支 SP 不碰资金表,Δbalance/Δincome/Δspecial 全结构性 Skipped,
而「回滚后零残留」永远为真 ⇒ 闸恒过,那条 case 什么都没测。
这是"空过断言"换了个域复发。

改动:
1. Assertion 加 Kind(AssertionBusiness / AssertionInfrastructure / AssertionUnclassified)
2. 闸门 EnsureNotAllSkipped → EnsureHasBusiness:只认非 Skipped 的业务断言
3. 现有 3 类断言归为基建:回滚后零残留(35) / 两侧基线逐分相等(17) / case 有效性前提(3)
4. Summarize 打出「业务 181 | 基建 55 | 未分类 0」,且未分类 > 0 时整批不通过
5. PrintResult 对非业务断言打 [业务]/[基建] 前缀,未分类打 ⚠

★ 默认值选严的一侧:AssertionUnclassified 是零值且**不算业务断言**。
若把 AssertionBusiness 设成 iota 第一个,"新建 Assertion{} 忘了标 Kind"会静默变成业务断言
⇒ 闸门随"忘写"而放宽。现在忘写 = 业务断言 0 = FATAL。
TestKindZeroIsStrict 锁住这条纪律本身(有人把 Business 改成 0 会立刻红)。
README §18 记下这条工程纪律,并列出同一道理在本包出现过四次的清单。

验收(干净树):
  分母 35=35=35 | 35 PASS | 0 FAIL | Skipped 69(与改前一致)
  业务 181 / 基建 55 / 未分类 0 | go build + vet + test 全绿
Q 5 tuntia sitten
vanhempi
sitoutus
90079c527b

+ 43 - 0
internal/academic/dualrun/stage2/README.md

@@ -1180,3 +1180,46 @@ $sql = "SELECT SUM(t1.discount_amount-t1.discount_amount_used) as amount FROM ..
 (两个参数绑成一个开关:传 `schoolid>0, campusid=0` 时条件变成 `AND campusid=0`,
 只统计全机构池、漏掉本校区全部额度)。**而那个缺陷在生产上从未发生**,因为这段 SP 根本没被调用过。
 它**不是"待迁移的风险",而是"如果照 dump 抄就会被引入的风险"** —— 这两句话的处置完全不同。
+
+## 18. 断言分「业务 / 基建」:闸门只认业务断言
+
+### 18.1 为什么必须分
+
+活动链那 8 支 SP **不碰资金表**(§17.1),它们的 `Δbalance / Δincome / Δspecial` 全是结构性 `Skipped`。
+而「回滚后零残留」这类断言**永远为真**。于是:
+
+> "每条 case 至少一条非 `Skipped` 断言"这道闸 **形同虚设** —— 残留那条在,闸就满足了,
+> 而那条 case 什么都没测。**这是"空过断言"换了个域复发。**
+
+所以 `Assertion` 加 `Kind`,闸门改成 `EnsureHasBusiness`:**只认 `AssertionBusiness`**。
+
+| Kind | 包含哪些 | 计入闸门? |
+|---|---|:--:|
+| `AssertionBusiness` | 金额 Δ、流水明细、活动状态演化、SP 返回值、拒绝契约、共同不变量… | **✔** |
+| `AssertionInfrastructure` | ① 回滚后零残留 ② 两侧基线逐分相等 ③ case 有效性前提(旧库无别的可失效报名 / 无别的负 log) | ✘ |
+| `AssertionUnclassified`(零值) | **不许出现**。报告里打 `⚠`,且不计入业务 | ✘ |
+
+当前 35 条的分布:**业务 181 条 / 基建 55 条**(残留 35 + 基线 17 + 有效性前提 3)/ 未分类 **0** 条。
+
+### 18.2 工程纪律:**默认值永远选严的那一侧**
+
+`AssertionUnclassified` 是**零值**,而且**不算业务断言**。这是故意的:
+
+- 若把 `AssertionBusiness` 设成零值(`iota` 的第一个),那么"新建 `Assertion{}` 忘了标 `Kind`"
+  会**静默变成业务断言** ⇒ 闸门随"忘写"而放宽,正是我们要防的那类退化;
+- 现在默认是 `Unclassified` ⇒ 忘写会让该 case 因为"业务断言 = 0"直接 **FATAL**,
+  而且 `Summarize` 会把"未分类断言数"打出来(`> 0` 时整批不通过)。
+
+> **默认值永远要选严的那一侧,因为它会在人忘记的时候替人做决定。**
+
+同一个道理已经在这个包里出现过四次,只是当时没起名字:
+
+| 位置 | 默认宽松会造成什么 |
+|---|---|
+| 金额三态 | `NULL` 折成 `0` ⇒ "没写过"和"恰好是 0"混成一个数 |
+| `Diff.Skipped` / `Assertion.Skipped` | 不打 `—` ⇒ `0 == 0` 冒充"比过且相等" |
+| `FailCount` 对 `Skipped` | 计入 FAIL ⇒ 结构性不适用被当成待修的红 |
+| `Assertion.Kind` | 零值算业务 ⇒ 忘标即放宽 |
+
+反过来,三处**故意选严**的:`toleranceCents = 0`(容差永不放宽)、
+哨兵的 `nonzeroSamples > 0`(非空自检)、`未分类断言数 == 0`。

+ 32 - 32
internal/academic/dualrun/stage2/c1_compare.go

@@ -42,14 +42,14 @@ func c1Assertions(
 		// 同上:校区余额"恰好是 0"必须排除 NULL —— NULL 的值也是 0,直接比 int64 会空过
 		okL := isZero(lg.After.Campus.Balance, lg.After.Campus.Nulls.Balance) &&
 			isZero(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance)
-		out = append(out, Assertion{
+		out = append(out, Assertion{Kind: AssertionBusiness, 
 			Name: "清零:**校区**余额恰好归 0(「清零」清的是校区这一行)",
 			Pass: okL,
 			Detail: fmt.Sprintf("legacy 校区余额后=%s | target 校区余额后=%s(都应为 0.00,且不能是 NULL)",
 				moneyStr(lg.After.Campus.Balance, lg.After.Campus.Nulls.Balance),
 				moneyStr(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance)),
 		})
-		out = append(out, Assertion{
+		out = append(out, Assertion{Kind: AssertionBusiness, 
 			Name: "清零:全局余额只减 campusBalance(**不是**归 0,除非原本只有一个校区有钱)",
 			Pass: dGlobal.Balance == -lg.Before.Campus.Balance && tGlobal.Balance == -tg.Before.Campus.Balance,
 			Detail: fmt.Sprintf("legacy 全局 %s→%s(Δ%s,campusBalance=%s)| target 全局 %s→%s(Δ%s,campusBalance=%s)",
@@ -62,7 +62,7 @@ func c1Assertions(
 		okT := dGlobal.Special == lg.Before.Campus.Balance && dCampus.Special == lg.Before.Campus.Balance &&
 			tGlobal.Special == tg.Before.Campus.Balance && tCampus.Special == tg.Before.Campus.Balance &&
 			dGlobal.Spend == lg.Before.Campus.Balance && tGlobal.Spend == tg.Before.Campus.Balance
-		out = append(out, Assertion{
+		out = append(out, Assertion{Kind: AssertionBusiness, 
 			Name: "清零:special_expenses 与 spend **同额增加** campusBalance(三列一起动,只动一列会红)",
 			Pass: okT,
 			Detail: fmt.Sprintf("legacy 全局 Δspec=%s Δspend=%s | target 全局 Δspec=%s Δspend=%s(期望各 +%s / +%s)",
@@ -71,7 +71,7 @@ func c1Assertions(
 				yuan(lg.Before.Campus.Balance), yuan(tg.Before.Campus.Balance)),
 		})
 		// 无请求号 → log_id / request_id 都应为空("不享幂等"是 SP 签名决定的,不是漏写)
-		out = append(out, Assertion{
+		out = append(out, Assertion{Kind: AssertionBusiness, 
 			Name: "清零:流水 log_id 与 request_id **都为空**(SP10 没有请求号入参,天然不幂等)",
 			Pass: allLogsWithoutID(lg.After.Logs) && allLogsWithoutID(tg.After.Logs),
 			Detail: fmt.Sprintf("legacy %d 条 %s | target %d 条 %s",
@@ -82,14 +82,14 @@ func c1Assertions(
 
 	// ④ 取消充值活动:流水 type=209、money=−m,且报名状态归 **0**(不是 4)
 	if c.Op == OpCancelRechargeActivity {
-		out = append(out, Assertion{
+		out = append(out, Assertion{Kind: AssertionBusiness, 
 			Name: "取消充值活动:流水 type 必须是 **209**、money 必须是 **−金额**(照 dump,不改符号)",
 			Pass: logsAllType(lg.After.Logs, 209) && logsAllType(tg.After.Logs, 209) &&
 				logsAllMoney(lg.After.Logs, -c.AmountCents) && logsAllMoney(tg.After.Logs, -c.AmountCents),
 			Detail: fmt.Sprintf("legacy %s | target %s(期望 type=209 money=%s ×%d)",
 				fmtLogs(lg.After.Logs), fmtLogs(tg.After.Logs), yuan(-c.AmountCents), runsLegacy),
 		})
-		out = append(out, Assertion{
+		out = append(out, Assertion{Kind: AssertionBusiness, 
 			Name: "取消充值活动:报名状态归 **0(取消报名)**,不是 4(过期)—— SP8 与 SP10 的差别",
 			Pass: signedAll(c, lg, 0) && signedAll(c, tg, 0),
 			Detail: fmt.Sprintf("legacy %s | target %s(活动 %d 应为 0)",
@@ -106,7 +106,7 @@ func c1Assertions(
 func campusMetaAssertion(c Case, lg, tg *SideResult) Assertion {
 	lm, tm := lg.CampusMeta, tg.CampusMeta
 	if lm == nil || tm == nil {
-		return Assertion{
+		return Assertion{Kind: AssertionBusiness, 
 			Name:   "两侧看到的校区元数据一致(机构 + 是否自营)",
 			Pass:   false,
 			Detail: "有一侧没读到校区元数据(读失败或未触发读取)",
@@ -115,7 +115,7 @@ func campusMetaAssertion(c Case, lg, tg *SideResult) Assertion {
 	// CampusMissing:两侧都应该**查不到**这个校区(不是"查到了但值是 0")
 	if c.CampusMissing {
 		ok := lm.Err != "" && tm.Err != "" && lm.InstitutionID == 0 && tm.InstitutionID == 0
-		return Assertion{
+		return Assertion{Kind: AssertionBusiness, 
 			Name: "两侧都**查不到**该校区(故意用不存在的校区 id)",
 			Pass: ok,
 			Detail: fmt.Sprintf("legacy 视图:机构=%d%s | target t_campus:机构=%d%s(都应为 0 + 查不到)",
@@ -132,7 +132,7 @@ func campusMetaAssertion(c Case, lg, tg *SideResult) Assertion {
 		lm.InstitutionID, lm.SelfSupport, errSuffix(lm.Err),
 		tm.InstitutionID, tm.SelfSupport, errSuffix(tm.Err),
 		c.InstitutionID, wantSelf)
-	return Assertion{
+	return Assertion{Kind: AssertionBusiness, 
 		Name:   "两侧看到的校区元数据一致(机构 + 是否自营)—— 不同就会走不同失效分支",
 		Pass:   ok,
 		Detail: detail,
@@ -158,7 +158,7 @@ func campusAccountAssertion(c Case, lg, tg *SideResult) Assertion {
 			reason = "(NoCampusAccount:校区存在但该用户没有账户行)"
 		}
 	}
-	return Assertion{
+	return Assertion{Kind: AssertionBusiness, 
 		Name: "调用前:校区账户行" + word + reason,
 		Pass: ok,
 		Detail: fmt.Sprintf("legacy 存在=%v | target 存在=%v(期望都为 %v)",
@@ -174,7 +174,7 @@ func campusAccountAssertion(c Case, lg, tg *SideResult) Assertion {
 // 单独把"行被建出来了"钉成一条,红的时候一眼知道是建户分支没走到。
 func campusAccountCreatedAssertion(c Case, lg, tg *SideResult) Assertion {
 	ok := lg.After.HasCampusAccount && tg.After.HasCampusAccount
-	return Assertion{
+	return Assertion{Kind: AssertionBusiness, 
 		Name: "调用后:校区账户行被建出来(dump 的 `IF logCount=0 THEN INSERT` 分支)",
 		Pass: ok,
 		Detail: fmt.Sprintf("legacy 存在=%v 余额=%s | target 存在=%v 余额=%s(都应为 true)",
@@ -204,7 +204,7 @@ func signedAssertion(c Case, lg, tg *SideResult) Assertion {
 		fmtSigned(want),
 		fmtSigned(lg.After.Signed), fmtChanges(lg.Before.Signed, lg.After.Signed),
 		fmtSigned(tg.After.Signed), fmtChanges(tg.Before.Signed, tg.After.Signed))
-	return Assertion{Name: name, Pass: ok, Detail: detail}
+	return Assertion{Kind: AssertionBusiness, Name: name, Pass: ok, Detail: detail}
 }
 
 // opExpiresActivities 标记"该 Op 会把可失效报名置 4"(而不是置 0)。
@@ -222,14 +222,14 @@ func opExpiresActivities(op Op) bool {
 // spReturnAssertion 断言 SP 自己 SELECT 出来的结果集(这是 SP 契约的一部分)。
 func spReturnAssertion(c Case, lg *SideResult) Assertion {
 	if len(lg.Calls) == 0 || lg.Calls[0].Returned == nil {
-		return Assertion{
+		return Assertion{Kind: AssertionBusiness, 
 			Name: "SP8 返回值:success=1 / msg=ok / schoolid 由校区反查得到",
 			Pass: false, Detail: "没有读到 SP 的结果集(SP8 每条路径都应以 SELECT 结尾)",
 		}
 	}
 	r := lg.Calls[0].Returned
 	ok := r["success"] == "1" && r["msg"] == "ok" && r["schoolid"] == fmt.Sprint(c.InstitutionID)
-	return Assertion{
+	return Assertion{Kind: AssertionBusiness, 
 		Name: "SP8 返回值:success=1 / msg=ok / schoolid 由校区反查得到",
 		Pass: ok,
 		Detail: fmt.Sprintf("实测 schoolid=%q success=%q msg=%q(期望 %q / \"1\" / \"ok\")",
@@ -281,23 +281,23 @@ func requestIDOverflowCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	)
 
 	asserts = append(asserts,
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name:   "前提:入参请求号 **确实 33 字符**(> legacy 的 32,否则本条退化成普通 case)",
 			Pass:   len(c.RequestID) == 33,
 			Detail: fmt.Sprintf("len(requestId)=%d(期望 33)", len(c.RequestID)),
 		},
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name:   "legacy 报的是 **1406(Data too long)且点名字段 logid** —— 证明是列宽不是逻辑",
 			Pass:   !lgOK && strings.Contains(lgErr, "1406") && strings.Contains(strings.ToLower(lgErr), "logid"),
 			Detail: fmt.Sprintf("legacy success=%v err=%q", lgOK, lgErr),
 		},
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name: "legacy **钱已扣、流水 0 条**(同一次 CALL 里前置 UPDATE 不回滚)—— 缺陷在 legacy 一侧",
 			Pass: dGlobal.Balance == want && dCampus.Balance == want && len(lg.After.Logs) == 0,
 			Detail: fmt.Sprintf("Δ全局=%s Δ校区=%s 流水=%d 条(期望各 %s、0 条)",
 				yuan(dGlobal.Balance), yuan(dCampus.Balance), len(lg.After.Logs), yuan(want)),
 		},
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name: "target 成功且 **33 字符请求号完整落库**(没被静默截断 / 没报错)",
 			Pass: tgOK && len(tg.After.Logs) == 1 &&
 				tg.After.Logs[0].RequestID == c.RequestID && tg.After.Logs[0].LogID == c.RequestID &&
@@ -306,7 +306,7 @@ func requestIDOverflowCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 				tgOK, len(tg.After.Logs), len(reqIDOf(tg.After.Logs)), len(logIDOf(tg.After.Logs)),
 				true, len(c.RequestID), len(c.RequestID)),
 		},
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name: "两侧 Δbalance 相同(钱的结果一致,差异只在**有没有凭证**)",
 			Pass: dGlobal.Balance == tGlobal.Balance && dCampus.Balance == tCampus.Balance,
 			Detail: fmt.Sprintf("全局 legacy=%s target=%s | 校区 legacy=%s target=%s",
@@ -388,37 +388,37 @@ func campusRowNullCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	)
 
 	asserts = append(asserts,
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name: "前提:两侧调用前**都**没有该校区账户行(否则本条测不到建行分支)",
 			Pass: !lg.Before.HasCampusAccount && !tg.Before.HasCampusAccount,
 			Detail: fmt.Sprintf("legacy 存在=%v | target 存在=%v(都应为 false)",
 				lg.Before.HasCampusAccount, tg.Before.HasCampusAccount),
 		},
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name: "legacy:校区行**被建出来了**,且五列金额**全是 NULL**(不是 0,也不是 −金额)",
 			Pass: lg.After.HasCampusAccount && lgAllNull,
 			Detail: fmt.Sprintf("legacy 行存在=%v NULL 列=%s(期望 true / 五列全 NULL)",
 				lg.After.HasCampusAccount, lg.After.Campus.Nulls.String()),
 		},
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name: "legacy:**Δ校区也要如实表示 NULL**——不能因为读成 0 就显示成 「Δ=0.00」(那与「扣了 0 元」无法区分)",
 			Pass: dCampus.Nulls.Any(),
 			Detail: fmt.Sprintf("Δ校区 balance=%s(NULL 列=%s)",
 				moneyStr(dCampus.Balance, dCampus.Nulls.Balance), dCampus.Nulls.String()),
 		},
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name: "legacy:**全局**被扣了 −金额(`t_user_expense` 的列有 DEFAULT 0.000)—— 同一笔业务两表矛盾",
 			Pass: dGlobal.Balance == want && dGlobal.Income == want,
 			Detail: fmt.Sprintf("Δ全局 balance=%s total_income=%s(期望各 %s)",
 				yuan(dGlobal.Balance), yuan(dGlobal.Income), yuan(want)),
 		},
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name: "legacy:流水照写 1 条 type=209 / money=−金额(**钱没扣,凭证有了**)",
 			Pass: len(lg.After.Logs) == 1 && lg.After.Logs[0].Type == 209 &&
 				lg.After.Logs[0].Money == want,
 			Detail: fmt.Sprintf("legacy %s(期望 type=209 money=%s ×1)", fmtLogs(lg.After.Logs), yuan(want)),
 		},
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name: "target:校区行被建出来、**五列都不为 NULL**、且真扣成 −金额(列是 NOT NULL DEFAULT 0.00)",
 			Pass: tg.After.HasCampusAccount && tgNoNull &&
 				tg.After.Campus.Balance == want && tCampus.Balance == want && tCampus.Income == want,
@@ -429,7 +429,7 @@ func campusRowNullCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 				moneyStr(tCampus.Income, tCampus.Nulls.Income),
 				yuan(want), yuan(want), yuan(want)),
 		},
-		Assertion{
+		Assertion{Kind: AssertionBusiness, 
 			Name: "判定:**LEGACY-DEFECT** —— legacy 两表自相矛盾(校区五列 NULL / 全局 −金额),target 侧正确,不跟",
 			Pass: lgAllNull && tgNoNull &&
 				dGlobal == tGlobal &&
@@ -484,7 +484,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 
 	// ① 前提
 	baseOK := campusBefore == tg.Before.Campus.Balance && totalBefore == tg.Before.Global.Balance
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name: "前提:两侧 **2 位**基线逐分相等(校区与全局都要相等)",
 		Pass: baseOK,
 		Detail: fmt.Sprintf("legacy 校区=%s 全局=%s | target 校区=%s 全局=%s",
@@ -493,7 +493,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	})
 
 	// ② legacy 的 3 位判据必须**成立**(否则构型选错了,这条 case 什么都证明不了)
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name: "legacy:3 位判据 `校区 > 全局` **成立**(构型前提;不成立说明选错人)",
 		Pass: c3ok && t3ok && c3 > t3,
 		Detail: fmt.Sprintf("campus=%s total=%s → %s > %s 成立=%v",
@@ -501,7 +501,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	})
 
 	// ③ target 的 2 位判据必须**不成立**(这就是"放行"的原因)
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name: "target:2 位判据 `校区 > 全局` **不成立** ⇒ 放行(分歧的成因:第 3 位被收敛抹平)",
 		Pass: tg.Before.Campus.Balance <= tg.Before.Global.Balance,
 		Detail: fmt.Sprintf("campus=%s total=%s → %s > %s 不成立",
@@ -512,7 +512,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	// ④ legacy 行为:LEAVE + 零写入 + 0 流水
 	lgLEAVE := len(lg.Calls) > 0 && lg.Calls[0].Success
 	lgNoWrite := lg.Before.Global == lg.After.Global && lg.Before.Campus == lg.After.Campus
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name: "legacy:`LEAVE` 静默返回、**零写入**、0 条流水",
 		Pass: lgLEAVE && lgNoWrite && len(lg.After.Logs) == 0,
 		Detail: fmt.Sprintf("抛错=%v 全局变=%v 校区变=%v 流水=%d(都应为 false/false/0)",
@@ -526,7 +526,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	logOK := len(tg.After.Logs) == 1 && tg.After.Logs[0].Type == 208 &&
 		tg.After.Logs[0].Money == -amount && tg.After.Logs[0].LogID == "" && tg.After.Logs[0].RequestID == ""
 	tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name: "target:清零成功 —— 校区 0、全局 0、special 与 spend 各 +金额、1 条 `type=208 money=−金额`(无 logid/request_id)",
 		Pass: tgOK && isZero(tg.After.Campus.Balance, tg.After.Campus.Nulls.Balance) &&
 			isZero(tg.After.Global.Balance, tg.After.Global.Nulls.Balance) &&
@@ -541,7 +541,7 @@ func predicateCollapseCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 	})
 
 	// ⑥ 结论行:把归类写进报告,免得下一个人当它是缺陷
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name: "判定:**PRECISION-CONVERGENCE / PREDICATE-COLLAPSE** —— 迁移的 2 位收敛抹平了判据输入,两侧分支不同,**不是缺陷**",
 		Pass: true,
 		Detail: fmt.Sprintf("legacy 1565.688>1565.687 成立 ⇒ LEAVE(未写入)|target %s>%s 不成立 ⇒ 清零 %s。"+

+ 19 - 19
internal/academic/dualrun/stage2/c2_refund.go

@@ -472,7 +472,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	}
 
 	// ---- 断言 ①:各侧前置校验的结论符合**各自**的期望 ----
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name: "两侧前置校验:各侧是否符合**自己**的期望(legacy `ISNULL(_balance) OR _balance<=0` / target `balance<=0`)",
 		Pass: lgRejectedActual == lgExp.Rejected && tgRejectedActual == tgExp.Rejected,
 		Detail: fmt.Sprintf("legacy 实测拒绝=%v(期望 %v,整数化余额=%s,success=%q msg=%q)| target 实测拒绝=%v(期望 %v,真实余额=%s,码=%d err=%q)",
@@ -480,7 +480,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			tgRejectedActual, tgExp.Rejected, yuan(tgB), codeOf(tg.Calls), errOf(tg.Calls)),
 	})
 	if c.ExpectTargetCode != 0 {
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name:   fmt.Sprintf("target 业务码 == %d(码比文案强:同一文案可能被多处复用)", c.ExpectTargetCode),
 			Pass:   targetRejected(tg) && codeOf(tg.Calls) == c.ExpectTargetCode,
 			Detail: fmt.Sprintf("实测码=%d err=%q", codeOf(tg.Calls), errOf(tg.Calls)),
@@ -493,21 +493,21 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	tgNoWrite := tg.Before.Campus == tg.After.Campus && tg.Before.Global == tg.After.Global &&
 		len(tg.After.Logs) == 0
 	if lgExp.Rejected {
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name: "legacy 拒绝契约:`success=0` + `msg='余额不能≤0'` + **零写入 / 0 条流水**(不抛错)",
 			Pass: lgRejectedActual && lgRet["msg"] == "余额不能≤0" && lgNoWrite,
 			Detail: fmt.Sprintf("success=%q msg=%q 零写入=%v 流水=%d 条",
 				lgRet["success"], lgRet["msg"], lgNoWrite, len(lg.After.Logs)),
 		})
 	} else {
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name:   "【不适用】legacy 本条未被拒绝 ⇒ 拒绝契约无从验证",
 			Pass:   true, Skipped: true,
 			Detail: "整数化余额=" + yuan(round0Cents(lgB)) + " > 0",
 		})
 	}
 	if tgExp.Rejected {
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name:   "target 拒绝契约:显式报错(3102)+ **零写入 / 0 条流水**",
 			Pass:   tgRejectedActual && tgNoWrite,
 			Detail: fmt.Sprintf("被拒=%v 码=%d 零写入=%v 流水=%d 条", tgRejectedActual, codeOf(tg.Calls), tgNoWrite, len(tg.After.Logs)),
@@ -518,14 +518,14 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	// 这两条是本批最重要的"没删干净"的部分:它们**不比较两侧之间**,而是各自对着
 	// 一个与实现无关的恒等式。如果因为"两侧本来就不同"把断言整体删掉,这一支就只剩打印。
 	sharedSkip := lgExp.Rejected || tgExp.Rejected
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name:   "共同不变量:`207.money == −inputMoney`(两侧都必须成立)",
 		Pass:   !sharedSkip && sameLog207(lg.After.Logs, c.AmountCents) && sameLog207(tg.After.Logs, c.AmountCents),
 		Skipped: sharedSkip,
 		Detail: fmt.Sprintf("legacy 207 合计=%s | target 207 合计=%s(期望各 %s)",
 			yuan(sumLogType(lg.After.Logs, 207)), yuan(sumLogType(tg.After.Logs, 207)), yuan(-c.AmountCents)),
 	})
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name:   "共同不变量:`Δtotal_income == Δbalance`(两侧都必须成立 —— 证明结构性复刻对了,只是舍入不同)",
 		Pass:   !sharedSkip && lgIncomeOK && tgIncomeOK,
 		Skipped: sharedSkip,
@@ -548,7 +548,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	lg108OK := lgBadDebtRuns > 0 &&
 		countLogType(lg.After.Logs, 108) == lgBadDebtRuns &&
 		sumLogType(lg.After.Logs, 108) == want108Total
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name:    "legacy 专属不变量:`108.money == round0(inputMoney − round0(balance_before))`(第二次整数化只在这里可见)",
 		Pass:    lg108OK,
 		Skipped: lgBadDebtRuns == 0,
@@ -557,7 +557,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			lgBadDebtRuns, yuan(want108Total),
 			yuan(c.AmountCents), yuan(lgB), yuan(want108Each)),
 	})
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name: "legacy 专属不变量:**坏账分支写 108、ELSE 分支不写**(108 条数 == 走坏账分支的次数)",
 		Pass: countLogType(lg.After.Logs, 108) == lgBadDebtRuns,
 		Detail: fmt.Sprintf("108 实测 %d 条 | 坏账分支 %d 次(分支序列:%s)",
@@ -567,7 +567,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	// ---- 断言 ⑤:target 专属不变量 ----
 	// 用**实测值**而不是 Service 返回值:返回值只能证明"它自己这么报",实测 Δ 才能证明"库真变了"。
 	tgSplitOK := !tgExp.Rejected && -dT.Balance+dT.Special == c.AmountCents
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name:    "target 专属不变量:`refunded + badDebt == inputMoney`(实测:`−Δbalance + Δspecial == inputMoney`)",
 		Pass:    tgSplitOK,
 		Skipped: tgExp.Rejected,
@@ -575,14 +575,14 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			yuan(-dT.Balance), yuan(dT.Special), yuan(-dT.Balance+dT.Special), yuan(c.AmountCents)),
 	})
 	tgRefundedOK := !tgExp.Rejected && -dT.Balance == minI64(c.AmountCents, tgB)
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name:    "target 专属不变量:`refunded == min(inputMoney, balance_before)`(实测:`−Δbalance` 等于两者较小值)",
 		Pass:    tgRefundedOK,
 		Skipped: tgExp.Rejected,
 		Detail: fmt.Sprintf("实测 refunded=−Δbal=%s | min(%s, %s)=%s",
 			yuan(-dT.Balance), yuan(c.AmountCents), yuan(tgB), yuan(minI64(c.AmountCents, tgB))),
 	})
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name:    "target 专属不变量:`balance_after >= 0`(精确口径下退费**不应该把余额退成负数**)",
 		Pass:    tgExp.Rejected || tg.After.Campus.Balance >= 0,
 		Skipped: tgExp.Rejected,
@@ -593,7 +593,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	if c.RunTimes >= 2 {
 		tgRepeated := len(tg.Calls) >= 2 && tg.Calls[1].Repeated
 		lgRuns := len(lg.Calls)
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name: "幂等:target 第 2 次 `repeated=true` 且**只扣 1 次**;legacy SP 无幂等键 ⇒ 真的扣 N 次",
 			Pass: tgRepeated && lgRuns == maxInt(1, c.RunTimes) &&
 				-dT.Balance == tgExp.Money && -dL.Balance == lgExp.Money,
@@ -612,7 +612,7 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		// 最后把 `campus_id=0`(全机构池)单拎一条 —— 它是自营/加盟唯一的分支差别,
 		// 混在整张 map 里被别的活动一衬托就不显眼了。
 		if poolID, ok := c.ActivityPlan.poolActivityID(); ok {
-			asserts = append(asserts, Assertion{
+			asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 				Name: "自营 ⇒ **全机构池活动(campus_id=0)也必须失效**(这是 self_support 分支的唯一判据)",
 				Pass: lg.After.Signed[poolID] == 4 && tg.After.Signed[poolID] == 4,
 				Detail: fmt.Sprintf("活动 %d(campus_id=0):legacy %d→%d | target %d→%d(期望 2→4)",
@@ -626,14 +626,14 @@ func customRefundCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	same := lgExp.Rejected == tgExp.Rejected && lgExp.Money == tgExp.Money &&
 		lgExp.BadDebt == tgExp.BadDebt && len(lgExp.Logs) == len(tgExp.Logs)
 	if same {
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name: "判定:**两侧一致**(整数余额 ⇒ legacy 的两次整数化不产生任何差异)",
 			Pass: true,
 			Detail: fmt.Sprintf("余额=%s 请求=%s ⇒ 两侧都 %s",
 				yuan(lgB), yuan(c.AmountCents), refundExpectBrief(lgExp)),
 		})
 	} else {
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name: "判定:**" + C2VerdictClass + "** —— 旧 SP 的 `DECLARE ... DECIMAL DEFAULT 0` 会把金额整数化两次,target 有意不复刻",
 			Pass: true,
 			Detail: fmt.Sprintf("余额=%s 请求=%s | legacy(整数化余额 %s):%s | target(精确余额 %s):%s",
@@ -719,18 +719,18 @@ func nullBalanceRejectCompare(c Case, lg, tg *SideResult) ([]Diff, []Assertion)
 			Note:   "拒绝**理由**不同(legacy `ISNULL` 短路 / target `balance<=0`),结论一致",
 		},
 	)
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name: "legacy:余额为 NULL ⇒ `ISNULL(_balance)` 短路 ⇒ 静默拒绝(`success=0` / `msg='余额不能≤0'`)+ 零写入",
 		Pass: lgNull && lgRejected && lgRet["msg"] == "余额不能≤0" && lgNoWrite,
 		Detail: fmt.Sprintf("NULL=%v success=%q msg=%q 零变化=%v 流水=%d 条",
 			lgNull, lgRet["success"], lgRet["msg"], lgNoWrite, len(lg.After.Logs)),
 	})
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name:   "target:余额 0.00 ⇒ `balance<=0` ⇒ 显式拒绝(3102)+ 零写入",
 		Pass:   tgRejected && codeOf(tg.Calls) == 3102 && tgNoWrite,
 		Detail: fmt.Sprintf("被拒=%v 码=%d err=%q 零变化=%v 流水=%d 条", tgRejected, codeOf(tg.Calls), errOf(tg.Calls), tgNoWrite, len(tg.After.Logs)),
 	})
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name: "判定:**迁移修掉的缺陷** —— NULL 余额在新库不可表达,两侧殊途同归(都拒绝);本探针**不构成验收**",
 		Pass: true,
 		Detail: "legacy 侧的 NULL 是数据缺陷(建行只写 3 列,见 C1-11);target 的 `not null default 0.00` " +

+ 70 - 21
internal/academic/dualrun/stage2/c2_refund_test.go

@@ -328,27 +328,37 @@ func TestCustomRefundCaseUserBalancesReal(t *testing.T) {
 	}
 }
 
-// TestEnsureNotAllSkipped 锁住"Skipped 不许把整条 case 掏空"这道闸。
+// TestEnsureHasBusiness 锁住"每条 case 至少要有一条**业务**断言"这道闸。
 //
-// 背景:`FailCount` 跳过 Skipped 之后,"全部断言都是 Skipped"会变成 0 FAIL 的 PASS ——
-// **分母收缩的又一个变种**:分母没变,但分子全是空的。这条闸就是让它变成非 0 退出码。
-func TestEnsureNotAllSkipped(t *testing.T) {
+// 两段历史,缺一不可:
+//
+//	① 原来只数"非 Skipped 断言" ⇒ 活动链那 8 支不碰资金表,
+//	   `Δbalance/Δincome/Δspecial` 全 Skipped,而 `回滚后零残留` **永远为真** ⇒ 闸恒过。
+//	② 于是给断言加 Kind,只认业务断言;`Kind == AssertionUnclassified` **不算业务**(从严),
+//	   所以"忘写 Kind"会让 case 立刻 FATAL,而不是静默通过。
+func TestEnsureHasBusiness(t *testing.T) {
+	biz := func(name string, pass bool) Assertion {
+		return Assertion{Kind: AssertionBusiness, Name: name, Pass: pass}
+	}
+	infra := func(name string, pass bool) Assertion {
+		return Assertion{Kind: AssertionInfrastructure, Name: name, Pass: pass}
+	}
 	cases := []struct {
 		name    string
 		r       CaseResult
 		wantErr bool
 	}{
 		{
-			name: "有一条真断言 ⇒ 放行",
+			name: "有业务断言 + 一条 Skipped ⇒ 放行",
 			r: CaseResult{Case: Case{Name: "x"}, Assertions: []Assertion{
-				{Name: "真", Pass: true}, {Name: "空", Pass: true, Skipped: true},
+				biz("真", true), {Kind: AssertionBusiness, Name: "空", Pass: true, Skipped: true},
 			}},
 		},
 		{
-			name: "断言全 Skipped ⇒ 报错(哪怕 Pass 全是 true)",
+			name: "业务断言全 Skipped ⇒ 报错(哪怕 Pass 全是 true)",
 			r: CaseResult{Case: Case{Name: "x"}, Assertions: []Assertion{
-				{Name: "a", Pass: true, Skipped: true},
-				{Name: "b", Pass: true, Skipped: true},
+				{Kind: AssertionBusiness, Name: "a", Pass: true, Skipped: true},
+				{Kind: AssertionBusiness, Name: "b", Pass: true, Skipped: true},
 			}},
 			wantErr: true,
 		},
@@ -358,25 +368,60 @@ func TestEnsureNotAllSkipped(t *testing.T) {
 			wantErr: true,
 		},
 		{
-			name: "断言全 Skipped,但 Diff 里有非 Skipped 的比对 ⇒ **仍然报错**",
+			name: "只有基建断言(残留 / 基线)⇒ **报错** —— 这正是加 Kind 的理由",
+			r: CaseResult{Case: Case{Name: "x"}, Assertions: []Assertion{
+				infra("回滚后零残留", true),
+				infra("两侧基线逐分相等", true),
+			}},
+			wantErr: true,
+		},
+		{
+			name: "只有未分类断言(Kind==0)⇒ **报错** —— 默认值从严,忘写 Kind 不会静默变成业务",
+			r: CaseResult{Case: Case{Name: "x"}, Assertions: []Assertion{
+				{Name: "忘标了 Kind", Pass: true},
+			}},
+			wantErr: true,
+		},
+		{
+			name: "业务断言全 Skipped,但 Diff 里有非 Skipped 比对 ⇒ **仍然报错**",
 			r: CaseResult{Case: Case{Name: "x"},
 				Diffs:      []Diff{{Item: "基线", Equal: true}},
-				Assertions: []Assertion{{Name: "a", Pass: true, Skipped: true}}},
+				Assertions: []Assertion{{Kind: AssertionBusiness, Name: "a", Pass: true, Skipped: true}}},
 			wantErr: true,
 		},
 	}
 	for _, c := range cases {
-		err := c.r.EnsureNotAllSkipped()
+		err := c.r.EnsureHasBusiness()
 		if c.wantErr && err == nil {
 			t.Errorf("%s:应当报错,实得 nil", c.name)
 		}
 		if !c.wantErr && err != nil {
 			t.Errorf("%s:不应报错,实得 %v", c.name, err)
 		}
-		if !c.wantErr && c.r.SkippedCount() == 0 && len(c.r.Assertions) > 0 {
-			// 这条只是提醒:SkippedCount 要能把 Skipped 数出来(报数第七个数靠它)
-			t.Logf("%s:SkippedCount=%d(本例无 Skipped,正常)", c.name, c.r.SkippedCount())
-		}
+	}
+}
+
+// TestKindZeroIsStrict 锁住"默认值选严的一侧"这条纪律本身。
+//
+// 若有人图省事把 `AssertionBusiness` 改成 0(iota 的第一个),下面断言立刻红:
+// 那种改法会让"忘写 Kind"**静默变成业务断言**,闸门随"忘写"而放宽。
+func TestKindZeroIsStrict(t *testing.T) {
+	if AssertionBusiness == AssertionUnclassified {
+		t.Fatal("AssertionBusiness 不能等于零值 —— 零值是 AssertionUnclassified,默认值必须从严")
+	}
+	var zero Assertion // 零值断言:Kind == AssertionUnclassified
+	if zero.Kind != AssertionUnclassified {
+		t.Fatalf("零值断言的 Kind 应为 AssertionUnclassified,实得 %v", zero.Kind)
+	}
+	r := &CaseResult{Case: Case{Name: "x"}, Assertions: []Assertion{zero}}
+	if r.LiveAssertionCount() != 0 {
+		t.Errorf("未分类断言不能计入业务断言,实得 %d", r.LiveAssertionCount())
+	}
+	if r.UnclassifiedCount() != 1 {
+		t.Errorf("UnclassifiedCount 应为 1,实得 %d", r.UnclassifiedCount())
+	}
+	if err := r.EnsureHasBusiness(); err == nil {
+		t.Error("只有未分类断言时应当报错")
 	}
 }
 
@@ -389,8 +434,9 @@ func TestSkipCountAndFailCountDisjoint(t *testing.T) {
 			{Item: "结构性不适用", Equal: true, Skipped: true},
 		},
 		Assertions: []Assertion{
-			{Name: "真通过", Pass: true},
-			{Name: "不适用(Pass 为 false 也不该算失败)", Pass: false, Skipped: true},
+			{Kind: AssertionBusiness, Name: "真通过", Pass: true},
+			{Kind: AssertionBusiness, Name: "不适用(Pass 为 false 也不该算失败)", Pass: false, Skipped: true},
+			{Kind: AssertionInfrastructure, Name: "基建", Pass: true},
 		},
 	}
 	if got := r.FailCount(); got != 0 {
@@ -400,10 +446,13 @@ func TestSkipCountAndFailCountDisjoint(t *testing.T) {
 		t.Errorf("SkippedCount 应为 2,实得 %d", got)
 	}
 	if got := r.LiveAssertionCount(); got != 1 {
-		t.Errorf("LiveAssertionCount 应为 1,实得 %d", got)
+		t.Errorf("LiveAssertionCount(只数业务)应为 1,实得 %d", got)
+	}
+	if got := r.InfraCount(); got != 1 {
+		t.Errorf("InfraCount 应为 1,实得 %d", got)
 	}
-	if err := r.EnsureNotAllSkipped(); err != nil {
-		t.Errorf("有一条真断言,不该报错:%v", err)
+	if err := r.EnsureHasBusiness(); err != nil {
+		t.Errorf("有一条业务断言,不该报错:%v", err)
 	}
 }
 

+ 82 - 18
internal/academic/dualrun/stage2/case.go

@@ -435,11 +435,54 @@ type CaseResult struct {
 	Residue    ResidueReport
 }
 
+// AssertionKind 区分「业务断言」与「基建断言」。
+//
+// 为什么必须分:活动链那 8 支 SP **不碰资金表**,它们的 `Δbalance / Δincome / Δspecial`
+// 全是结构性 Skipped;而「回滚后零残留」这类基建断言**永远为真**。
+// 于是"每条 case 至少一条非 Skipped 断言"这道闸会**形同虚设** —— 残留那条在,闸就满足了,
+// 而那条 case 什么都没测。这是"空过断言"换了个域复发。
+//
+// ⚠ **默认值必须选严的一侧**:`AssertionUnclassified = 0` 是零值,
+// 但它在统计里**不算业务断言**。所以新建 `Assertion{}` 忘了标 Kind 时,
+// 那条 case 会因"缺少业务断言"直接 FATAL,而不是静默通过。
+// 反过来若把 `AssertionBusiness` 设成 0,忘了标注就会**静默变成业务断言** —— 闸门随"忘写"而放宽,
+// 正是我们要防的那类退化。**默认值永远要选严的那一侧**,因为它会在人忘记的时候替人做决定。
+type AssertionKind int
+
+const (
+	// AssertionUnclassified 未标注。**不许出现在任何实际断言上**(报告里会打 ⚠,且不计入业务断言)。
+	AssertionUnclassified AssertionKind = 0
+	// AssertionBusiness 业务断言:这条操作**应该**发生什么(金额、流水、状态演化、返回值、拒绝契约…)。
+	AssertionBusiness AssertionKind = 1
+	// AssertionInfrastructure 基建断言:case 是否**有效**、环境是否**干净**。
+	//
+	// 三类归到这里(且只有这三类):
+	//   ① 回滚后零残留(环境)
+	//   ② 两侧基线逐分相等 / 活动种子基线(case 有效性前提)
+	//   ③ case 有效性前提(旧库里没有别的可失效报名 / 没有别的负 log)
+	// 它们为真**不代表**被测操作被验证过 —— 它们只是"这次实验的器材是准的"。
+	AssertionInfrastructure AssertionKind = 2
+)
+
+// String 返回可读名字(报告用)。
+func (k AssertionKind) String() string {
+	switch k {
+	case AssertionBusiness:
+		return "业务"
+	case AssertionInfrastructure:
+		return "基建"
+	default:
+		return "未分类"
+	}
+}
+
 // Assertion 是一条语义断言。
 type Assertion struct {
 	Name   string
 	Pass   bool
 	Detail string
+	// Kind 决定它是否计入"这条 case 至少有业务断言"的闸门。见 AssertionKind 的说明。
+	Kind AssertionKind
 	// Skipped 表示"这条断言在当前 case 上**结构性不适用**",报告里打 "—" 而不是 "✔"。
 	//
 	// 与 Diff.Skipped 同源,理由也一样:断言「本 case 不涉及」时必须**显形**。
@@ -511,43 +554,64 @@ func (r *CaseResult) SkippedCount() int {
 	return n
 }
 
-// LiveAssertionCount 返回**非 Skipped 的断言条数**。
+// LiveAssertionCount 返回**非 Skipped 的业务断言**条数(闸门用)。
 //
-// 只看断言、不看 Diff:Diff 全 ✔ 而断言全 Skipped 的 case 看起来"比过了",
-// 实际一条语义断言都没做(Diff 的 Equal 可能只是 `lgB == tgB` 这种基线前提)。
+// 只看业务断言:基建断言(残留 / 基线 / 有效性前提)永远为真,把它们算进来等于闸门失效。
 func (r *CaseResult) LiveAssertionCount() int {
 	n := 0
 	for _, a := range r.Assertions {
-		if !a.Skipped {
+		if !a.Skipped && a.Kind == AssertionBusiness {
 			n++
 		}
 	}
 	return n
 }
 
-// EnsureNotAllSkipped 是"Skipped 不许把整条 case 掏空"的闸。
+// UnclassifiedCount 返回 Kind 未标注(== 0)的断言条数。
 //
-// 触发条件:断言**一条都没有**,或**全部**都是 Skipped。
+// **这个数必须恒为 0**。它不是"警告",是代码缺陷:说明有人新写了断言却没标 Kind。
+// 之所以要单独统计而不只靠闸门:闸门只会让"某条 case 缺少业务断言"时 FATAL,
+// 而"某条 case 既有业务断言、又有一条忘标 Kind 的断言"会静默通过 —— 那条忘标的断言
+// 永远不会被算进任何一侧,等于**写了一条谁都不看的断言**。
+func (r *CaseResult) UnclassifiedCount() int {
+	n := 0
+	for _, a := range r.Assertions {
+		if a.Kind == AssertionUnclassified {
+			n++
+		}
+	}
+	return n
+}
+
+// EnsureHasBusiness 是「每条 case 至少一条**业务**断言」的闸。
+//
+// 触发条件:没有任何非 Skipped 的**业务**断言(`Kind == AssertionUnclassified` 不算业务)。
 //
 // 报错而不是记 FAIL,理由:这不是"行为不对",是"这条 case 什么都没测"——
 // 与 `-case` 匹配不到、分母收缩同级,属于**测试基础设施失效**,
 // 必须以非 0 退出码冒出来(记 FAIL 会被当成"待修的红"排进队列,而它根本不该被容忍)。
 //
-// 一个例外:一条 case 若有 0 条断言却有非 Skipped 的 Diff,仍然放行 ——
-// 那种 case 的判定完全由 Diff 的 Equal 承担(例如 C1-5b 的 3 位 detail 行)。
-// 但**断言全 Skipped 时一律报错**,因为 `FailCount` 此时只剩 Diff 在兜底,
-// 而 Diff 里那些 `Equal: true` 的基线行会把结论染绿。
-func (r *CaseResult) EnsureNotAllSkipped() error {
-	live := r.LiveAssertionCount()
-	if live > 0 {
+// 为什么不能只数"非 Skipped 断言":基建断言(`回滚后零残留`)**永远为真**,
+// 只要它在,闸门就满足了。活动域 15 条 case 会因此集体"合法空过"。
+func (r *CaseResult) EnsureHasBusiness() error {
+	if live := r.LiveAssertionCount(); live > 0 {
 		return nil
 	}
-	allSkipped := len(r.Assertions) > 0
 	return fmt.Errorf(
-		"case %q 没有一条**非 Skipped** 的断言(断言共 %d 条,全部 Skipped=%v)—— "+
-			"Skipped 不计入 FailCount,这条 case 会以 0 FAIL 通过,但它什么都没测;"+
-			"请检查构型是否把所有断言都变成了结构性不适用",
-		r.Case.Name, len(r.Assertions), allSkipped)
+		"case %q 没有一条**非 Skipped 的业务断言**(断言共 %d 条:业务 0、基建 %d、未分类 %d、Skipped %d)—— "+
+			"基建断言永远为真,满足不了这条闸;请检查构型是否把所有业务断言都变成了结构性不适用",
+		r.Case.Name, len(r.Assertions), r.InfraCount(), r.UnclassifiedCount(), r.SkippedCount())
+}
+
+// InfraCount 返回基建断言条数(报告与闸门文案用)。
+func (r *CaseResult) InfraCount() int {
+	n := 0
+	for _, a := range r.Assertions {
+		if a.Kind == AssertionInfrastructure {
+			n++
+		}
+	}
+	return n
 }
 
 // toleranceCents 是 Δ 比对的容差。

+ 71 - 26
internal/academic/dualrun/stage2/run.go

@@ -577,7 +577,7 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 		if err != nil {
 			return nil, err
 		}
-		res.Assertions = append(res.Assertions, Assertion{
+		res.Assertions = append(res.Assertions, Assertion{Kind: AssertionInfrastructure,
 			Name: "case 有效性前提:旧库里该用户**没有别的**可失效报名(否则两侧失效范围天然不同)",
 			Pass: n == 0,
 			Detail: fmt.Sprintf("本 case 之外可失效报名行=%d(应为 0;机构=%d 校区=%d 自营=%v)",
@@ -586,23 +586,30 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 	}
 
 	// ---- 残留检查(两边都回滚之后)----
+	//
+	// 这条是 **基建断言**(`AssertionInfrastructure`):它证明"实验器材没问题",
+	// **不证明**被测操作发生过什么。所以它不能充抵"这条 case 至少有业务断言"那道闸 ——
+	// 否则活动域那些不碰资金表的 SP 会靠它集体合法空过。
 	res.Residue = checkResidue(ctx, cfg, c, uid, natural, naturalLogs)
 	if !res.Residue.Pass {
-		res.Assertions = append(res.Assertions, Assertion{
+		res.Assertions = append(res.Assertions, Assertion{Kind: AssertionInfrastructure,
 			Name: "回滚后零残留(两侧都回到调用前状态)", Pass: false, Detail: res.Residue.Detail,
 		})
 	} else {
-		res.Assertions = append(res.Assertions, Assertion{
+		res.Assertions = append(res.Assertions, Assertion{Kind: AssertionInfrastructure,
 			Name: "回滚后零残留(两侧都回到调用前状态)", Pass: true, Detail: res.Residue.Detail,
 		})
 	}
 
-	// ---- 闸:Skipped 不许把整条 case 掏空(见 CaseResult.EnsureNotAllSkipped)----
+	// ---- 闸:每条 case 至少要有一条**业务**断言(见 CaseResult.EnsureHasBusiness)----
+	//
+	// 放在**最后**:前面所有断言(含残留检查)都已生成,此时才能准确分类。
 	//
-	// 放在**最后**:前面所有断言(含残留检查)都已生成,此时才能准确判断"还有没有真断言"。
-	// 放在 compare 之后但在残留检查之前是错的位置 —— 残留那条本身也是一条真断言,
-	// 提前判断会把"只有残留断言"的 case 误判成空。
-	if err := res.EnsureNotAllSkipped(); err != nil {
+	// 为什么不是"至少要有一条非 Skipped 断言":基建断言(回滚零残留 / 两侧基线 / 有效性前提)
+	// **永远为真**,只要有它们在,那种闸就恒过。活动链那 8 支 SP 不碰资金表,
+	// `Δbalance / Δincome / Δspecial` 全是结构性 Skipped —— 它们会靠残留那条"合法空过"。
+	// 这是"空过断言"换了个域复发,所以闸门必须区分业务/基建。
+	if err := res.EnsureHasBusiness(); err != nil {
 		return nil, err
 	}
 	return res, nil
@@ -698,6 +705,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			return nullBalanceRejectCompare(c, lg, tg)
 		}
 		return nil, []Assertion{{
+			Kind: AssertionBusiness,
 			Name: "未知的 SpecialCase:" + c.SpecialCase, Pass: false,
 			Detail: "compare 里没有这个分支,case 会变成空测试",
 		}}
@@ -724,13 +732,13 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		add("target 是否显式拒绝", "N/A", fmt.Sprint(tgRejected), tgRejected,
 			"Go 应显式报错(业务码 "+fmt.Sprint(c.ExpectTargetCode)+"),错误文本:"+tgErr)
 		asserts = append(asserts,
-			Assertion{
+			Assertion{Kind: AssertionBusiness, 
 				Name: "intentional-strict:legacy 静默不报错 + target 显式拒绝(码要对)",
 				Pass: lgOK && tgRejected && codeOK,
 				Detail: fmt.Sprintf("legacy 抛错=%v(期望 false,SP 静默 no-op);target 被拒=%v 码=%d(期望 被拒=true 码=%d)err=%q",
 					!lgOK, tgRejected, tgCode, c.ExpectTargetCode, tgErr),
 			},
-			Assertion{
+			Assertion{Kind: AssertionBusiness, 
 				Name: "intentional-strict:两侧都**没有写库**(全局与校区余额不变、流水 0 条、活动状态不变)",
 				Pass: noWrite && signedSame,
 				Detail: fmt.Sprintf("legacy 写库=%v(全局/校区)流水=%d 活动变=%v | target 写库=%v(全局/校区)流水=%d 活动变=%v —— 都应为 未写库/0 条/不变",
@@ -741,7 +749,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		// SP 若自带结果集,它的 success/msg 也是契约:SP8 找不到校区时返回 success=0 而不是抛错。
 		if c.Op == OpCancelRechargeActivity && len(lg.Calls) > 0 && lg.Calls[0].Returned != nil {
 			r := lg.Calls[0].Returned
-			asserts = append(asserts, Assertion{
+			asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 				Name:   "SP8 返回值:找不到校区时 **success=0 + msg='找不到学校信息'**(不抛错,靠返回值表达失败)",
 				Pass:   r["success"] == "0" && r["msg"] == "找不到学校信息",
 				Detail: fmt.Sprintf("实测 schoolid=%q success=%q msg=%q", r["schoolid"], r["success"], r["msg"]),
@@ -751,7 +759,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		// ⇒ 走 `_balance<=0` 的 LEAVE,返回 **success=0 + msg='余额不能≤0'**,调用方看到的是"成功"。
 		if c.Op == OpCustomRefund && len(lg.Calls) > 0 && lg.Calls[0].Returned != nil {
 			r := lg.Calls[0].Returned
-			asserts = append(asserts, Assertion{
+			asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 				Name:   "SP9 返回值:校区账户行不存在时 **success=0 + msg='余额不能≤0'**(不抛错,靠返回值表达失败)",
 				Pass:   r["success"] == "0" && r["msg"] == "余额不能≤0",
 				Detail: fmt.Sprintf("实测 success=%q msg=%q(SP 的 `SELECT ... INTO` 无行可赋 ⇒ 变量停在默认 0,与'余额真的是 0'不可区分)", r["success"], r["msg"]),
@@ -761,8 +769,12 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	}
 
 	// ① 基线逐分相等(case 有效性前提)
+	//
+	// **基建断言**:它保证"两个库喂进去的是同一份基线",不保证操作结果。
+	// 两侧读的甚至不是同一个库(target ← `finance_bk`,legacy SP ← `legacy_oracle`),
+	// 所以这条断言是"原值复制"能成立的前提 —— 但它充抵不了业务断言。
 	baseEqual := lg.Before.Global == tg.Before.Global && lg.Before.Campus == tg.Before.Campus
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionInfrastructure,
 		Name: "两侧基线逐分相等(case 有效性前提)",
 		Pass: baseEqual,
 		Detail: fmt.Sprintf("legacy 全局=%s 校区=%s | target 全局=%s 校区=%s",
@@ -861,7 +873,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		} else {
 			ok = ok && isZero(dCampus.Balance, dCampus.Nulls.Balance) && isZero(tCampus.Balance, tCampus.Nulls.Balance)
 		}
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name: names + ":Δbalance 必须**恰好是 0**(不是不比较,也不是 NULL)",
 			Pass: ok,
 			Detail: fmt.Sprintf("全局 %s/%s;转出校区 %s/%s;转入校区 %s/%s(都应为 0.00)",
@@ -955,7 +967,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	tgOK := len(tg.Calls) > 0 && tg.Calls[0].Success
 	add("首次调用是否成功", fmt.Sprint(lgOK), fmt.Sprint(tgOK), lgOK == tgOK, "R15 场景两侧都应成功")
 
-	asserts = append(asserts, Assertion{
+	asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 		Name: "首次调用结果与预期一致(success / R15 不报余额不足)",
 		Pass: lgOK == c.ExpectLegacySuccess && tgOK == c.ExpectTargetSuccess,
 		Detail: fmt.Sprintf("legacy success=%v(期望 %v);target success=%v(期望 %v)|target err=%q",
@@ -966,7 +978,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	if c.RunTimes >= 2 {
 		lgSecondOK := len(lg.Calls) >= 2 && lg.Calls[1].Success
 		tgRepeated := len(tg.Calls) >= 2 && tg.Calls[1].Repeated
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name: "幂等:target 第二次 repeated=true;legacy SP 无幂等(会重复入账)",
 			Pass: tgRepeated && lgSecondOK,
 			Detail: fmt.Sprintf("target 第2次 repeated=%v(期望 true);legacy 第2次 success=%v(SP 无幂等键,确实重复入账)",
@@ -986,7 +998,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	if c.Op == OpRecharge || c.Op == OpSchoolRecharge {
 		okL := dCampus.Balance == wfL.Balance && dCampus.Income == wfL.Income
 		okT := tCampus.Balance == wfT.Balance && tCampus.Income == wfT.Income
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name: "双表写入:校区表 Δbalance / Δtotal_income 必须等于充值额(若被写成\"只写全局\"会红)",
 			Pass: okL && okT,
 			Detail: fmt.Sprintf("校区 Δbalance legacy=%s target=%s(期望 %s / %s);Δincome legacy=%s target=%s(期望 %s / %s)",
@@ -998,7 +1010,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	// ⑥.6 批 B 的 op 专属断言
 	switch c.Op {
 	case OpSpend:
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name:   "累计消费:**不写流水**(dump 的 SP11 全文没有 INSERT log)",
 			Pass:   len(lg.After.Logs) == 0 && len(tg.After.Logs) == 0,
 			Detail: fmt.Sprintf("legacy %d 条 / target %d 条(都应为 0)", len(lg.After.Logs), len(tg.After.Logs)),
@@ -1008,7 +1020,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		typesL, typesT := logTypes(lg.After.Logs), logTypes(tg.After.Logs)
 		okL := transferLogsOK(lg.After.Logs, runsLegacy)
 		okT := transferLogsOK(tg.After.Logs, runsTarget)
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name: "转结:每次**一对流水(106 +m / 206 −m)且 SUM(money)==0**(钱没消失,只是换了校区)",
 			Pass: okL && okT && sumL == 0 && sumT == 0,
 			Detail: fmt.Sprintf("legacy %d 条 types=%s SUM=%s(期望 %d 条)| target %d 条 types=%s SUM=%s(期望 %d 条)",
@@ -1018,7 +1030,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 		// 转结的核心语义:两个校区**各自**变化,且互为反号
 		ok := dCampus.Expenditure == -dCampus2.Expenditure && tCampus.Expenditure == -tCampus2.Expenditure &&
 			dCampus.Expenditure != 0
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name: "转结:两校区 total_expenditure 变化量**互为反号**(转出 −m / 转入 +m)",
 			Pass: ok,
 			Detail: fmt.Sprintf("legacy 转出=%s 转入=%s | target 转出=%s 转入=%s",
@@ -1031,7 +1043,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 			tGlobal.Balance == c.AmountCents && tGlobal.Special == c.AmountCents &&
 			dCampus.Balance == c.AmountCents && dCampus.Special == c.AmountCents &&
 			tCampus.Balance == c.AmountCents && tCampus.Special == c.AmountCents
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name: "坏账:balance 与 special_expenses **必须同时 +金额**(只加其中一个 → 红)",
 			Pass: ok,
 			Detail: fmt.Sprintf("legacy 全局 Δbal=%s Δspec=%s | target 全局 Δbal=%s Δspec=%s(期望各 +%s)",
@@ -1049,7 +1061,7 @@ func compare(c Case, lg, tg *SideResult) ([]Diff, []Assertion) {
 	if c.ExpectNegativeBalance {
 		negOK := lg.After.Global.Balance < 0 && tg.After.Global.Balance < 0 &&
 			lg.After.Campus.Balance < 0 && tg.After.Campus.Balance < 0
-		asserts = append(asserts, Assertion{
+		asserts = append(asserts, Assertion{Kind: AssertionBusiness, 
 			Name: "R15:余额不足时两侧都扣成负数且都返回成功(不校验余额)",
 			Pass: negOK,
 			Detail: fmt.Sprintf("legacy 全局=%s 校区=%s | target 全局=%s 校区=%s(都应为负)",
@@ -1389,7 +1401,17 @@ func PrintResult(w io.Writer, r *CaseResult) {
 		if a.Skipped {
 			mark = "—" // 结构性不适用:不是"证明过",是"本 case 无从验证"
 		}
-		fmt.Fprintf(w, "    %s %s\n       %s\n", mark, a.Name, a.Detail)
+		if a.Kind == AssertionUnclassified {
+			// 未标注 Kind = 代码缺陷:这条断言既不算业务也不算基建,**谁都不看它**。
+			// 用 ⚠ 而不是 ✘:它不是"失败",是"没被分类、因此不参与任何判定"。
+			mark = "⚠"
+		}
+		kind := ""
+		if a.Kind != AssertionBusiness {
+			// 只给非业务打字样:业务断言占绝大多数,全打一遍会把报告淹掉。
+			kind = "[" + a.Kind.String() + "] "
+		}
+		fmt.Fprintf(w, "    %s %s%s\n       %s\n", mark, kind, a.Name, a.Detail)
 	}
 
 	verdict := "PASS"
@@ -1401,24 +1423,47 @@ func PrintResult(w io.Writer, r *CaseResult) {
 
 // Summarize 打印总体结论。
 func Summarize(w io.Writer, results []*CaseResult) bool {
-	total, pass, skipped := 0, 0, 0
+	total, pass, skipped, infra, unclassified := 0, 0, 0, 0, 0
 	for _, r := range results {
 		total++
 		if r.OK() {
 			pass++
 		}
 		skipped += r.SkippedCount()
+		infra += r.InfraCount()
+		unclassified += r.UnclassifiedCount()
 	}
 	fmt.Fprintf(w, "══════════════════════════════════════════════════════════════\n")
 	fmt.Fprintf(w, "总览:%d 个 case,%d PASS,%d FAIL\n", total, pass, total-pass)
 	// Skipped 是**第七个数**:`FailCount` 跳过它之后,"这条 case 是不是什么都没测"只能靠它看出来。
 	fmt.Fprintf(w, "Skipped:%d 项(结构性不适用,不计入 FAIL;每条 case 仍须有 ≥1 条真断言,否则 FATAL)\n", skipped)
+	// 业务 / 基建的条数:闸门只认业务断言,把两个数都打出来才能看出"闸门被谁满足了"。
+	fmt.Fprintf(w, "断言分类:业务 %d 条 | 基建 %d 条(残留 / 基线 / 有效性前提,**不计入闸门**)\n",
+		sumBusiness(results), infra)
+	// 未分类必须恒为 0:非 0 = 有人写了断言却没标 Kind,那条断言不参与任何判定。
+	if unclassified > 0 {
+		fmt.Fprintf(w, "⚠ 未分类断言(Kind==0):%d 条 —— **这是代码缺陷**,这些断言既不算业务也不算基建,"+
+			"等于写了一条谁都不看的断言\n", unclassified)
+	} else {
+		fmt.Fprintf(w, "未分类断言(Kind==0):0 条\n")
+	}
 	for _, r := range results {
 		st := "PASS"
 		if !r.OK() {
 			st = "FAIL"
 		}
-		fmt.Fprintf(w, "  %s  %-40s 不一致 %d  Skipped %d\n", st, r.Case.Name, r.FailCount(), r.SkippedCount())
+		fmt.Fprintf(w, "  %s  %-40s 不一致 %d  Skipped %d  业务断言 %d\n",
+			st, r.Case.Name, r.FailCount(), r.SkippedCount(), r.LiveAssertionCount())
+	}
+	// 未分类 > 0 也算不通过:它不会让某条 case 红,但它会让"断言分类"这件事本身失效。
+	return pass == total && unclassified == 0
+}
+
+// sumBusiness 统计全部 case 的非 Skipped 业务断言条数(报告用)。
+func sumBusiness(results []*CaseResult) int {
+	n := 0
+	for _, r := range results {
+		n += r.LiveAssertionCount()
 	}
-	return pass == total
+	return n
 }