فهرست منبع

fix(core): reconcile known migration collision before update

2026-09-15 22:58 一次重启从尚未更新到目标 revision 的工作区启动,其
UpdateScripts/1.0.536.sql 当时仍是旧 Stage-3 内容(与 1.0.539 同一份 SQL、
仅注释里版本号不同),该内容被执行并记成 version=1.0.536 / hash=970E5A8F /
Success。而同一份内容早已由 version=1.0.539 / hash=9B627E41 合法记录。
结果真正的 1.0.536(API_INBOUND,hash=5A18A15A)永远无法执行——
ShouldSkipScript 在计算 pending 时就抛 hash 不符,任何带目标 revision
的实例都起不来。

新增 MigrationCollisionPolicy:纯函数、版本精确登记、七道闸门。
G1-G4 识别事故签名,任一不成立返回 None(全新环境、已升级完成的环境、
失败重试中的环境都必须放行给既有流程;尤其「已升级完成」与事故环境
只差 file_hash 一个字段,误判会打死正常库)。G5-G7 是安全前提,
签名确认后任一不成立一律 Reject 并停机。

不删除任何历史行:sys_db_migration_log 对 version 有唯一键,且
UpsertMigrationLog 按 version 原地 UPDATE,因此只把误记行 status 置为
Superseded,随后由原有正常流程执行真正的脚本并覆写同一行——
「这条迁移到底成没成功」始终是脚本自己跑出来的结论。

reconcile 必须在计算 pending 之前调用;放在执行循环里(像
TryRecoverKnownFailedMigration 那样)永远够不到这类事故。

本策略不能感知同库上的其它实例:若重整后、正式脚本执行前有仍持旧脚本
的实例启动,它会重跑旧内容并把 hash 改回去。部署时必须先停掉所有连接
该库的旧 backend,这是流程前提,代码无法兜底。

隔离库演练:First Run 完成 reconcile → 1.0.536/537/538/551 全部 Success
且 verify Success;Second Run reconcile 次数 0、pending 0、history 与
schema checksum 逐行不变。单测 24/24,并经变异验证(闸门 4 改 Reject
触发 3 个失败、删除闸门 6 触发 4 个失败)。
YY968XX 3 روز پیش
والد
کامیت
941ea73482

+ 119 - 0
server/Admin.NET.Core/Update/AutoVersionUpdate.cs

@@ -80,6 +80,13 @@ public static class AutoVersionUpdate
 
         var scripts = LoadMigrationScripts();
         var migrationRows = LoadMigrationLogs(db);
+
+        // 已登记的「迁移记录被误记」重整。必须在计算 pending **之前**做:
+        // ShouldSkipScript 正是在计算 pending 时就对 hash 不符抛错,放在执行循环里(像
+        // TryRecoverKnownFailedMigration 那样)永远够不到这类事故。
+        if (TryReconcileKnownCollisions(db, scripts, migrationRows))
+            migrationRows = LoadMigrationLogs(db);
+
         var successVersions = migrationRows
             .Where(x => string.Equals(x.Status, StatusSuccess, StringComparison.OrdinalIgnoreCase))
             .Select(x => ParseVersion(x.Version))
@@ -119,6 +126,118 @@ public static class AutoVersionUpdate
         SetEntryAssemblyCurrentVersion(currentVersionText, true);
     }
 
+    /// <summary>
+    /// 已登记的「迁移记录被误记」重整。裁决全部交给 <see cref="MigrationCollisionPolicy"/>(纯函数、可单测),
+    /// 本方法只负责执行副作用。
+    ///
+    /// <para><b>不删除任何历史行</b>:只把误记行的 status 置为
+    /// <see cref="MigrationCollisionPolicy.SupersededStatus"/>,随后由原有正常流程执行真正的脚本,
+    /// <see cref="UpsertMigrationLog"/> 会按 version 原地把同一行覆写成真实执行结果。</para>
+    ///
+    /// <para><b>幂等</b>:重整后若进程崩溃,该行停在 Superseded、hash 仍是旧值,
+    /// 下次启动闸门 3 不成立 ⇒ 返回 None,而 ShouldSkipScript 对非 Success 行返回 false,
+    /// 脚本照常执行,仍然收敛。执行成功后 hash 变为目标值,闸门 4 不成立 ⇒ 永不再触发。</para>
+    /// </summary>
+    /// <returns>是否实际改动过迁移日志(true 时调用方需重新载入)。</returns>
+    private static bool TryReconcileKnownCollisions(
+        ISqlSugarClient db,
+        List<MigrationScript> scripts,
+        List<MigrationLogRow> migrationRows)
+    {
+        var changed = false;
+
+        foreach (var plan in MigrationCollisionPolicy.KnownCollisions)
+        {
+            var rows = migrationRows
+                .Where(x => string.Equals(x.Version, plan.Version, StringComparison.OrdinalIgnoreCase))
+                .ToList();
+            var row = rows.Count == 1 ? rows[0] : null;
+            var displacedRow = migrationRows.FirstOrDefault(x =>
+                string.Equals(x.Version, plan.DisplacedVersion, StringComparison.OrdinalIgnoreCase));
+
+            var targetScript = scripts.FirstOrDefault(s =>
+                string.Equals(s.Version, plan.Version, StringComparison.OrdinalIgnoreCase));
+            var displacedScript = scripts.FirstOrDefault(s =>
+                string.Equals(s.Version, plan.DisplacedVersion, StringComparison.OrdinalIgnoreCase));
+
+            var outcome = MigrationCollisionPolicy.Decide(
+                plan.Version,
+                rows.Count,
+                row?.Status,
+                row?.FileHash,
+                targetScript != null && MigrationScriptHash.MatchesFile(plan.ExpectedDiskHash, targetScript.FilePath),
+                displacedRow?.Status,
+                displacedRow?.FileHash,
+                displacedScript != null && MigrationScriptHash.MatchesFile(plan.DisplacedFileHash, displacedScript.FilePath));
+
+            if (outcome.Decision == MigrationCollisionDecision.None) continue;
+
+            if (outcome.Decision == MigrationCollisionDecision.Reject)
+                throw new InvalidOperationException(
+                    $"版本 {plan.Version} 命中已登记的记录冲突策略 {plan.Strategy},但拒绝执行:"
+                    + $"{outcome.RejectReason}。该操作会修改迁移历史,不对未确认状态执行,请人工确认。");
+
+            LogHelper.Handle(
+                $"Migration collision reconcile start version={plan.Version} strategy={plan.Strategy} "
+                + $"collided={plan.CollidedFileHash} displaced_by={plan.DisplacedVersion}");
+
+            ResetCollidedMigrationRecord(db, plan);
+            changed = true;
+
+            LogHelper.Handle(
+                $"Migration collision reconcile done version={plan.Version};"
+                + "该行已置为 Superseded,接下来由原迁移脚本正常执行并原地覆写,状态由正常流程写入。");
+        }
+
+        return changed;
+    }
+
+    /// <summary>
+    /// 把误记行的状态重整掉。事务内执行,命中数必须恰为 1,否则回滚并停机。
+    /// 谓词同时钉死 version + file_hash + status,确保只可能命中那一行。
+    /// </summary>
+    private static void ResetCollidedMigrationRecord(ISqlSugarClient db, MigrationCollisionPlan plan)
+    {
+        var audit =
+            $"{plan.Strategy}: 本行原记录 version={plan.Version} / hash={plan.CollidedFileHash},"
+            + $"实为 {plan.DisplacedVersion} 的脚本内容被误记;该内容已由 {plan.DisplacedVersion} "
+            + $"(hash={plan.DisplacedFileHash})合法记录在案。本行已重整,待 {plan.Version} 正式执行后覆写。";
+
+        db.Ado.BeginTran();
+        try
+        {
+            var affected = db.Ado.ExecuteCommand(
+                $"""
+                 UPDATE {MigrationLogTable}
+                    SET status = @newStatus,
+                        error_message = @audit,
+                        updated_at = @now
+                  WHERE version = @version
+                    AND file_hash = @collidedHash
+                    AND status = '{StatusSuccess}'
+                 """,
+                new
+                {
+                    newStatus = MigrationCollisionPolicy.SupersededStatus,
+                    audit,
+                    now = DateTime.Now,
+                    version = plan.Version,
+                    collidedHash = plan.CollidedFileHash
+                });
+
+            if (affected != 1)
+                throw new InvalidOperationException(
+                    $"重整 {plan.Version} 误记行时命中 {affected} 行,期望恰好 1 行。已回滚,不继续启动。");
+
+            db.Ado.CommitTran();
+        }
+        catch
+        {
+            db.Ado.RollbackTran();
+            throw;
+        }
+    }
+
     /// <summary>
     /// 已知失败迁移的「重跑前恢复」。裁决全部交给 <see cref="MigrationRecoveryPolicy"/>(纯函数、可单测),
     /// 本方法只负责执行副作用。

+ 155 - 0
server/Admin.NET.Core/Update/MigrationCollisionPolicy.cs

@@ -0,0 +1,155 @@
+namespace Admin.NET.Core.Update;
+
+/// <summary>已知迁移记录冲突的重整裁决。</summary>
+public enum MigrationCollisionDecision
+{
+    /// <summary>不做任何事(绝大多数情况,含全新环境与已升级完成的环境)。</summary>
+    None = 0,
+
+    /// <summary>把误记的那一行状态重整掉,让原脚本按正常流程重跑并原地覆写该行。</summary>
+    ResetCollidedRecord = 1,
+
+    /// <summary>拒绝并停机:命中了登记的事故签名,但安全前提不成立。</summary>
+    Reject = 2
+}
+
+/// <summary>一条已登记的迁移记录冲突,及其重整所需的全部证据常量。</summary>
+public sealed record MigrationCollisionPlan(
+    string Version,
+    string CollidedFileHash,
+    string ExpectedDiskHash,
+    string DisplacedVersion,
+    string DisplacedFileHash,
+    string Strategy);
+
+/// <summary>裁决结果。</summary>
+public sealed record MigrationCollisionOutcome(
+    MigrationCollisionDecision Decision,
+    MigrationCollisionPlan? Plan,
+    string? RejectReason);
+
+/// <summary>
+/// 已知「迁移记录被误记」的重整策略。<b>纯函数,不碰数据库</b>,effect 由调用方执行。
+///
+/// <para><b>与 <see cref="MigrationRecoveryPolicy"/> 的区别</b>:那个处理「脚本 Failed、重跑前清状态」,
+/// 在执行循环内按 pending 脚本调用;本策略处理「某版本被记成 Success,但记录的内容其实是另一个版本的脚本」,
+/// 必须在 pending <b>计算之前</b>调用 —— 因为 <c>ShouldSkipScript</c> 正是在计算 pending 时就抛 hash 不符。</para>
+///
+/// <para><b>1.0.536 的具体情况</b>:2026-09-15 22:58 一次重启从<b>尚未更新到目标 revision 的工作区</b>启动,
+/// 其 <c>UpdateScripts/1.0.536.sql</c> 当时仍是旧 Stage-3 内容(与 1.0.539 同一份 SQL、仅注释里版本号不同),
+/// 于是该内容被执行并记成了 <c>version=1.0.536 / hash=970E5A8F… / Success</c>。
+/// 而同一份 Stage-3 内容早已在 2026-09-12 以 <c>version=1.0.539 / hash=9B627E41…</c> 合法记录。
+/// 结果:真正的 1.0.536(API_INBOUND)永远无法执行 —— 它在目标 revision 上的 hash 是 5A18A15A…,
+/// 与记录不符,<c>ShouldSkipScript</c> 直接抛错,任何带目标 revision 的实例都起不来。</para>
+///
+/// <para><b>为什么是「重整状态」而不是「删除记录」</b>:<c>sys_db_migration_log</c> 对 version 有唯一键,
+/// 且 <c>UpsertMigrationLog</c> 按 version 查到已有行后<b>原地 UPDATE</b>。
+/// 因此只要把误记行的状态改掉,正式的 1.0.536 就会被执行,并由<b>原有正常流程</b>把同一行
+/// 覆写成真实的 <c>hash=5A18A15A… / Success</c>。历史不被删除,而是被真实执行结果取代——
+/// 「这条迁移到底成没成功」始终是脚本自己跑出来的结论。</para>
+///
+/// <para><b>安全边界</b>:本策略<b>不能</b>感知同一数据库上的其它实例。若重整之后、正式脚本执行之前,
+/// 某个仍持旧脚本的实例启动,它会重跑旧 Stage-3 并把 hash 改回去。
+/// 因此部署时必须先停掉所有连接该库的旧 backend —— 这是流程前提,代码无法兜底。</para>
+/// </summary>
+public static class MigrationCollisionPolicy
+{
+    /// <summary>重整后写入的中间状态。刻意不使用 Running/Success/Failed/Skipped 任一既有值,避免与正常流程语义混淆。</summary>
+    public const string SupersededStatus = "Superseded";
+
+    /// <summary>
+    /// 冲突登记表。<b>只按版本号精确登记,并钉死全部相关 SHA256,不做任何模式匹配、不按脚本内容推断。</b>
+    /// 新增条目等于授权一次对迁移历史的修改,必须逐条评审。
+    /// <b>严禁</b>据此演化出任何「hash 不符就改历史」的通用逻辑。
+    /// </summary>
+    public static readonly IReadOnlyList<MigrationCollisionPlan> KnownCollisions = new[]
+    {
+        new MigrationCollisionPlan(
+            Version:           "1.0.536",
+            CollidedFileHash:  "970E5A8F0133944CB04AC95E67112A2EC6E2E45F06AE61C3F2049163F7DC9A61",
+            ExpectedDiskHash:  "5A18A15AB379167A151C317A18617E2841EC4E808C8FD727216360D81EA76776",
+            DisplacedVersion:  "1.0.539",
+            DisplacedFileHash: "9B627E41C15EBB5195C035592870E56A9EDE5704FA423DDEC5A4A8FF94A6CE42",
+            Strategy:          "COLLISION_STATUS_RESET")
+    };
+
+    /// <summary>
+    /// 裁决是否要在计算 pending 之前重整某条误记的迁移记录。
+    ///
+    /// <para>闸门 1–4 用于<b>识别事故签名</b>,任一不成立一律返回 <see cref="MigrationCollisionDecision.None"/>
+    /// —— 这些情形(全新环境、已升级完成的环境、失败重试中的环境)都属正常,必须放行给既有流程处理。</para>
+    ///
+    /// <para>签名确认之后,闸门 5–7 是<b>安全前提</b>,任一不成立一律
+    /// <see cref="MigrationCollisionDecision.Reject"/> 并停机,不允许降级为「跳过重整继续启动」。</para>
+    /// </summary>
+    /// <param name="version">待判定的脚本版本号。</param>
+    /// <param name="matchedRowCount">迁移日志中该 version 的行数(正常为 0 或 1)。</param>
+    /// <param name="loggedStatus">该 version 日志行的状态;无行时传 null。</param>
+    /// <param name="loggedFileHash">该 version 日志行记录的 SHA256。</param>
+    /// <param name="targetDiskHashMatches">磁盘脚本内容是否等于 <c>ExpectedDiskHash</c>(由调用方用 MigrationScriptHash 判定)。</param>
+    /// <param name="displacedLoggedStatus">被顶替版本(如 1.0.539)日志行的状态。</param>
+    /// <param name="displacedLoggedFileHash">被顶替版本日志行记录的 SHA256。</param>
+    /// <param name="displacedDiskHashMatches">磁盘上被顶替版本的脚本内容是否等于 <c>DisplacedFileHash</c>。</param>
+    public static MigrationCollisionOutcome Decide(
+        string version,
+        int matchedRowCount,
+        string? loggedStatus,
+        string? loggedFileHash,
+        bool targetDiskHashMatches,
+        string? displacedLoggedStatus,
+        string? displacedLoggedFileHash,
+        bool displacedDiskHashMatches)
+    {
+        // 闸门 1:版本必须精确登记。
+        var plan = KnownCollisions.FirstOrDefault(x =>
+            string.Equals(x.Version, version, StringComparison.OrdinalIgnoreCase));
+        if (plan == null)
+            return new MigrationCollisionOutcome(MigrationCollisionDecision.None, null, null);
+
+        // 闸门 2:该版本必须恰好有一行日志。
+        // 0 行 = 全新环境的正常首次执行,不是事故场景。
+        if (matchedRowCount != 1)
+            return new MigrationCollisionOutcome(MigrationCollisionDecision.None, plan, null);
+
+        // 闸门 3:必须处于 Success。
+        // Failed / Running 是别的问题,交给既有路径(ShouldSkipScript 会让它重跑)。
+        if (!string.Equals(loggedStatus, "Success", StringComparison.OrdinalIgnoreCase))
+            return new MigrationCollisionOutcome(MigrationCollisionDecision.None, plan, null);
+
+        // 闸门 4:记录的 hash 必须正是那次事故写入的值。
+        // 不符则一律 None —— 关键:**已经升级完成的环境**这里是 ExpectedDiskHash,
+        // 若在此 Reject 会把所有正常环境打死。交给 ShouldSkipScript 按常规判定即可。
+        if (!string.Equals(loggedFileHash?.Trim(), plan.CollidedFileHash, StringComparison.OrdinalIgnoreCase))
+            return new MigrationCollisionOutcome(MigrationCollisionDecision.None, plan, null);
+
+        // —— 至此确认命中登记的事故签名,以下任一不成立即停机 ——
+
+        // 闸门 5:当前部署必须就是目标 revision。
+        // 否则说明这是个带着别的代码版本的实例,重整后它会跑出我们没预期的脚本。
+        if (!targetDiskHashMatches)
+            return new MigrationCollisionOutcome(
+                MigrationCollisionDecision.Reject, plan,
+                $"命中 {plan.Version} 事故签名,但磁盘脚本与登记的目标内容({plan.ExpectedDiskHash})不一致,"
+                + "当前部署不是该重整所针对的 revision");
+
+        // 闸门 6:被顶替的内容必须已被合法记录在案。
+        // 这是最关键的一道:证明重整不会抹掉任何真实发生过的执行史。
+        if (!string.Equals(displacedLoggedStatus, "Success", StringComparison.OrdinalIgnoreCase) ||
+            !string.Equals(displacedLoggedFileHash?.Trim(), plan.DisplacedFileHash, StringComparison.OrdinalIgnoreCase))
+            return new MigrationCollisionOutcome(
+                MigrationCollisionDecision.Reject, plan,
+                $"命中 {plan.Version} 事故签名,但未能证明被顶替的内容已由 {plan.DisplacedVersion} "
+                + $"(hash={plan.DisplacedFileHash}、status=Success)合法记录:"
+                + $"实际 status={displacedLoggedStatus}、hash={displacedLoggedFileHash}");
+
+        // 闸门 7:磁盘上被顶替版本的脚本也必须仍是登记的那一份,
+        // 确保「已记录的那条」与「本 revision 携带的那条」是同一个东西。
+        if (!displacedDiskHashMatches)
+            return new MigrationCollisionOutcome(
+                MigrationCollisionDecision.Reject, plan,
+                $"命中 {plan.Version} 事故签名,但磁盘上的 {plan.DisplacedVersion} 脚本内容与登记的 "
+                + $"{plan.DisplacedFileHash} 不一致");
+
+        return new MigrationCollisionOutcome(MigrationCollisionDecision.ResetCollidedRecord, plan, null);
+    }
+}

+ 202 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Migration/MigrationCollisionPolicyTests.cs

@@ -0,0 +1,202 @@
+using Admin.NET.Core.Update;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.Migration;
+
+/// <summary>
+/// 已登记「迁移记录被误记」重整策略的契约。
+///
+/// <para>被守护的核心不变量有两条,方向相反、同等重要:</para>
+///
+/// <para><b>① 不能误伤正常环境。</b>闸门 1–4 任一不成立必须返回 None——
+/// 全新环境(无日志行)、已升级完成的环境(hash 已是目标值)、失败重试中的环境(非 Success),
+/// 这三种都是正常状态,一旦在这里 Reject 就是把好好的库打死。
+/// 其中「已升级完成」最危险:它与事故环境只差 file_hash 一个字段。</para>
+///
+/// <para><b>② 命中事故签名之后不能沉默放行。</b>闸门 5–7 是安全前提,
+/// 任一不成立必须 Reject 停机,绝不允许降级成「跳过重整继续启动」——
+/// 该通道会修改迁移历史,沉默放行等于在无人知晓的情况下改写执行史。</para>
+///
+/// <para>策略是纯函数,故这些用例不需要数据库;副作用(UPDATE / 抛异常)由
+/// AutoVersionUpdate 执行,其真实运行结果由隔离库的 First Run / Second Run 覆盖。</para>
+/// </summary>
+public class MigrationCollisionPolicyTests
+{
+    private const string V = "1.0.536";
+    private const string CollidedHash = "970E5A8F0133944CB04AC95E67112A2EC6E2E45F06AE61C3F2049163F7DC9A61";
+    private const string TargetHash = "5A18A15AB379167A151C317A18617E2841EC4E808C8FD727216360D81EA76776";
+    private const string DisplacedV = "1.0.539";
+    private const string DisplacedHash = "9B627E41C15EBB5195C035592870E56A9EDE5704FA423DDEC5A4A8FF94A6CE42";
+
+    /// <summary>事故现场的完整参数;各用例只改动自己要验证的那一个维度。</summary>
+    private static MigrationCollisionOutcome Decide(
+        string version = V,
+        int matchedRowCount = 1,
+        string? loggedStatus = "Success",
+        string? loggedFileHash = CollidedHash,
+        bool targetDiskHashMatches = true,
+        string? displacedLoggedStatus = "Success",
+        string? displacedLoggedFileHash = DisplacedHash,
+        bool displacedDiskHashMatches = true)
+        => MigrationCollisionPolicy.Decide(
+            version, matchedRowCount, loggedStatus, loggedFileHash, targetDiskHashMatches,
+            displacedLoggedStatus, displacedLoggedFileHash, displacedDiskHashMatches);
+
+    // ── 正向:只有事故现场本身才允许重整 ────────────────────────────────────────────
+
+    [Fact]
+    public void ExactAccidentSignature_ResetsRecord()
+    {
+        var outcome = Decide();
+
+        Assert.Equal(MigrationCollisionDecision.ResetCollidedRecord, outcome.Decision);
+        Assert.Equal("COLLISION_STATUS_RESET", outcome.Plan!.Strategy);
+        Assert.Equal(DisplacedV, outcome.Plan.DisplacedVersion);
+        Assert.Null(outcome.RejectReason);
+    }
+
+    /// <summary>不同驱动回读 hash / status 的形态不一致,大小写与首尾空白不应改变裁决。</summary>
+    [Theory]
+    [InlineData("Success", "970e5a8f0133944cb04ac95e67112a2ec6e2e45f06ae61c3f2049163f7dc9a61")]
+    [InlineData("SUCCESS", "  " + CollidedHash + "  ")]
+    [InlineData("success", CollidedHash)]
+    public void CaseInsensitiveAndTrimmed_StillResets(string status, string hash)
+        => Assert.Equal(
+            MigrationCollisionDecision.ResetCollidedRecord,
+            Decide(loggedStatus: status, loggedFileHash: hash).Decision);
+
+    // ── 闸门 1–4:正常环境一律 None,绝不 Reject ──────────────────────────────────
+
+    [Fact]
+    public void UnregisteredVersion_IsNone()
+    {
+        var outcome = Decide(version: "1.0.551");
+
+        Assert.Equal(MigrationCollisionDecision.None, outcome.Decision);
+        Assert.Null(outcome.Plan);
+    }
+
+    /// <summary>全新环境:该版本从未跑过 ⇒ 正常首次执行,不是事故场景。</summary>
+    [Fact]
+    public void FreshEnvironmentWithNoRow_IsNone()
+        => Assert.Equal(MigrationCollisionDecision.None, Decide(matchedRowCount: 0, loggedStatus: null, loggedFileHash: null).Decision);
+
+    /// <summary>唯一键理论上保证不会有多行;真出现了也只能停手,不能猜该动哪一行。</summary>
+    [Fact]
+    public void MultipleRowsForVersion_IsNone()
+        => Assert.Equal(MigrationCollisionDecision.None, Decide(matchedRowCount: 2).Decision);
+
+    [Theory]
+    [InlineData("Failed")]
+    [InlineData("Running")]
+    [InlineData("Skipped")]
+    [InlineData("Superseded")]
+    public void NonSuccessStatus_IsNone(string status)
+        => Assert.Equal(MigrationCollisionDecision.None, Decide(loggedStatus: status).Decision);
+
+    /// <summary>
+    /// 最危险的一条:<b>已经升级完成</b>的环境,记录里是目标 hash + Success。
+    /// 它与事故环境只差 file_hash 一个字段,若这里 Reject 就会把所有正常环境打死,
+    /// 必须放行给 ShouldSkipScript 按常规「hash 未变 ⇒ 跳过」处理。
+    /// </summary>
+    [Fact]
+    public void AlreadyUpgradedEnvironment_IsNone_NotReject()
+    {
+        var outcome = Decide(loggedFileHash: TargetHash);
+
+        Assert.Equal(MigrationCollisionDecision.None, outcome.Decision);
+        Assert.Null(outcome.RejectReason);
+    }
+
+    /// <summary>某个我们没见过的 1.0.536 ⇒ 不认领,交给既有 hash 守卫去拒绝启动。</summary>
+    [Fact]
+    public void UnknownHashForRegisteredVersion_IsNone()
+        => Assert.Equal(
+            MigrationCollisionDecision.None,
+            Decide(loggedFileHash: "0000000000000000000000000000000000000000000000000000000000000000").Decision);
+
+    // ── 闸门 5–7:命中签名后,安全前提不成立一律 Reject ────────────────────────────
+
+    /// <summary>命中事故签名但磁盘不是目标 revision ⇒ 重整后会跑出未预期的脚本,必须停机。</summary>
+    [Fact]
+    public void SignatureMatchedButWrongRevision_Rejects()
+    {
+        var outcome = Decide(targetDiskHashMatches: false);
+
+        Assert.Equal(MigrationCollisionDecision.Reject, outcome.Decision);
+        Assert.Contains(TargetHash, outcome.RejectReason);
+    }
+
+    /// <summary>
+    /// 最关键的一道:被顶替的内容必须已被合法记录在案。
+    /// 拿不出这个证据就重整,等于可能抹掉一次真实发生过的执行史。
+    /// </summary>
+    [Theory]
+    [InlineData(null, DisplacedHash)]
+    [InlineData("Failed", DisplacedHash)]
+    [InlineData("Success", null)]
+    [InlineData("Success", "0000000000000000000000000000000000000000000000000000000000000000")]
+    public void DisplacedContentNotProvenRecorded_Rejects(string? status, string? hash)
+    {
+        var outcome = Decide(displacedLoggedStatus: status, displacedLoggedFileHash: hash);
+
+        Assert.Equal(MigrationCollisionDecision.Reject, outcome.Decision);
+        Assert.Contains(DisplacedV, outcome.RejectReason);
+    }
+
+    /// <summary>磁盘上的被顶替脚本也必须仍是登记的那一份,否则「已记录的」与「本 revision 携带的」不是同一个东西。</summary>
+    [Fact]
+    public void DisplacedScriptOnDiskDiffers_Rejects()
+        => Assert.Equal(MigrationCollisionDecision.Reject, Decide(displacedDiskHashMatches: false).Decision);
+
+    // ── 幂等:三种中断/完成状态下的收敛行为 ──────────────────────────────────────
+
+    /// <summary>First Run 成功后:hash 已是目标值 ⇒ 永不再触发。</summary>
+    [Fact]
+    public void AfterSuccessfulFirstRun_NeverTriggersAgain()
+        => Assert.Equal(MigrationCollisionDecision.None, Decide(loggedFileHash: TargetHash).Decision);
+
+    /// <summary>重整后崩溃(Superseded + 旧 hash):闸门 3 不成立 ⇒ None;
+    /// 而 ShouldSkipScript 对非 Success 行返回 false,脚本照常执行,仍然收敛。</summary>
+    [Fact]
+    public void AfterResetButBeforeExecution_IsNone_SoNormalFlowProceeds()
+        => Assert.Equal(
+            MigrationCollisionDecision.None,
+            Decide(loggedStatus: MigrationCollisionPolicy.SupersededStatus).Decision);
+
+    /// <summary>正式脚本执行失败(目标 hash + Failed):走常规重试,不再经过本策略。</summary>
+    [Fact]
+    public void AfterFailedExecution_IsNone()
+        => Assert.Equal(
+            MigrationCollisionDecision.None,
+            Decide(loggedStatus: "Failed", loggedFileHash: TargetHash).Decision);
+
+    // ── 登记表本身的约束 ────────────────────────────────────────────────────────
+
+    /// <summary>
+    /// 登记表只应包含这一条已评审的事故。新增条目 = 授权一次对迁移历史的修改,
+    /// 必须先更新本用例,以强制评审发生。
+    /// </summary>
+    [Fact]
+    public void RegistryContainsOnlyTheReviewedCollision()
+    {
+        var plan = Assert.Single(MigrationCollisionPolicy.KnownCollisions);
+
+        Assert.Equal(V, plan.Version);
+        Assert.Equal(CollidedHash, plan.CollidedFileHash);
+        Assert.Equal(TargetHash, plan.ExpectedDiskHash);
+        Assert.Equal(DisplacedV, plan.DisplacedVersion);
+        Assert.Equal(DisplacedHash, plan.DisplacedFileHash);
+    }
+
+    /// <summary>
+    /// 重整写入的中间状态必须避开全部既有状态值,否则会被正常流程误解
+    /// (例如被 successVersions 统计、或被失败恢复策略认领)。
+    /// </summary>
+    [Fact]
+    public void SupersededStatusDoesNotCollideWithExistingStatuses()
+    {
+        foreach (var existing in new[] { "Running", "Success", "Failed", "Skipped" })
+            Assert.NotEqual(existing, MigrationCollisionPolicy.SupersededStatus, StringComparer.OrdinalIgnoreCase);
+    }
+}