diff --git a/test/integration/Elsa.Hosts.SmokeTests/MigrationGuideTests.cs b/test/integration/Elsa.Hosts.SmokeTests/MigrationGuideTests.cs
new file mode 100644
index 000000000..5d95ddf5a
--- /dev/null
+++ b/test/integration/Elsa.Hosts.SmokeTests/MigrationGuideTests.cs
@@ -0,0 +1,162 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+using Elsa.Authorization;
+using Elsa.Permissions;
+using Elsa.ModularServer.Web;
+using Elsa.Server.Web;
+
+namespace Elsa.Hosts.SmokeTests;
+
+///
+/// Checks the upgrade guide's mapping table against the catalog a host actually builds.
+///
+///
+/// The table tells operators how to rewrite every stored permission, and nothing had ever checked that what
+/// it tells them to write is a permission Elsa accepts. That is not hypothetical: the cutover left several
+/// checks comparing against legacy constants which the guide itself instructs you to replace, so following
+/// it silently disabled them. A table entry that does not parse, or that names a resource or verb no module
+/// advertises, is a deployment locked out of an endpoint by doing exactly as it was told.
+///
+/// Reading the published document rather than a copy is the point: this fails when the guide drifts from the
+/// code, which is the direction the drift actually goes.
+///
+///
+public class MigrationGuideTests
+{
+ [Fact]
+ public void EveryReplacementInTheMappingTableIsAWellFormedPermission()
+ {
+ var malformed = ReadReplacements()
+ .Where(x => !Permission.TryParse(x, out _))
+ .OrderBy(x => x, StringComparer.Ordinal)
+ .ToArray();
+
+ Assert.True(malformed.Length == 0, $"The guide tells operators to write {malformed.Length} value(s) that are not well-formed permissions, so nobody can hold them: {string.Join(", ", malformed)}.");
+ }
+
+ [Fact]
+ public void EveryReplacementInTheMappingTableIsAdvertisedByTheCatalog()
+ {
+ var catalog = BuildCatalog();
+
+ // A catalog that came up empty would pass every check below without testing anything.
+ Assert.True(catalog.Count > 20, $"Only {catalog.Count} resource(s) were discovered; the catalog is not being built and this test would pass vacuously.");
+
+ var unadvertised = ReadReplacements()
+ .Where(x => Permission.TryParse(x, out var permission) && !permission.HasWildcard && !IsAdvertised(catalog, permission))
+ .OrderBy(x => x, StringComparer.Ordinal)
+ .ToArray();
+
+ Assert.True(unadvertised.Length == 0, $"{unadvertised.Length} replacement(s) name a resource or verb no module advertises, so a role rewritten as the guide says would not authorize anything: {string.Join(", ", unadvertised)}.");
+ }
+
+ private static bool IsAdvertised(IReadOnlyDictionary> catalog, Permission permission) =>
+ catalog.TryGetValue(permission.Resource, out var verbs) && verbs.Contains(permission.Verb, StringComparer.Ordinal);
+
+ /// The resources and verbs every Elsa module shipped alongside these tests contributes.
+ ///
+ /// Built by loading every Elsa.*.dll in the output directory, which is deterministic and complete.
+ /// Two lazier approaches were tried and both under-report. AppDomain.CurrentDomain.GetAssemblies()
+ /// describes whatever earlier tests happened to touch, so this class saw 29 resources as missing when run
+ /// alone and none in a full run. Walking GetReferencedAssemblies() from the two hosts is no better:
+ /// the compiler drops references to assemblies whose types the app never names, so AI, OpenTelemetry and
+ /// Shells vanished despite being project references. The output directory has every module either host
+ /// pulls in, regardless of whether its types are mentioned.
+ ///
+ private static Dictionary> BuildCatalog() =>
+ Directory.GetFiles(AppContext.BaseDirectory, "Elsa.*.dll")
+ .Select(TryLoad)
+ .Where(x => x is not null)
+ .SelectMany(x => SafeGetTypes(x!))
+ .Where(x => x is { IsClass: true, IsAbstract: false } && typeof(IPermissionDescriptorProvider).IsAssignableFrom(x) && x.GetConstructor(Type.EmptyTypes) is not null)
+ .Select(x => (IPermissionDescriptorProvider)Activator.CreateInstance(x)!)
+ .SelectMany(x => x.GetDescriptors())
+ .GroupBy(x => x.Resource, StringComparer.Ordinal)
+ .ToDictionary(x => x.Key, x => (IReadOnlyCollection)x.SelectMany(d => d.SupportedVerbs).Distinct(StringComparer.Ordinal).ToArray(), StringComparer.Ordinal);
+
+ private static Assembly? TryLoad(string path)
+ {
+ try
+ {
+ return Assembly.LoadFrom(path);
+ }
+ catch (Exception ex) when (ex is BadImageFormatException or FileLoadException or FileNotFoundException)
+ {
+ // A native or otherwise unloadable file in the output directory contributes nothing.
+ return null;
+ }
+ }
+
+ private static IEnumerable SafeGetTypes(Assembly assembly)
+ {
+ try
+ {
+ return assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException ex)
+ {
+ return ex.Types.Where(x => x is not null)!;
+ }
+ }
+
+ /// Every permission the mapping table's right-hand column tells an operator to write.
+ ///
+ /// Rows are matched after trimming, and every data row must yield at least one replacement. Requiring a
+ /// row to start with an unindented | ` looked equivalent and was not: a formatting-only change that
+ /// indented the table, or a left column written without backticks, would drop rows silently while the
+ /// totals still looked plausible. A skipped row is an unchecked permission, which is the one outcome this
+ /// test exists to prevent, so it is made loud rather than merely unlikely.
+ ///
+ private static IReadOnlyCollection ReadReplacements()
+ {
+ var guide = FindGuide();
+ var lines = File.ReadAllLines(guide);
+ var start = Array.FindIndex(lines, x => x.Trim().StartsWith("## Full mapping", StringComparison.Ordinal));
+ Assert.True(start >= 0, $"No '## Full mapping' section in {guide}. If the section was renamed, this test is looking in the wrong place rather than passing vacuously.");
+
+ var rows = lines.Skip(start + 1)
+ .TakeWhile(x => !x.Trim().StartsWith("## ", StringComparison.Ordinal))
+ .Select(x => x.Trim())
+ .Where(x => x.StartsWith('|'))
+ .Select(x => x.Split('|'))
+ .Where(x => x.Length >= 3)
+ // The header names the columns and the next row is the --- separator; neither maps a permission.
+ .Where(x => !x[1].Trim().Trim('-', ':', ' ').Equals(string.Empty, StringComparison.Ordinal))
+ .Where(x => x[1].Contains('`'))
+ .ToArray();
+
+ var replacements = new List();
+ var emptyRows = new List();
+
+ foreach (var row in rows)
+ {
+ var matches = Regex.Matches(row[2], "`([^`]+)`").Select(x => x.Groups[1].Value).ToArray();
+
+ if (matches.Length > 0)
+ replacements.AddRange(matches);
+ // A permission that was dropped rather than translated has no replacement to check. Those rows say
+ // so in words, so they are recognised rather than treated as a parse failure -- but only those.
+ else if (!row[2].Contains("removed", StringComparison.OrdinalIgnoreCase))
+ emptyRows.Add(row[1].Trim());
+ }
+
+ Assert.True(emptyRows.Count == 0, $"{emptyRows.Count} mapping row(s) yielded no replacement and do not say the permission was removed, so what they document is going unchecked: {string.Join(", ", emptyRows)}.");
+
+ // A parser that silently matches nothing passes forever.
+ Assert.True(rows.Length > 20, $"Only {rows.Length} mapping row(s) parsed out of the table; the format has changed and this test is no longer reading it.");
+ return replacements.Distinct(StringComparer.Ordinal).ToArray();
+ }
+
+ private static string FindGuide()
+ {
+ for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent)
+ {
+ var candidate = Path.Combine(directory.FullName, "doc", "migrations", "authorization-model.md");
+
+ if (File.Exists(candidate))
+ return candidate;
+ }
+
+ throw new FileNotFoundException("Could not locate doc/migrations/authorization-model.md from the test output directory.");
+ }
+}