diff --git a/TODO.md b/TODO.md index c84c9c0..697a760 100644 --- a/TODO.md +++ b/TODO.md @@ -118,6 +118,13 @@ Reliability rule for runtime/mod tasks: evidence: bundle `TestResults/runs/LIVE-TACTICAL-20260228-211256/repro-bundle.json` evidence: bundle `TestResults/runs/LIVE-AOTR-20260228-211521/repro-bundle.json` evidence: bundle `TestResults/runs/LIVE-ROE-20260228-211757/repro-bundle.json` +- [x] Delta closure wave: default non-forced promoted FoC routing + env override (`SWFOC_FORCE_PROMOTED_EXTENDER`), `universal_auto` backend de-trap (`auto`), and profile metadata save-root portability cleanup. + evidence: test `tests/SwfocTrainer.Tests/Runtime/BackendRouterTests.cs` + evidence: test `tests/SwfocTrainer.Tests/Profiles/ProfileInheritanceTests.cs` + evidence: test `tests/SwfocTrainer.Tests/Profiles/ProfileMetadataPortabilityTests.cs` + evidence: manual `2026-03-01` `dotnet restore SwfocTrainer.sln` + `dotnet build SwfocTrainer.sln -c Release --no-restore` + `dotnet test tests/SwfocTrainer.Tests/SwfocTrainer.Tests.csproj -c Release --no-build --filter "FullyQualifiedName!~SwfocTrainer.Tests.Profiles.Live&FullyQualifiedName!~RuntimeAttachSmokeTests"` => `Passed: 233` + evidence: bundle `TestResults/runs/20260301-004145/repro-bundle.json` (`classification=blocked_environment`, tactical default routing run; no swfoc process detected) + evidence: bundle `TestResults/runs/20260301-004232/repro-bundle.json` (`classification=blocked_environment`, tactical forced-override run; no swfoc process detected) ## Later (M2 + M3 + M4) diff --git a/docs/LIVE_VALIDATION_RUNBOOK.md b/docs/LIVE_VALIDATION_RUNBOOK.md index bd250c7..6055ef9 100644 --- a/docs/LIVE_VALIDATION_RUNBOOK.md +++ b/docs/LIVE_VALIDATION_RUNBOOK.md @@ -9,9 +9,11 @@ Use this runbook to gather real-machine evidence for runtime/mod issues and mile - `native/runtime/SwfocExtender.Host.exe` - `SWFOC_EXTENDER_HOST_PATH` for every `dotnet test` subprocess. - If running a live test command manually (outside run-live script), set `SWFOC_EXTENDER_HOST_PATH` explicitly to avoid nondeterministic host resolution. -- Promoted actions are extender-authoritative and fail-closed: - - no managed/memory fallback is accepted for promoted matrix evidence. - - missing or unverified promoted capability must surface fail-closed diagnostics. +- Save-root defaults are runtime-resolved from current user environment (`SaveOptions.DefaultSaveRootPath`); no profile should hardcode per-user save paths. +- Promoted FoC actions default to non-forced routing: + - `SWFOC_FORCE_PROMOTED_EXTENDER` unset/false keeps normal execution-kind routing. + - set `SWFOC_FORCE_PROMOTED_EXTENDER=1` when running extender-authoritative promoted matrix evidence. + - missing or unverified promoted capability under forced mode must surface fail-closed diagnostics. - For AOTR: ensure launch context resolves to `aotr_1397421866_swfoc`. - For ROE: ensure launch context resolves to `roe_3447786229_swfoc`. - From repo root, run on Windows PowerShell. @@ -139,6 +141,12 @@ vNext bundle sections (required for runtime-affecting changes): ## 4. Promoted Action Matrix Evidence (Issue #7) +Set extender-forced routing before promoted matrix closure runs: + +```powershell +$env:SWFOC_FORCE_PROMOTED_EXTENDER = "1" +``` + Promoted matrix evidence must cover 3 profiles x 5 actions (15 total checks): | Action ID | `base_swfoc` | `aotr_1397421866_swfoc` | `roe_3447786229_swfoc` | @@ -168,6 +176,12 @@ Expected evidence behavior for promoted actions: - `hasFallbackMarker=false` - fail-closed outcomes use explicit route diagnostics (`SAFETY_FAIL_CLOSED`) and block issue `#7` closure. +Reset the override after matrix runs: + +```powershell +Remove-Item Env:SWFOC_FORCE_PROMOTED_EXTENDER -ErrorAction SilentlyContinue +``` + `set_unit_cap` promoted matrix contract: - run as a deterministic two-step sequence per profile: diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md index d389f98..bd61338 100644 --- a/docs/TEST_PLAN.md +++ b/docs/TEST_PLAN.md @@ -69,7 +69,8 @@ - verifies feature-flag gate, missing runtime context gate, and mode mismatch blocking - `BackendRouterTests` - verifies fail-closed mutating behavior for hard-extender profiles - - verifies extender route promotion only when capability proof is present + - verifies promoted extender routing is opt-in via `SWFOC_FORCE_PROMOTED_EXTENDER` + - verifies extender route promotion only when capability proof is present under override mode - verifies capability contract blocking and legacy memory fallback behavior - verifies fallback patch actions stay off promoted extender matrix and preserve managed-memory routing - `MainViewModelSessionGatingTests` @@ -193,7 +194,13 @@ For each profile (`base_sweaw`, `base_swfoc`, `aotr_1397421866_swfoc`, `roe_3447 ## Phase 2 promoted action and hotkey checks -Promoted actions are extender-authoritative and fail-closed for FoC profiles. +Promoted FoC actions are not forced to extender by default. +For extender-authoritative promoted matrix evidence, explicitly enable: + +```powershell +$env:SWFOC_FORCE_PROMOTED_EXTENDER = "1" +``` + Evidence must come from `actionStatusDiagnostics` in `repro-bundle.json` (source `live-promoted-action-matrix.json`). Required profile/action matrix: @@ -226,6 +233,12 @@ Verification checklist: - when `top-mods.json` is present, `actionStatusDiagnostics.entries` must include explicit rows for discovered workshop ids (up to 10 mods x 5 promoted actions) - top-mod rows may be `Skipped` during deterministic/live runs that only execute shipped profile matrix, but each skipped row must include `skipReasonCode` +After matrix runs: + +```powershell +Remove-Item Env:SWFOC_FORCE_PROMOTED_EXTENDER -ErrorAction SilentlyContinue +``` + ## Live Ops checklist (M1) For tactical sessions: diff --git a/profiles/default/profiles/base_sweaw.json b/profiles/default/profiles/base_sweaw.json index c4005c1..e93b630 100644 --- a/profiles/default/profiles/base_sweaw.json +++ b/profiles/default/profiles/base_sweaw.json @@ -373,7 +373,6 @@ "metadata": { "profileLineage": "base", "targetExecutable": "sweaw.exe", - "saveRootDefault": "C:\\Users\\Prekzursil\\Saved Games\\Petroglyph", "profileAliases": "base_sweaw,sweaw,empire at war,eaw", "localPathHints": "sweaw,empire at war", "criticalSymbols": "credits,planet_owner,hero_respawn_timer,game_speed,unit_cap", diff --git a/profiles/default/profiles/base_swfoc.json b/profiles/default/profiles/base_swfoc.json index 00fa2f9..fa1a1fd 100644 --- a/profiles/default/profiles/base_swfoc.json +++ b/profiles/default/profiles/base_swfoc.json @@ -426,7 +426,6 @@ "metadata": { "profileLineage": "base", "targetExecutable": "swfoc.exe", - "saveRootDefault": "C:\\Users\\Prekzursil\\Saved Games\\Petroglyph", "profileAliases": "base_swfoc,swfoc,forces of corruption,foc", "localPathHints": "swfoc,corruption,forces of corruption", "criticalSymbols": "credits,planet_owner,hero_respawn_timer,game_speed,unit_cap", diff --git a/profiles/default/profiles/universal_auto.json b/profiles/default/profiles/universal_auto.json index 6310794..00eba33 100644 --- a/profiles/default/profiles/universal_auto.json +++ b/profiles/default/profiles/universal_auto.json @@ -4,14 +4,8 @@ "inherits": "base_swfoc", "exeTarget": "Swfoc", "steamWorkshopId": null, - "backendPreference": "extender", - "requiredCapabilities": [ - "set_credits", - "freeze_timer", - "toggle_fog_reveal", - "set_unit_cap", - "toggle_instant_build_patch" - ], + "backendPreference": "auto", + "requiredCapabilities": [], "hostPreference": "starwarsg_preferred", "experimentalFeatures": [ "overlay_ui" diff --git a/src/SwfocTrainer.Runtime/Services/BackendRouter.cs b/src/SwfocTrainer.Runtime/Services/BackendRouter.cs index 082e659..6f49e5e 100644 --- a/src/SwfocTrainer.Runtime/Services/BackendRouter.cs +++ b/src/SwfocTrainer.Runtime/Services/BackendRouter.cs @@ -5,6 +5,8 @@ namespace SwfocTrainer.Runtime.Services; public sealed class BackendRouter : IBackendRouter { + private const string PromotedExtenderOverrideEnvironmentVariable = "SWFOC_FORCE_PROMOTED_EXTENDER"; + private static readonly HashSet PromotedExtenderActionIds = new(StringComparer.OrdinalIgnoreCase) { "freeze_timer", @@ -59,7 +61,9 @@ private static RouteResolutionState CreateRouteResolutionState( ProcessMetadata process, CapabilityReport capabilityReport) { - var isPromotedExtenderAction = IsPromotedExtenderAction(request.Action.Id, profile, process); + var promotedExtenderOverride = ResolvePromotedExtenderOverrideState(); + var isPromotedActionCandidate = IsPromotedExtenderActionCandidate(request.Action.Id, profile, process); + var isPromotedExtenderAction = promotedExtenderOverride.Enabled && isPromotedActionCandidate; var defaultBackend = MapDefaultBackend(request.Action.ExecutionKind, isPromotedExtenderAction); var preferredBackend = ResolvePreferredBackend(profile.BackendPreference, defaultBackend, isPromotedExtenderAction); var isMutating = IsMutating(request.Action.Id); @@ -81,7 +85,9 @@ private static RouteResolutionState CreateRouteResolutionState( profileRequiredCapabilities, requiredCapabilities, missingRequired, - isPromotedExtenderAction)); + isPromotedExtenderAction, + promotedExtenderOverride.Enabled, + promotedExtenderOverride.Source)); return new RouteResolutionState( PreferredBackend: preferredBackend, MutatingAction: isMutating, @@ -108,6 +114,9 @@ private static RouteResolutionState CreateRouteResolutionState( ["missingRequiredCapabilities"] = context.MissingRequired, ["hybridExecution"] = false, ["promotedExtenderAction"] = context.PromotedExtenderAction, + ["promotedExtenderOverrideEnabled"] = context.PromotedExtenderOverrideEnabled, + ["promotedExtenderOverrideSource"] = context.PromotedExtenderOverrideSource, + ["promotedExtenderApplied"] = context.PromotedExtenderAction, ["capabilityMapReasonCode"] = capabilityMapReasonCode ?? string.Empty, ["capabilityMapState"] = capabilityMapState ?? string.Empty, ["capabilityDeclaredAvailable"] = capabilityDeclaredAvailable @@ -393,7 +402,7 @@ private static string[] ResolveRequiredCapabilitiesForAction( return required.ToArray(); } - private static bool IsPromotedExtenderAction( + private static bool IsPromotedExtenderActionCandidate( string actionId, TrainerProfile profile, ProcessMetadata process) @@ -403,6 +412,30 @@ private static bool IsPromotedExtenderAction( PromotedExtenderActionIds.Contains(actionId); } + private static PromotedExtenderOverrideState ResolvePromotedExtenderOverrideState() + { + var raw = Environment.GetEnvironmentVariable(PromotedExtenderOverrideEnvironmentVariable); + if (string.IsNullOrWhiteSpace(raw)) + { + return new PromotedExtenderOverrideState(Enabled: false, Source: "default"); + } + + if (bool.TryParse(raw, out var parsedBool)) + { + return new PromotedExtenderOverrideState(Enabled: parsedBool, Source: "env"); + } + + if (int.TryParse(raw, out var parsedInt)) + { + return new PromotedExtenderOverrideState(Enabled: parsedInt != 0, Source: "env"); + } + + var normalized = raw.Trim(); + var enabled = normalized.Equals("on", StringComparison.OrdinalIgnoreCase) || + normalized.Equals("yes", StringComparison.OrdinalIgnoreCase); + return new PromotedExtenderOverrideState(Enabled: enabled, Source: "env"); + } + private static bool IsFoCContext(TrainerProfile profile, ProcessMetadata process) { return profile.ExeTarget == ExeTarget.Swfoc || @@ -419,7 +452,9 @@ private readonly record struct BackendDiagnosticsContext( IReadOnlyList ProfileRequiredCapabilities, IReadOnlyList RequiredCapabilities, IReadOnlyList MissingRequired, - bool PromotedExtenderAction); + bool PromotedExtenderAction, + bool PromotedExtenderOverrideEnabled, + string PromotedExtenderOverrideSource); private readonly record struct RouteResolutionState( ExecutionBackendKind PreferredBackend, @@ -427,4 +462,8 @@ private readonly record struct RouteResolutionState( IReadOnlyList MissingRequired, Dictionary Diagnostics, bool PromotedExtenderAction); + + private readonly record struct PromotedExtenderOverrideState( + bool Enabled, + string Source); } diff --git a/tests/SwfocTrainer.Tests/Profiles/ProfileInheritanceTests.cs b/tests/SwfocTrainer.Tests/Profiles/ProfileInheritanceTests.cs index a6dcbe5..96da987 100644 --- a/tests/SwfocTrainer.Tests/Profiles/ProfileInheritanceTests.cs +++ b/tests/SwfocTrainer.Tests/Profiles/ProfileInheritanceTests.cs @@ -50,4 +50,22 @@ public async Task Manifest_Should_List_All_Target_Profiles() "universal_auto" ]); } + + [Fact] + public async Task UniversalAutoProfile_Should_Default_To_AutoBackendPreference() + { + var root = TestPaths.FindRepoRoot(); + var options = new ProfileRepositoryOptions + { + ProfilesRootPath = Path.Combine(root, "profiles", "default") + }; + + var repository = new FileSystemProfileRepository(options); + var profile = await repository.ResolveInheritedProfileAsync("universal_auto"); + + profile.BackendPreference.Should().Be("auto"); + profile.Actions.Should().ContainKey("set_credits"); + profile.Actions.Should().ContainKey("freeze_timer"); + profile.Actions.Should().ContainKey("set_unit_cap"); + } } diff --git a/tests/SwfocTrainer.Tests/Profiles/ProfileMetadataPortabilityTests.cs b/tests/SwfocTrainer.Tests/Profiles/ProfileMetadataPortabilityTests.cs new file mode 100644 index 0000000..e17d611 --- /dev/null +++ b/tests/SwfocTrainer.Tests/Profiles/ProfileMetadataPortabilityTests.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; +using FluentAssertions; +using SwfocTrainer.Tests.Common; +using Xunit; + +namespace SwfocTrainer.Tests.Profiles; + +public sealed class ProfileMetadataPortabilityTests +{ + private static readonly Regex UserScopedWindowsSaveRootPattern = new( + "\"saveRootDefault\"\\s*:\\s*\"[A-Za-z]:\\\\\\\\Users\\\\\\\\", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + [Fact] + public void DefaultProfiles_ShouldNotContain_UserSpecific_SaveRootDefaults() + { + var root = TestPaths.FindRepoRoot(); + var profileDirectory = Path.Combine(root, "profiles", "default", "profiles"); + var profileFiles = Directory.GetFiles(profileDirectory, "*.json", SearchOption.TopDirectoryOnly); + + profileFiles.Should().NotBeEmpty(); + + foreach (var profileFile in profileFiles) + { + var content = File.ReadAllText(profileFile); + UserScopedWindowsSaveRootPattern.IsMatch(content).Should().BeFalse( + because: $"profile metadata in '{Path.GetFileName(profileFile)}' should stay portable across user accounts"); + } + } +} diff --git a/tests/SwfocTrainer.Tests/Runtime/BackendRouterTests.cs b/tests/SwfocTrainer.Tests/Runtime/BackendRouterTests.cs index c2fbd17..559d2ea 100644 --- a/tests/SwfocTrainer.Tests/Runtime/BackendRouterTests.cs +++ b/tests/SwfocTrainer.Tests/Runtime/BackendRouterTests.cs @@ -14,10 +14,11 @@ public sealed class BackendRouterTests [InlineData("toggle_ai", ExecutionKind.Memory)] [InlineData("set_unit_cap", ExecutionKind.CodePatch)] [InlineData("toggle_instant_build_patch", ExecutionKind.CodePatch)] - public void Resolve_ShouldPromoteHybridActions_ToExtender_WhenCapabilityIsAvailable( + public void Resolve_ShouldKeepLegacyBackend_ForPromotedActions_WhenOverrideDisabled( string actionId, ExecutionKind executionKind) { + using var _ = PromotedExtenderOverrideScope.Disable(); var router = new BackendRouter(); var request = BuildRequest( actionId, @@ -46,12 +47,18 @@ public void Resolve_ShouldPromoteHybridActions_ToExtender_WhenCapabilityIsAvaila var decision = router.Resolve(request, profile, process, report); decision.Allowed.Should().BeTrue(); - decision.Backend.Should().Be(ExecutionBackendKind.Extender); + decision.Backend.Should().Be(ExecutionBackendKind.Memory); decision.ReasonCode.Should().Be(RuntimeReasonCode.CAPABILITY_PROBE_PASS); decision.Diagnostics.Should().ContainKey("hybridExecution"); decision.Diagnostics!["hybridExecution"].Should().Be(false); decision.Diagnostics.Should().ContainKey("promotedExtenderAction"); - decision.Diagnostics!["promotedExtenderAction"].Should().Be(true); + decision.Diagnostics!["promotedExtenderAction"].Should().Be(false); + decision.Diagnostics.Should().ContainKey("promotedExtenderOverrideEnabled"); + decision.Diagnostics!["promotedExtenderOverrideEnabled"].Should().Be(false); + decision.Diagnostics.Should().ContainKey("promotedExtenderOverrideSource"); + decision.Diagnostics!["promotedExtenderOverrideSource"].Should().Be("default"); + decision.Diagnostics.Should().ContainKey("promotedExtenderApplied"); + decision.Diagnostics!["promotedExtenderApplied"].Should().Be(false); decision.Diagnostics.Should().ContainKey("capabilityMapReasonCode"); decision.Diagnostics!["capabilityMapReasonCode"].Should().Be("CAPABILITY_PROBE_PASS"); decision.Diagnostics.Should().ContainKey("capabilityMapState"); @@ -66,10 +73,11 @@ public void Resolve_ShouldPromoteHybridActions_ToExtender_WhenCapabilityIsAvaila [InlineData("toggle_ai", ExecutionKind.Memory)] [InlineData("set_unit_cap", ExecutionKind.CodePatch)] [InlineData("toggle_instant_build_patch", ExecutionKind.CodePatch)] - public void Resolve_ShouldBlockHybridActions_WhenCapabilityIsMissing( + public void Resolve_ShouldKeepLegacyBackend_ForPromotedActions_WhenCapabilityIsMissing_AndOverrideDisabled( string actionId, ExecutionKind executionKind) { + using var _ = PromotedExtenderOverrideScope.Disable(); var router = new BackendRouter(); var request = BuildRequest(actionId, executionKind); var profile = BuildProfile(backendPreference: "auto"); @@ -78,13 +86,19 @@ public void Resolve_ShouldBlockHybridActions_WhenCapabilityIsMissing( var decision = router.Resolve(request, profile, process, report); - decision.Allowed.Should().BeFalse(); - decision.Backend.Should().Be(ExecutionBackendKind.Extender); - decision.ReasonCode.Should().Be(RuntimeReasonCode.CAPABILITY_REQUIRED_MISSING); + decision.Allowed.Should().BeTrue(); + decision.Backend.Should().Be(ExecutionBackendKind.Memory); + decision.ReasonCode.Should().Be(RuntimeReasonCode.CAPABILITY_PROBE_PASS); decision.Diagnostics.Should().ContainKey("hybridExecution"); decision.Diagnostics!["hybridExecution"].Should().Be(false); decision.Diagnostics.Should().ContainKey("promotedExtenderAction"); - decision.Diagnostics!["promotedExtenderAction"].Should().Be(true); + decision.Diagnostics!["promotedExtenderAction"].Should().Be(false); + decision.Diagnostics.Should().ContainKey("promotedExtenderOverrideEnabled"); + decision.Diagnostics!["promotedExtenderOverrideEnabled"].Should().Be(false); + decision.Diagnostics.Should().ContainKey("promotedExtenderOverrideSource"); + decision.Diagnostics!["promotedExtenderOverrideSource"].Should().Be("default"); + decision.Diagnostics.Should().ContainKey("promotedExtenderApplied"); + decision.Diagnostics!["promotedExtenderApplied"].Should().Be(false); decision.Diagnostics.Should().NotContainKey("fallbackBackend"); } @@ -94,10 +108,11 @@ public void Resolve_ShouldBlockHybridActions_WhenCapabilityIsMissing( [InlineData("toggle_ai", ExecutionKind.Memory)] [InlineData("set_unit_cap", ExecutionKind.CodePatch)] [InlineData("toggle_instant_build_patch", ExecutionKind.CodePatch)] - public void Resolve_ShouldFailClosedForPromotedActions_WhenCapabilityIsUnverified( + public void Resolve_ShouldKeepLegacyBackend_ForPromotedActions_WhenCapabilityIsUnverified_AndOverrideDisabled( string actionId, ExecutionKind executionKind) { + using var _ = PromotedExtenderOverrideScope.Disable(); var router = new BackendRouter(); var request = BuildRequest(actionId, executionKind); var profile = BuildProfile(backendPreference: "auto"); @@ -117,16 +132,130 @@ public void Resolve_ShouldFailClosedForPromotedActions_WhenCapabilityIsUnverifie var decision = router.Resolve(request, profile, process, report); - decision.Allowed.Should().BeFalse(); - decision.Backend.Should().Be(ExecutionBackendKind.Extender); - decision.ReasonCode.Should().Be(RuntimeReasonCode.SAFETY_FAIL_CLOSED); + decision.Allowed.Should().BeTrue(); + decision.Backend.Should().Be(ExecutionBackendKind.Memory); + decision.ReasonCode.Should().Be(RuntimeReasonCode.CAPABILITY_PROBE_PASS); decision.Diagnostics.Should().ContainKey("hybridExecution"); decision.Diagnostics!["hybridExecution"].Should().Be(false); decision.Diagnostics.Should().ContainKey("promotedExtenderAction"); - decision.Diagnostics!["promotedExtenderAction"].Should().Be(true); + decision.Diagnostics!["promotedExtenderAction"].Should().Be(false); + decision.Diagnostics.Should().ContainKey("promotedExtenderOverrideEnabled"); + decision.Diagnostics!["promotedExtenderOverrideEnabled"].Should().Be(false); + decision.Diagnostics.Should().ContainKey("promotedExtenderOverrideSource"); + decision.Diagnostics!["promotedExtenderOverrideSource"].Should().Be("default"); + decision.Diagnostics.Should().ContainKey("promotedExtenderApplied"); + decision.Diagnostics!["promotedExtenderApplied"].Should().Be(false); decision.Diagnostics.Should().NotContainKey("fallbackBackend"); } + [Theory] + [InlineData("freeze_timer", ExecutionKind.Memory)] + [InlineData("toggle_fog_reveal", ExecutionKind.Memory)] + [InlineData("toggle_ai", ExecutionKind.Memory)] + [InlineData("set_unit_cap", ExecutionKind.CodePatch)] + [InlineData("toggle_instant_build_patch", ExecutionKind.CodePatch)] + public void Resolve_ShouldPromoteActions_ToExtender_WhenOverrideEnabled_AndCapabilityIsVerified( + string actionId, + ExecutionKind executionKind) + { + using var _ = PromotedExtenderOverrideScope.Enable(); + var router = new BackendRouter(); + var request = BuildRequest(actionId, executionKind); + var profile = BuildProfile(backendPreference: "auto"); + var process = BuildProcess(); + var report = new CapabilityReport( + profile.Id, + DateTimeOffset.UtcNow, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [actionId] = new BackendCapability( + actionId, + Available: true, + CapabilityConfidenceState.Verified, + RuntimeReasonCode.CAPABILITY_PROBE_PASS) + }, + RuntimeReasonCode.CAPABILITY_PROBE_PASS); + + var decision = router.Resolve(request, profile, process, report); + + decision.Allowed.Should().BeTrue(); + decision.Backend.Should().Be(ExecutionBackendKind.Extender); + decision.ReasonCode.Should().Be(RuntimeReasonCode.CAPABILITY_PROBE_PASS); + decision.Diagnostics.Should().ContainKey("promotedExtenderAction"); + decision.Diagnostics!["promotedExtenderAction"].Should().Be(true); + decision.Diagnostics.Should().ContainKey("promotedExtenderOverrideEnabled"); + decision.Diagnostics!["promotedExtenderOverrideEnabled"].Should().Be(true); + decision.Diagnostics.Should().ContainKey("promotedExtenderOverrideSource"); + decision.Diagnostics!["promotedExtenderOverrideSource"].Should().Be("env"); + decision.Diagnostics.Should().ContainKey("promotedExtenderApplied"); + decision.Diagnostics!["promotedExtenderApplied"].Should().Be(true); + } + + [Theory] + [InlineData("freeze_timer", ExecutionKind.Memory)] + [InlineData("toggle_fog_reveal", ExecutionKind.Memory)] + [InlineData("toggle_ai", ExecutionKind.Memory)] + [InlineData("set_unit_cap", ExecutionKind.CodePatch)] + [InlineData("toggle_instant_build_patch", ExecutionKind.CodePatch)] + public void Resolve_ShouldBlockPromotedActions_WhenOverrideEnabled_AndCapabilityIsMissing( + string actionId, + ExecutionKind executionKind) + { + using var _ = PromotedExtenderOverrideScope.Enable(); + var router = new BackendRouter(); + var request = BuildRequest(actionId, executionKind); + var profile = BuildProfile(backendPreference: "auto"); + var process = BuildProcess(); + var report = CapabilityReport.Unknown(profile.Id, RuntimeReasonCode.CAPABILITY_UNKNOWN); + + var decision = router.Resolve(request, profile, process, report); + + decision.Allowed.Should().BeFalse(); + decision.Backend.Should().Be(ExecutionBackendKind.Extender); + decision.ReasonCode.Should().Be(RuntimeReasonCode.CAPABILITY_REQUIRED_MISSING); + decision.Diagnostics.Should().ContainKey("promotedExtenderAction"); + decision.Diagnostics!["promotedExtenderAction"].Should().Be(true); + decision.Diagnostics.Should().ContainKey("promotedExtenderApplied"); + decision.Diagnostics!["promotedExtenderApplied"].Should().Be(true); + } + + [Theory] + [InlineData("freeze_timer", ExecutionKind.Memory)] + [InlineData("toggle_fog_reveal", ExecutionKind.Memory)] + [InlineData("toggle_ai", ExecutionKind.Memory)] + [InlineData("set_unit_cap", ExecutionKind.CodePatch)] + [InlineData("toggle_instant_build_patch", ExecutionKind.CodePatch)] + public void Resolve_ShouldFailClosedForPromotedActions_WhenOverrideEnabled_AndCapabilityIsUnverified( + string actionId, + ExecutionKind executionKind) + { + using var _ = PromotedExtenderOverrideScope.Enable(); + var router = new BackendRouter(); + var request = BuildRequest(actionId, executionKind); + var profile = BuildProfile(backendPreference: "auto"); + var process = BuildProcess(); + var report = new CapabilityReport( + profile.Id, + DateTimeOffset.UtcNow, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [actionId] = new BackendCapability( + actionId, + Available: true, + Confidence: CapabilityConfidenceState.Experimental, + ReasonCode: RuntimeReasonCode.CAPABILITY_FEATURE_EXPERIMENTAL) + }, + RuntimeReasonCode.CAPABILITY_PROBE_PASS); + + var decision = router.Resolve(request, profile, process, report); + + decision.Allowed.Should().BeFalse(); + decision.Backend.Should().Be(ExecutionBackendKind.Extender); + decision.ReasonCode.Should().Be(RuntimeReasonCode.SAFETY_FAIL_CLOSED); + decision.Diagnostics.Should().ContainKey("promotedExtenderApplied"); + decision.Diagnostics!["promotedExtenderApplied"].Should().Be(true); + } + [Fact] public void Resolve_ShouldFailClosed_WhenExtenderIsRequiredButCapabilityUnknownForMutation() { @@ -367,4 +496,31 @@ private static ProcessMetadata BuildProcess( WorkshopMatchCount: 1, SelectionScore: 1311.0d); } + + private sealed class PromotedExtenderOverrideScope : IDisposable + { + private const string VariableName = "SWFOC_FORCE_PROMOTED_EXTENDER"; + private readonly string? _previousValue; + + private PromotedExtenderOverrideScope(string? value) + { + _previousValue = Environment.GetEnvironmentVariable(VariableName); + Environment.SetEnvironmentVariable(VariableName, value); + } + + public static PromotedExtenderOverrideScope Disable() + { + return new PromotedExtenderOverrideScope(value: null); + } + + public static PromotedExtenderOverrideScope Enable() + { + return new PromotedExtenderOverrideScope(value: "1"); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(VariableName, _previousValue); + } + } }