diff --git a/TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs b/TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs index 63dfb25cd..3c8d3958f 100644 --- a/TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs +++ b/TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs @@ -209,6 +209,88 @@ public void BuildFreshStoresWrapper_WhenLiveStoresAvailable_ReturnsInitializedWr result.Stores.Single().DisplayName.Should().Be("Mailbox"); } + /// + /// A fixed, non-existent AppData-shaped settings path. It is never opened, created or + /// probed: both AC1 tests assert on the configuration value the loader carries, so no + /// filesystem access occurs and no host path appears in the test. + /// + private const string FakeAppDataSettingsPath = + @"X:\FakeAppData\TaskMaster\StoresWrapper.json"; + + [TestMethod] + public async Task LoadStoresAsync_WhenConfigDeserializesToNull_FreshWrapperAdoptsLoaderDiskConfiguration() + { + // Arrange (issue #797, AC1): the "StoresWrapper" key is present and carries a loader + // whose disk configuration holds the resource-defined path, but the deserialize returns + // null because the file is absent. The fresh-build branch must adopt that configuration + // instead of discarding it, so the first Save has a path to write to. + var application = new Mock(); + var configuration = new ConcurrentDictionary(); + var globals = new StubApplicationGlobals(); + var loader = new SmartSerializableLoader(); + loader.Config.Disk.FilePath = FakeAppDataSettingsPath; + configuration.TryAdd("StoresWrapper", loader); + globals.IntelResInstance = new StubIntelligenceConfig(globals, configuration); + var freshWrapper = new StoresWrapper(); + var smartSerializable = new Mock(); + smartSerializable + .Setup(x => + x.Deserialize( + It.IsAny>() + ) + ) + .Returns((StoresWrapper)null); + + var sut = new TestableAppOlObjects( + application.Object, + globals, + _ => Task.CompletedTask, + freshWrapper + ) + { + SmartSerializable = smartSerializable.Object, + }; + + // Act + await sut.LoadStoresAsync(); + + // Assert + sut.StoresWrapper.Should().BeSameAs(freshWrapper); + sut.StoresWrapper.Config.Disk.FilePath.Should() + .Be( + FakeAppDataSettingsPath, + "the fresh-build branch must adopt the loader's disk configuration (AC1)." + ); + } + + [TestMethod] + public async Task LoadStoresAsync_WhenConfigKeyIsAbsent_FreshWrapperKeepsEmptyDiskPath() + { + // Arrange (issue #797, AC1 negative case): with no "StoresWrapper" key there is no + // loader to adopt, so the fresh build must keep the FilePathHelper default empty path. + // That case is deliberately out of AC1's scope and is made visible by AC2's error log. + var application = new Mock(); + var configuration = new ConcurrentDictionary(); + var globals = new StubApplicationGlobals(); + globals.IntelResInstance = new StubIntelligenceConfig(globals, configuration); + var freshWrapper = new StoresWrapper(); + + var sut = new TestableAppOlObjects( + application.Object, + globals, + _ => Task.CompletedTask, + freshWrapper + ); + + // Act + await sut.LoadStoresAsync(); + + // Assert + sut.StoresWrapper.Should().BeSameAs(freshWrapper); + sut.StoresWrapper.Config.Disk.FilePath.Should() + .BeEmpty("there is no loader to adopt on the key-absent branch."); + } + private sealed class TestableAppOlObjects : AppOlObjects { private readonly Func awaitStoreRewireAsync; diff --git a/TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs b/TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs index 68f24318d..d3f14681e 100644 --- a/TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs +++ b/TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs @@ -16,7 +16,7 @@ namespace TaskMaster /// AppOlObjects.cs to bring that file under the 500-line cap. Behavior is unchanged /// (move-only). /// - public partial class AppOlObjects + public partial class AppOlObjects : IJunkFolderSelectionSink { private Folder _junkPotential; public Folder JunkPotential => Initializer.GetOrLoad(ref _junkPotential, LoadJunkPotential); @@ -44,6 +44,18 @@ string junkPotentialRelativePath RefreshJunkFolderSelections(); } + /// + /// Explicit implementation of (issue #797, AC5), + /// forwarding to the existing internal method with the arguments in the certain-then- + /// potential order the interface fixes. The implementation is explicit so the public + /// surface of this type does not widen; the internal method keeps its accessibility and its + /// body. + /// + void IJunkFolderSelectionSink.ApplyJunkFolderSelections( + string junkCertainRelativePath, + string junkPotentialRelativePath + ) => ApplyJunkFolderSelections(junkCertainRelativePath, junkPotentialRelativePath); + internal void RefreshJunkFolderSelections() { _junkCertain = null; diff --git a/TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs b/TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs index fc553dc0d..ce3112b8b 100644 --- a/TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs +++ b/TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs @@ -36,7 +36,11 @@ internal async Task LoadStoresAsync() { try { - if (_globals.IntelRes.Config.TryGetValue("StoresWrapper", out var config)) + var configFound = _globals.IntelRes.Config.TryGetValue( + "StoresWrapper", + out var config + ); + if (configFound) { var deserialized = SmartSerializable.Deserialize< StoresWrapper, @@ -62,6 +66,17 @@ internal async Task LoadStoresAsync() // source from which a previously disabled-for-future-sessions store could be // recovered, so a store re-enabled here is expected, not a regression in F1/F5. StoresWrapper = BuildFreshStoresWrapper(); + + // why: issue #797 AC1. A freshly built wrapper carries the FilePathHelper default + // empty file path, so every later Save returned silently and the settings file was + // never created. The loader resolved above already carries the resource-defined + // path under the local application data TaskMaster directory, so adopt it here with + // a deep copy. The key-absent branch has no loader and deliberately keeps the empty + // path; AC2's error log makes that case visible rather than silent. + if (configFound && StoresWrapper is not null) + { + StoresWrapper.Config.CopyFrom(config.Config, true); + } } catch (Exception e) { diff --git a/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs b/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs index bb86443a8..5d85c3b18 100644 --- a/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs @@ -2,8 +2,13 @@ using System.Collections.Generic; using System.ComponentModel; using System.Drawing; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; +using log4net; +using log4net.Appender; +using log4net.Core; +using log4net.Repository.Hierarchy; using Microsoft.Office.Interop.Outlook; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -123,8 +128,11 @@ public void PopulateWithCurrent_WhenInvokeRequired_DelegatesToViewerInvoke() [TestMethod] public void PersistJunkFolderSelections_WhenApplyMethodIsMissing_DoesNotThrow() { + // Retargeted to the typed seam (issue #797, AC5). What is "missing" is no longer a + // method discoverable by name but an implementation of IJunkFolderSelectionSink: the + // double below declares a matching public method yet does not implement the interface. var globals = new Mock(); - globals.SetupGet(x => x.Ol).Returns(new NoApplyOlObjects()); + globals.SetupGet(x => x.Ol).Returns(new NonSinkOlObjects()); var controller = new StoreWrapperController(globals.Object) { JunkEmail = new FolderMinimalWrapper("Junk", "Inbox\\Junk Email"), @@ -136,6 +144,121 @@ public void PersistJunkFolderSelections_WhenApplyMethodIsMissing_DoesNotThrow() act.Should().NotThrow(); } + [TestMethod] + public void PersistJunkFolderSelections_PassesJunkCertainPathFirst() + { + // Arrange (issue #797, AC5): the argument order is enforced by nothing except + // positional agreement between the call site and the signature, which is exactly the + // fragility the typed seam removes. Pin it with distinguishable values. + var olObjects = new RecordingOlObjects(); + var globals = new Mock(); + globals.SetupGet(x => x.Ol).Returns(olObjects); + var controller = new StoreWrapperController(globals.Object) + { + JunkEmail = new FolderMinimalWrapper("Certain", "Inbox\\Certain Folder"), + JunkPotential = new FolderMinimalWrapper("Potential", "Inbox\\Potential Folder"), + }; + + // Act + controller.PersistJunkFolderSelections(); + + // Assert + olObjects.ApplyCallCount.Should().Be(1); + olObjects + .AppliedJunkCertainPath.Should() + .Be("Inbox\\Certain Folder", "the junk-certain path is supplied first."); + olObjects + .AppliedJunkPotentialPath.Should() + .Be("Inbox\\Potential Folder", "the junk-potential path is supplied second."); + } + + [TestMethod] + public void PersistJunkFolderSelections_WhenGlobalsAreNotTheTypedSink_LogsErrorAndDoesNotInvoke() + { + // Arrange (issue #797, AC5): the failure must be loud. The double declares a public + // method with the historic name and signature but does not implement the sink + // interface, so the reflection lookup would have succeeded while the typed cast fails. + var olObjects = new NonSinkOlObjects(); + var globals = new Mock(); + globals.SetupGet(x => x.Ol).Returns(olObjects); + var controller = new StoreWrapperController(globals.Object) + { + JunkEmail = new FolderMinimalWrapper("Junk", "Inbox\\Junk Email"), + JunkPotential = new FolderMinimalWrapper("Potential", "Inbox\\Junk Potential"), + }; + + var appender = AttachControllerMemoryAppender(out var restore); + try + { + // Act + controller.PersistJunkFolderSelections(); + + // Assert: existence, not an exact count. The controller's logger is a static field + // shared with every other controller test class in this assembly and the run + // settings impose a class-level parallel scope, so a sibling class can only add + // events. The paired assertion that the double recorded no invocation is what + // attributes the event to this test. + SinkErrorEvents(appender) + .Should() + .NotBeEmpty( + "a failed cast to the typed sink must be reported at error level (AC5)." + ); + olObjects + .ApplyCallCount.Should() + .Be(0, "a double that is not the typed sink must never be invoked."); + } + finally + { + restore(); + } + } + + /// + /// Attaches an in-memory appender to the logger the controller writes to. The controller is + /// not generic, so its logger name is the full name of the controller type and the appender + /// can be attached to that named logger in the ordinary way. + /// + /// + /// Receives the action that detaches the appender and restores the logger's previous level + /// and the repository's previous configured flag. + /// + private static MemoryAppender AttachControllerMemoryAppender(out System.Action restore) + { + var appender = new MemoryAppender(); + appender.ActivateOptions(); + + var controllerType = typeof(StoreWrapperController); + var hierarchy = (Hierarchy)LogManager.GetRepository(controllerType.Assembly); + var logger = (Logger)hierarchy.GetLogger(controllerType.FullName); + var previousLevel = logger.Level; + var previousConfigured = hierarchy.Configured; + + logger.Level = Level.Debug; + hierarchy.Configured = true; + logger.AddAppender(appender); + + restore = () => + { + logger.RemoveAppender(appender); + logger.Level = previousLevel; + hierarchy.Configured = previousConfigured; + }; + + return appender; + } + + private static LoggingEvent[] SinkErrorEvents(MemoryAppender appender) + { + return appender + .GetEvents() + .Where(loggingEvent => + loggingEvent.Level >= Level.Error + && loggingEvent.RenderedMessage != null + && loggingEvent.RenderedMessage.Contains(nameof(IJunkFolderSelectionSink)) + ) + .ToArray(); + } + private abstract class OlObjectsStubBase : IOlObjects { public Application App => null!; @@ -193,7 +316,11 @@ string junkPotentialRelativePath } } - private sealed class RecordingOlObjects : OlObjectsStubBase + /// + /// Globals double that implements the typed junk-folder sink (issue #797, AC5) in addition + /// to the globals stub base, and records both arguments and the invocation count. + /// + private sealed class RecordingOlObjects : OlObjectsStubBase, IJunkFolderSelectionSink { public string AppliedJunkCertainPath { get; private set; } = string.Empty; public string AppliedJunkPotentialPath { get; private set; } = string.Empty; @@ -211,6 +338,24 @@ string junkPotentialRelativePath } } - private sealed class NoApplyOlObjects : OlObjectsStubBase { } + /// + /// Globals double that implements only the globals interface while still declaring a public + /// method named ApplyJunkFolderSelections with the same two string parameters (issue + /// #797, AC5). The historic reflection lookup would bind to that method; the typed cast does + /// not, so this double drives the loud-failure branch. + /// + private sealed class NonSinkOlObjects : OlObjectsStubBase + { + public int ApplyCallCount { get; private set; } + + public void ApplyJunkFolderSelections( + string junkCertainRelativePath, + string junkPotentialRelativePath + ) + { + ApplyCallCount++; + SetJunkFolders(junkCertainRelativePath, junkPotentialRelativePath); + } + } } } diff --git a/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs b/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs index e067c5dbc..036caa729 100644 --- a/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs +++ b/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs @@ -127,11 +127,17 @@ public void PopulateWithCurrent_NullCurrent_SetsErrorLoadingText() controller.Current = null; controller.FsConverter = (path) => ("", ""); - // PopulateWithCurrent accesses Current which may be null - // This would throw NullReferenceException, verifying we need Current set + // Declared AC8 expectation inversion under D6 (issue #797): this test's name always + // described the fixed behaviour while its assertion codified the defect. The inverted + // assertion is stricter than the original, pinning the specific rendered values rather + // than merely an exception type. var act = () => controller.PopulateWithCurrent(); - act.Should().Throw(); + act.Should().NotThrow(); + mockViewer.Object.ArchiveOutlook.Text.Should().Be("Please select an archive"); + mockViewer.Object.ArchiveFS.Text.Should().Be("Please select an archive"); + mockViewer.Object.JunkEmail.Text.Should().Be("Please select a folder"); + mockViewer.Object.JunkPotential.Text.Should().Be("Please select a folder"); } [TestMethod] diff --git a/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs b/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs new file mode 100644 index 000000000..0335b09ec --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs @@ -0,0 +1,252 @@ +using System.Runtime.InteropServices; +using FluentAssertions; +using Microsoft.Office.Interop.Outlook; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UtilitiesCS.OutlookObjects.Store; +using OutlookFolder = Microsoft.Office.Interop.Outlook.Folder; +using OutlookRecipient = Microsoft.Office.Interop.Outlook.Recipient; + +namespace UtilitiesCS.Test.OutlookObjects.Store +{ + /// + /// Display-partial behaviour of (issue #797): the AC6 retry + /// of the SMTP lookup on dialog open, the AC7 store-prefix trim on the Inbox and Root Folder + /// labels, and the AC8 guards that render placeholders instead of throwing when no store is + /// selected. Partial of so it reuses the base mock + /// harness. No live Outlook process and no user interface is required, and no file is created. + /// + public partial class StoreWrapperController_Tests + { + #region AC6 — retry on dialog open + + /// + /// Builds a mocked Outlook folder chain whose Exchange primary SMTP read succeeds. + /// + private static Mock CreateDisplaySmtpRootFolder(string primarySmtpAddress) + { + var rootFolder = new Mock(); + var session = new Mock(); + var currentUser = new Mock(); + var addressEntry = new Mock(); + var exchangeUser = new Mock(); + + exchangeUser.SetupGet(x => x.PrimarySmtpAddress).Returns(primarySmtpAddress); + addressEntry.Setup(x => x.GetExchangeUser()).Returns(exchangeUser.Object); + currentUser.SetupGet(x => x.AddressEntry).Returns(addressEntry.Object); + session.SetupGet(x => x.CurrentUser).Returns(currentUser.Object); + rootFolder.SetupGet(x => x.Session).Returns(session.Object); + + return rootFolder; + } + + /// + /// Builds a mocked Outlook folder chain in which every SMTP source fails, so the lookup + /// yields null and captures a failure reason. + /// + private static Mock CreateDisplayFailingSmtpRootFolder(string reason) + { + var rootFolder = new Mock(); + var session = new Mock(); + var currentUser = new Mock(); + var addressEntry = new Mock(); + var exchangeUser = new Mock(); + + exchangeUser.SetupGet(x => x.PrimarySmtpAddress).Throws(new COMException(reason)); + addressEntry.SetupGet(x => x.Address).Returns("/o=EX/cn=Recipients"); + addressEntry.Setup(x => x.GetExchangeUser()).Returns(exchangeUser.Object); + currentUser.SetupGet(x => x.AddressEntry).Returns(addressEntry.Object); + session.SetupGet(x => x.CurrentUser).Returns(currentUser.Object); + rootFolder.SetupGet(x => x.Session).Returns(session.Object); + + return rootFolder; + } + + [TestMethod] + public void PopulateWithCurrent_WhenUserEmailIsNull_RetriesLookupAndRendersAddress() + { + // Arrange (issue #797, AC6): the address is null because the startup lookup failed, so + // opening the dialog must retry it rather than render the generic placeholder forever. + var (controller, _) = CreateControllerWithViewer(); + var rootFolder = CreateDisplaySmtpRootFolder("retried@example.com"); + controller.Current = new StoreWrapper(null) + { + RootFolder = rootFolder.Object, + UserEmailAddress = null, + DisplayName = "Mailbox", + }; + + // Act + controller.PopulateWithCurrent(); + + // Assert + controller.Current.UserEmailAddress.Should().Be("retried@example.com"); + controller.Viewer.UserEmail.Text.Should().Be("retried@example.com"); + } + + [TestMethod] + public void PopulateWithCurrent_WhenUserEmailIsAlreadyPopulated_DoesNotRetryLookup() + { + // Arrange (issue #797, AC6): the retry is attempted at most once per dialog open and + // only when the address is null, which bounds the added UI-thread latency. The mocked + // chain would yield a different address, so an unchanged value proves no retry ran. + var (controller, _) = CreateControllerWithViewer(); + var rootFolder = CreateDisplaySmtpRootFolder("would-have-retried@example.com"); + controller.Current = new StoreWrapper(null) + { + RootFolder = rootFolder.Object, + UserEmailAddress = "already@example.com", + DisplayName = "Mailbox", + }; + + // Act + controller.PopulateWithCurrent(); + + // Assert + controller.Current.UserEmailAddress.Should().Be("already@example.com"); + controller.Viewer.UserEmail.Text.Should().Be("already@example.com"); + } + + [TestMethod] + public void PopulateWithCurrent_WhenRetryFails_RendersSpecificMessageWithReason() + { + // Arrange (issue #797, AC6): on total failure the label must carry a specific message + // including the reason, not the generic placeholder shared with the other two labels. + var (controller, _) = CreateControllerWithViewer(); + var rootFolder = CreateDisplayFailingSmtpRootFolder("The operation failed."); + controller.Current = new StoreWrapper(null) + { + RootFolder = rootFolder.Object, + UserEmailAddress = null, + DisplayName = "Mailbox", + }; + + // Act + controller.PopulateWithCurrent(); + + // Assert + controller.Viewer.UserEmail.Text.Should().Contain("The operation failed."); + controller.Viewer.UserEmail.Text.Should().NotBe("Error Loading"); + } + + #endregion AC6 + + #region AC7 — the leading store prefix is trimmed for display + + [TestMethod] + public void TrimStorePrefix_WithLeadingStorePrefix_RemovesIt() + { + StoreWrapperController + .TrimStorePrefix(@"\\mailbox@example.com\Inbox") + .Should() + .Be(@"mailbox@example.com\Inbox"); + } + + [TestMethod] + public void TrimStorePrefix_WithNoLeadingBackslash_ReturnsInputUnchanged() + { + StoreWrapperController + .TrimStorePrefix(@"mailbox@example.com\Inbox") + .Should() + .Be(@"mailbox@example.com\Inbox"); + } + + [TestMethod] + public void TrimStorePrefix_WithSingleLeadingBackslash_ReturnsInputUnchanged() + { + StoreWrapperController + .TrimStorePrefix(@"\mailbox@example.com\Inbox") + .Should() + .Be(@"\mailbox@example.com\Inbox"); + } + + [TestMethod] + public void TrimStorePrefix_WithEmptyString_ReturnsEmptyString() + { + StoreWrapperController.TrimStorePrefix(string.Empty).Should().BeEmpty(); + } + + [TestMethod] + public void TrimStorePrefix_WithNull_ReturnsNull() + { + StoreWrapperController.TrimStorePrefix(null).Should().BeNull(); + } + + [TestMethod] + public void TrimStorePrefix_WithOnlyTheStorePrefix_ReturnsEmptyString() + { + StoreWrapperController.TrimStorePrefix(@"\\").Should().BeEmpty(); + } + + [TestMethod] + public void PopulateWithCurrent_RendersInboxAndRootFolderWithoutStorePrefix() + { + // Arrange (issue #797, AC7): MAPIFolder.FolderPath is Outlook's native form and is read + // straight into the label today, so the store prefix is visible to the user. + var (controller, _) = CreateControllerWithViewer(); + var inbox = new Mock(); + var rootFolder = new Mock(); + inbox.SetupGet(x => x.FolderPath).Returns(@"\\mailbox@example.com\Inbox"); + rootFolder.SetupGet(x => x.FolderPath).Returns(@"\\mailbox@example.com"); + controller.Current = new StoreWrapper(null) + { + Inbox = inbox.Object, + RootFolder = rootFolder.Object, + UserEmailAddress = "user@example.com", + DisplayName = "Mailbox", + }; + + // Act + controller.PopulateWithCurrent(); + + // Assert + controller.Viewer.Inbox.Text.Should().Be(@"mailbox@example.com\Inbox"); + controller.Viewer.RootFolder.Text.Should().Be("mailbox@example.com"); + } + + #endregion AC7 + + #region AC8 — a null current store renders placeholders instead of throwing + + [TestMethod] + public void PopulateWithCurrent_WithNullCurrent_RendersPlaceholdersAndDoesNotThrow() + { + // Arrange (issue #797, AC8): List.Find returns null when no store matches, and the four + // dereferences at the top of the method threw before any placeholder could render. + var (controller, _) = CreateControllerWithViewer(); + controller.Current = null; + controller.FsConverter = _ => (string.Empty, string.Empty); + + // Act + var act = () => controller.PopulateWithCurrent(); + + // Assert + act.Should().NotThrow(); + controller.Viewer.Inbox.Text.Should().Be("Error Loading"); + controller.Viewer.RootFolder.Text.Should().Be("Error Loading"); + controller.Viewer.ArchiveOutlook.Text.Should().Be("Please select an archive"); + controller.Viewer.ArchiveFS.Text.Should().Be("Please select an archive"); + controller.Viewer.JunkEmail.Text.Should().Be("Please select a folder"); + controller.Viewer.JunkPotential.Text.Should().Be("Please select a folder"); + } + + [TestMethod] + public void GetRelativeFsPath_WithNullCurrent_ReturnsPlaceholderAndDoesNotThrow() + { + // Arrange (issue #797, AC8): the same unguarded dereference exists in the relative-path + // helper, which the populate path calls. + var controller = CreateController(); + controller.Current = null; + controller.FsConverter = _ => (string.Empty, string.Empty); + + // Act + var act = () => controller.GetRelativeFsPath(); + + // Assert + act.Should().NotThrow(); + controller.GetRelativeFsPath().Should().Be("Please select an archive"); + } + + #endregion AC8 + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs b/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs index 6a3819ede..3f5ec09ef 100644 --- a/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs @@ -136,6 +136,137 @@ string primarySmtpAddress return rootFolder; } + /// + /// Builds the same mocked Outlook folder chain as + /// , except that the Exchange primary + /// SMTP read throws a COM exception and the address entry's own address is supplied by the + /// caller. Used by the AC6 fallback-ordering cases (issue #797). + /// + private static Mock CreateRootFolderWithFailingPrimarySmtpAddress( + string addressEntryAddress + ) + { + var rootFolder = new Mock(); + var session = new Mock(); + var currentUser = new Mock(); + var addressEntry = new Mock(); + var exchangeUser = new Mock(); + + exchangeUser + .SetupGet(x => x.PrimarySmtpAddress) + .Throws(new COMException("The operation failed.")); + addressEntry.SetupGet(x => x.Address).Returns(addressEntryAddress); + addressEntry.Setup(x => x.GetExchangeUser()).Returns(exchangeUser.Object); + currentUser.SetupGet(x => x.AddressEntry).Returns(addressEntry.Object); + session.SetupGet(x => x.CurrentUser).Returns(currentUser.Object); + rootFolder.SetupGet(x => x.Session).Returns(session.Object); + + return rootFolder; + } + + [TestMethod] + public void GetSmtpAddressFromStore_WhenPrimarySmtpAddressIsPresent_ReturnsIt() + { + // Arrange (issue #797, AC6 case 1): the first source in the fallback order succeeds. + var store = new Mock(); + var rootFolder = CreateRootFolderWithPrimarySmtpAddress("primary@example.com"); + var wrapper = new StoreWrapper(store.Object) + { + RootFolder = rootFolder.Object, + DisplayName = "Mailbox", + }; + + // Act + var result = wrapper.GetSmtpAddressFromStore(); + + // Assert + result.Should().Be("primary@example.com"); + wrapper + .LastSmtpLookupError.Should() + .BeNull("a successful lookup clears the captured reason."); + } + + [TestMethod] + public void GetSmtpAddressFromStore_WhenPrimarySmtpThrows_FallsBackToAddressEntryAddress() + { + // Arrange (issue #797, AC6 case 2): the Exchange read fails and the address entry's own + // address contains an at-sign, so it is used. + var store = new Mock(); + var rootFolder = CreateRootFolderWithFailingPrimarySmtpAddress("entry@example.com"); + var wrapper = new StoreWrapper(store.Object) + { + RootFolder = rootFolder.Object, + DisplayName = "Mailbox", + }; + + // Act + var result = wrapper.GetSmtpAddressFromStore(); + + // Assert + result.Should().Be("entry@example.com"); + } + + [TestMethod] + public void GetSmtpAddressFromStore_WhenPrimaryAndAddressEntryFail_FallsBackToDisplayName() + { + // Arrange (issue #797, AC6 case 3): the Exchange read fails and the address entry's + // address carries no at-sign, so the store display name is used because it does. + var store = new Mock(); + var rootFolder = CreateRootFolderWithFailingPrimarySmtpAddress("/o=EX/cn=Recipients"); + var wrapper = new StoreWrapper(store.Object) + { + RootFolder = rootFolder.Object, + DisplayName = "display@example.com", + }; + + // Act + var result = wrapper.GetSmtpAddressFromStore(); + + // Assert + result.Should().Be("display@example.com"); + } + + [TestMethod] + public void GetSmtpAddressFromStore_WhenEveryFallbackFails_ReturnsNullAndCapturesReason() + { + // Arrange (issue #797, AC6 case 4): no source yields an at-sign-bearing value. + var store = new Mock(); + var rootFolder = CreateRootFolderWithFailingPrimarySmtpAddress("/o=EX/cn=Recipients"); + var wrapper = new StoreWrapper(store.Object) + { + RootFolder = rootFolder.Object, + DisplayName = "Mailbox", + }; + + // Act + var result = wrapper.GetSmtpAddressFromStore(); + + // Assert + result.Should().BeNull(); + wrapper + .LastSmtpLookupError.Should() + .NotBeNullOrEmpty( + "the dialog renders the captured reason in place of the generic placeholder." + ); + } + + [TestMethod] + public void RefreshUserEmailAddress_WhenRootFolderIsNull_ReturnsNullAndDoesNotThrow() + { + // Arrange (issue #797, AC6): the dialog calls the retry entry point on whichever store + // is selected, which may have no root folder. + var store = new Mock(); + var wrapper = new StoreWrapper(store.Object) { RootFolder = null }; + + // Act + var act = () => wrapper.RefreshUserEmailAddress(); + + // Assert + act.Should().NotThrow(); + wrapper.RefreshUserEmailAddress().Should().BeNull(); + wrapper.UserEmailAddress.Should().BeNull(); + } + [TestMethod] public void TryRestore_WhenRestoreSucceeds_ShouldReturnTrue() { diff --git a/UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs b/UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs new file mode 100644 index 000000000..1eee4b6e0 --- /dev/null +++ b/UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs @@ -0,0 +1,352 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using log4net; +using log4net.Appender; +using log4net.Core; +using log4net.Repository.Hierarchy; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using UtilitiesCS.Interfaces; +using UtilitiesCS.ReusableTypeClasses; +using UtilitiesCS.Test.TestHelpers; + +namespace UtilitiesCS.Test.ReusableTypeClasses +{ + /// + /// Guard and flush behaviour of (issue #797, AC2 and AC4). + /// AC2: the empty-or-null disk path must log an error rather than return silently. AC4: an + /// explicit save must write synchronously, without waiting for the deferred three-second timer, + /// while the deferred path for every other caller is unchanged. + /// + /// This file declares its own harness and its own probe item type because the established + /// harness is a private nested class inside SmartSerializable_Tests.cs and is not reachable from + /// another file. No temporary file is created: writes are captured through the injectable + /// stream-writer seam into a MemoryStream, and the timer is a deterministic manual-fire double. + /// + [TestClass] + [DoNotParallelize] + public class SmartSerializableSerializeGuardTests + { + /// + /// A fixed, non-existent path. It is never opened because every write in this file goes + /// through the injected stream-writer seam. + /// + private const string FakeSettingsPath = @"X:\FakeAppData\TaskMaster\GuardProbe.json"; + + #region AC2 — the empty-or-null path guard logs an error and arms no timer + + [TestMethod] + public void Serialize_WithEmptyDiskPath_LogsErrorAndArmsNoTimer() + { + // Arrange + var parent = new SerializeGuardProbeItem { Name = "empty-path" }; + var harness = new SerializeGuardHarness(parent); + using var timerStub = new ManualFireTimerWrapper(); + var timerFactoryCallCount = 0; + harness.SetTimerFactory(_ => + { + timerFactoryCallCount++; + return timerStub; + }); + harness.Config.Disk.FilePath = string.Empty; + + var appender = AttachRootMemoryAppender(out var restore); + try + { + // Act + harness.Serialize(); + + // Assert + ProbeErrorEvents(appender) + .Should() + .NotBeEmpty( + "an empty Config.Disk.FilePath must be reported at error level, not " + + "swallowed by a silent return (AC2)." + ); + timerFactoryCallCount.Should().Be(0, "the rejecting path arms no timer."); + timerStub.Started.Should().BeFalse(); + } + finally + { + restore(); + } + } + + [TestMethod] + public void Serialize_WithNullDiskPath_LogsErrorAndArmsNoTimer() + { + // Arrange: the pre-fix guard compared only against the empty string, so a null path + // passed it and reached the write path. FilePathHelper can assign a null file path in + // its property-changed handler, so this case is genuinely reachable. + var parent = new SerializeGuardProbeItem { Name = "null-path" }; + var harness = new SerializeGuardHarness(parent); + using var timerStub = new ManualFireTimerWrapper(); + var timerFactoryCallCount = 0; + harness.SetTimerFactory(_ => + { + timerFactoryCallCount++; + return timerStub; + }); + harness.Config.Disk.FilePath = null; + + var appender = AttachRootMemoryAppender(out var restore); + try + { + // Act + harness.Serialize(); + + // Assert + ProbeErrorEvents(appender) + .Should() + .NotBeEmpty( + "a null Config.Disk.FilePath must be reported at error level (AC2)." + ); + timerFactoryCallCount.Should().Be(0, "the rejecting path arms no timer."); + timerStub.Started.Should().BeFalse(); + } + finally + { + restore(); + } + } + + #endregion AC2 + + #region AC4 — the explicit save flushes synchronously; the deferred path is unchanged + + [TestMethod] + public void SerializeNow_WithConfiguredPath_WritesWithoutFiringTimer() + { + // Arrange + var parent = new SerializeGuardProbeItem { Name = "explicit-save" }; + var harness = new SerializeGuardHarness(parent); + using var timerStub = new ManualFireTimerWrapper(); + var writerCallCount = 0; + harness.SetTimerFactory(_ => timerStub); + harness.SetCreateStreamWriter(_ => + { + writerCallCount++; + return new StreamWriter(new MemoryStream(), Encoding.UTF8, 1024, leaveOpen: false); + }); + harness.Config.Disk.FilePath = FakeSettingsPath; + + // Act + harness.SerializeNow(); + + // Assert + writerCallCount + .Should() + .Be( + 1, + "the explicit save must write inline so a save is not lost when the host " + + "process exits inside the deferred window (AC4)." + ); + timerStub + .Started.Should() + .BeFalse("the explicit save does not arm the deferred timer."); + timerStub.FireCount.Should().Be(0); + } + + [TestMethod] + public void Serialize_WithConfiguredPath_StillRequiresTimerFireToWrite() + { + // Arrange: pins the unchanged behaviour of the deferred path for every other caller. + var parent = new SerializeGuardProbeItem { Name = "deferred-save" }; + var harness = new SerializeGuardHarness(parent); + using var timerStub = new ManualFireTimerWrapper(); + var writerCallCount = 0; + harness.SetTimerFactory(_ => timerStub); + harness.SetCreateStreamWriter(_ => + { + writerCallCount++; + return new StreamWriter(new MemoryStream(), Encoding.UTF8, 1024, leaveOpen: false); + }); + harness.Config.Disk.FilePath = FakeSettingsPath; + + // Act + harness.Serialize(); + + // Assert: nothing is written until the timer fires. + timerStub.Started.Should().BeTrue("the deferred path arms the single-shot timer."); + writerCallCount.Should().Be(0, "the deferred path defers the write."); + + timerStub.FireElapsed(); + + writerCallCount.Should().Be(1, "firing the timer performs the deferred write."); + } + + #endregion AC4 + + #region Log capture helpers + + /// + /// Attaches an in-memory appender to the root logger of the repository that owns + /// . + /// + /// The serializer initialises its logger from the declaring type reported by reflection over + /// a member of a generic type, which resolves to the generic type definition rather than to + /// any closed constructed type. One logger therefore serves every instantiation and its name + /// carries no type argument, so an appender attached to the full name of a closed + /// constructed serializer type would be a different logger and would capture nothing. + /// Attaching to the root logger is correct under either resolution. + /// + /// + /// Receives the action that detaches the appender and restores the root logger's previous + /// level and the repository's previous configured flag. Attaching to the root logger and + /// marking the repository configured are process-wide mutations that must not outlive the + /// test. + /// + /// The attached appender. + private static MemoryAppender AttachRootMemoryAppender(out Action restore) + { + var appender = new MemoryAppender(); + appender.ActivateOptions(); + + var repositoryAssembly = typeof(SmartSerializable).Assembly; + var hierarchy = (Hierarchy)LogManager.GetRepository(repositoryAssembly); + var root = hierarchy.Root; + var previousLevel = root.Level; + var previousConfigured = hierarchy.Configured; + + root.Level = Level.Debug; + hierarchy.Configured = true; + root.AddAppender(appender); + + restore = () => + { + root.RemoveAppender(appender); + root.Level = previousLevel; + hierarchy.Configured = previousConfigured; + }; + + return appender; + } + + /// + /// Selects the captured error-level events whose rendered message names this file's own + /// probe item type. + /// + /// The assertion is existence rather than an exact count. The run settings this plan uses + /// impose a class-level parallel scope, so a concurrently running class can only add events. + /// The probe item type name occurs nowhere else in this test project, so no concurrent class + /// can contribute a matching event. + /// + private static LoggingEvent[] ProbeErrorEvents(MemoryAppender appender) + { + return appender + .GetEvents() + .Where(loggingEvent => + loggingEvent.Level >= Level.Error + && loggingEvent.RenderedMessage != null + && loggingEvent.RenderedMessage.Contains(nameof(SerializeGuardProbeItem)) + ) + .ToArray(); + } + + #endregion Log capture helpers + + #region Harness and probe type + + /// + /// Exposes the protected stream-writer and timer-factory seams so the writes and the + /// deferred timer can be driven deterministically without touching disk or the clock. + /// + private sealed class SerializeGuardHarness : SmartSerializable + { + public SerializeGuardHarness(SerializeGuardProbeItem parent) + : base(parent) { } + + public void SetCreateStreamWriter(Func createStreamWriter) => + CreateStreamWriter = createStreamWriter; + + public void SetTimerFactory(Func timerFactory) => + TimerFactory = timerFactory; + } + + /// + /// Minimal implementation used only by this file. Its + /// name occurs nowhere else in this test project, so it uniquely identifies the log events + /// this file asserts on. + /// + private sealed class SerializeGuardProbeItem : ISmartSerializable + { + public SerializeGuardProbeItem() + { + Config = new NewSmartSerializableConfig(); + } + + public NewSmartSerializableConfig Config { get; set; } + + public string Name { get; set; } + + // Required by ISmartSerializable : INotifyPropertyChanged. This probe never raises + // it, so CS0067 fires; the interface makes deletion impossible, so the suppression is + // scoped to the single member. +#pragma warning disable CS0067 + public event PropertyChangedEventHandler PropertyChanged; +#pragma warning restore CS0067 + + public SerializeGuardProbeItem Deserialize(string fileName, string folderPath) => new(); + + public SerializeGuardProbeItem Deserialize( + string fileName, + string folderPath, + bool askUserOnError + ) => new(); + + public SerializeGuardProbeItem Deserialize( + string fileName, + string folderPath, + bool askUserOnError, + JsonSerializerSettings settings + ) => new(); + + public SerializeGuardProbeItem Deserialize(SmartSerializable loader) + where U : class, ISmartSerializable, new() => new(); + + public SerializeGuardProbeItem Deserialize( + SmartSerializable loader, + bool askUserOnError, + Func altLoader + ) + where U : class, ISmartSerializable, new() => altLoader?.Invoke() ?? new(); + + public Task DeserializeAsync(SmartSerializable config) + where U : class, ISmartSerializable, new() => + Task.FromResult(new SerializeGuardProbeItem()); + + public Task DeserializeAsync( + SmartSerializable config, + bool askUserOnError + ) + where U : class, ISmartSerializable, new() => + Task.FromResult(new SerializeGuardProbeItem()); + + public Task DeserializeAsync( + SmartSerializable config, + bool askUserOnError, + Func altLoader + ) + where U : class, ISmartSerializable, new() => + Task.FromResult(altLoader?.Invoke() ?? new SerializeGuardProbeItem()); + + public SerializeGuardProbeItem DeserializeObject( + string json, + JsonSerializerSettings settings + ) => JsonConvert.DeserializeObject(json, settings); + + public void Serialize() { } + + public void Serialize(string filePath) { } + + public void SerializeThreadSafe(string filePath) { } + } + + #endregion Harness and probe type + } +} diff --git a/UtilitiesCS.Test/UtilitiesCS.Test.csproj b/UtilitiesCS.Test/UtilitiesCS.Test.csproj index 9702d6a98..79ec432e2 100644 --- a/UtilitiesCS.Test/UtilitiesCS.Test.csproj +++ b/UtilitiesCS.Test/UtilitiesCS.Test.csproj @@ -474,6 +474,7 @@ + @@ -533,6 +534,7 @@ + diff --git a/UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs b/UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs new file mode 100644 index 000000000..adf0a0873 --- /dev/null +++ b/UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs @@ -0,0 +1,29 @@ +namespace UtilitiesCS +{ + /// + /// Typed seam through which the store settings dialog persists the user's junk-folder + /// selections (issue #797, AC5). It replaces a reflection lookup for a method named + /// ApplyJunkFolderSelections, which bound the call site to the implementation by name + /// only and therefore failed silently after a rename. Declared in UtilitiesCS and implemented in + /// TaskMaster, so the one-way TaskMaster to UtilitiesCS project reference direction is + /// preserved. The member is deliberately not added to , which would + /// force every existing implementer and test stub to change. + /// + public interface IJunkFolderSelectionSink + { + /// + /// Persists the two junk-folder selections. The parameter order is fixed and is part of the + /// contract: the junk-certain path is supplied first and the junk-potential path second. + /// + /// + /// The store-relative path of the junk-certain folder. Supplied first. + /// + /// + /// The store-relative path of the junk-potential folder. Supplied second. + /// + void ApplyJunkFolderSelections( + string junkCertainRelativePath, + string junkPotentialRelativePath + ); + } +} diff --git a/UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs b/UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs index ef4dcfa91..3967edadc 100644 --- a/UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs +++ b/UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs @@ -176,8 +176,43 @@ public void RestoreGlobalAddresses(Application olApp) [JsonIgnore] public List? GlobalAddressBook { get; internal set; } + /// + /// The reason the most recent SMTP lookup failed, or null when the last lookup succeeded + /// (issue #797, AC6). Not persisted: it describes one runtime lookup, not stored state. + /// + [JsonIgnore] + internal string? LastSmtpLookupError { get; private set; } + + /// + /// Re-runs the SMTP lookup and republishes the result on + /// (issue #797, AC6). Safe to call when + /// is null. + /// + /// The resolved address, or null when every source failed. + internal string? RefreshUserEmailAddress() + { + // why: issue #797 AC6. The lookup ran once per Init and was never retried, and the + // resolved address carries JsonIgnore so a success is not cached across restarts. The + // settings dialog calls this at most once per open, and only when the address is null, + // which bounds the added UI-thread latency to the single lookup startup already + // performs. Safe when RootFolder is null: the chain's first read is null-conditional, + // so the call yields null and records a reason rather than throwing. + UserEmailAddress = GetSmtpAddressFromStore(); + return UserEmailAddress; + } + internal string? GetSmtpAddressFromStore() { + // why: issue #797 AC6. A single outer catch converted every COM failure into null, with + // no fallback source, no captured reason and no retry, so the settings dialog rendered a + // generic placeholder on every start. Each step below carries its own COM handling, in + // the order the specification fixes: the Exchange primary SMTP address; then the address + // entry's own address when it contains an at-sign; then the store display name when it + // contains an at-sign; then null. This mirrors the ordering the application globals + // helper already implements for an address entry. + string? capturedError = null; + AddressEntry? addressEntry = null; + try { var currentUserStopwatch = Stopwatch.StartNew(); @@ -187,7 +222,7 @@ public void RestoreGlobalAddresses(Application olApp) ); var addressEntryStopwatch = Stopwatch.StartNew(); - var addressEntry = currentUser?.AddressEntry; + addressEntry = currentUser?.AddressEntry; logger.Debug( $"[Startup timing] GetSmtpAddressFromStore '{DisplayName ?? ""}' AddressEntry: {addressEntryStopwatch.ElapsedMilliseconds} ms" ); @@ -204,16 +239,50 @@ public void RestoreGlobalAddresses(Application olApp) $"[Startup timing] GetSmtpAddressFromStore '{DisplayName ?? ""}' PrimarySmtpAddress: {primarySmtpAddressStopwatch.ElapsedMilliseconds} ms (result={primarySmtpAddress ?? ""})" ); - return primarySmtpAddress; + if (!string.IsNullOrEmpty(primarySmtpAddress)) + { + LastSmtpLookupError = null; + return primarySmtpAddress; + } } catch (COMException e) { + capturedError = e.Message; logger.Error( $"Error retrieving PrimarySmtpAddress from secondary inbox. {e.Message}", e ); - return null; } + + try + { + var address = addressEntry?.Address; + if (address is not null && address.Contains("@")) + { + LastSmtpLookupError = null; + return address; + } + } + catch (COMException e) + { + capturedError = e.Message; + logger.Error( + $"Error retrieving the address entry address for '{DisplayName ?? ""}'. {e.Message}", + e + ); + } + + var displayName = DisplayName; + if (displayName is not null && displayName.Contains("@")) + { + LastSmtpLookupError = null; + return displayName; + } + + LastSmtpLookupError = + capturedError + ?? "No Exchange address, address entry address or store display name yielded an SMTP address."; + return null; } #endregion Store Properties diff --git a/UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs b/UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs new file mode 100644 index 000000000..221240192 --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs @@ -0,0 +1,173 @@ +#nullable enable +using System; +using System.Linq; + +namespace UtilitiesCS.OutlookObjects.Store +{ + /// + /// Display partial of (issue #797, D4 file-size relief). + /// The rendering members — , + /// and — were relocated + /// verbatim out of StoreWrapperController.cs, which stood at 478 lines against the + /// 500-line cap while four acceptance criteria landed in it. Follows the documented + /// AppOlObjects.JunkFolders.cs partial precedent. + /// + public partial class StoreWrapperController + { + internal void PopulateWithCurrent() + { + if (Viewer.InvokeRequired) + { + Viewer.Invoke(() => PopulateWithCurrent()); + return; + } + + // Mirror the current store into the controller before rendering labels. + // why: issue #797 AC8. The store selection is produced by a list search that returns + // null when no store matches, and these four dereferences were unguarded while the very + // next block already used the null-conditional form, so a null selection threw before + // any placeholder could render. The four are now null-conditional, matching that block. + ArchiveOutlook = Current?.ArchiveRoot; + ArchiveFS = Current?.ArchiveFsRoot; + JunkEmail = Current?.JunkCertain; + JunkPotential = Current?.JunkPotential; + + // why: issue #797 AC6. The SMTP lookup runs once per store initialisation and is never + // retried, and a successful result is not persisted, so one transient COM failure at + // startup left the label showing a generic placeholder for the rest of the session. + // Retry here, at most once per dialog open and only when the address is null, which + // bounds the added UI-thread latency to the single lookup startup already performs. + // Every dereference on this path is null-conditional, so a null current store cannot + // throw here. + if (Current is not null && Current.UserEmailAddress is null) + { + Current.RefreshUserEmailAddress(); + } + + // Populate Form + Viewer.Inbox.Text = TrimStorePrefix(Current?.Inbox?.FolderPath) ?? "Error Loading"; + Viewer.RootFolder.Text = + TrimStorePrefix(Current?.RootFolder?.FolderPath) ?? "Error Loading"; + Viewer.UserEmail.Text = Current?.UserEmailAddress ?? BuildUserEmailUnavailableText(); + Viewer.ArchiveOutlook.Text = ArchiveOutlook?.RelativePath ?? "Please select an archive"; + Viewer.ArchiveFS.Text = GetRelativeFsPath(); + //if (Current.ArchiveFsRoot is not null && !Current.ArchiveFsRoot.FolderPath.IsNullOrEmpty()) + //{ + // var (specialFolder, relativePath) = FsConverter(Current.ArchiveFsRoot.FolderPath); + // if (specialFolder.IsNullOrEmpty() & relativePath.IsNullOrEmpty()) + // { + // Viewer.ArchiveFS.Text = "Please select an archive"; + // } + // else + // { + // Viewer.ArchiveFS.Text = $"{string.Join(" -> ", [specialFolder,relativePath]).Trim()}"; + // } + //} + Viewer.JunkEmail.Text = JunkEmail?.RelativePath ?? "Please select a folder"; + Viewer.JunkPotential.Text = JunkPotential?.RelativePath ?? "Please select a folder"; + BindExcludeStoreCheckbox(); + } + + /// + /// Builds the text shown in place of the mailbox SMTP address when no source yielded one + /// (issue #797, AC6). The message is specific to this failure and, when a reason was + /// captured, names it, replacing the generic placeholder the label shared with the Inbox and + /// Root Folder labels. + /// + private string BuildUserEmailUnavailableText() + { + var reason = Current?.LastSmtpLookupError; + if (string.IsNullOrEmpty(reason)) + { + return "Email address unavailable"; + } + + return $"Email address unavailable: {reason}"; + } + + /// + /// Binds the ExcludeStore checkbox to the current store's membership in + /// Model.ExcludedStoreIds (issue #328, OrdinalIgnoreCase). When the current store's + /// StoreID is unreadable the checkbox is disabled and cleared (fail-safe per AC10) so it can + /// neither mislead the user nor mutate the exclusion set. + /// + internal void BindExcludeStoreCheckbox() + { + // Defensive: a viewer that does not expose the checkbox (e.g., a partial test double) + // has nothing to bind. Production viewers always supply it. + var excludeStore = Viewer?.ExcludeStore; + if (excludeStore is null) + { + return; + } + + var storeId = Current?.StoreId; + if (string.IsNullOrWhiteSpace(storeId)) + { + excludeStore.Enabled = false; + excludeStore.Checked = false; + return; + } + + excludeStore.Enabled = true; + excludeStore.Checked = + Model?.ExcludedStoreIds?.Any(id => + string.Equals(id, storeId, StringComparison.OrdinalIgnoreCase) + ) + ?? false; + } + + internal string GetRelativeFsPath() + { + // why: issue #797 AC8. This dereference of the current store was unguarded and threw + // when no store matched the selection, on a path the populate method calls, so the + // placeholder could never render. Null-conditional here returns the same placeholder + // the method already returns for an unset archive root. + if ( + Current?.ArchiveFsRoot is not null + && !Current.ArchiveFsRoot.FolderPath.IsNullOrEmpty() + ) + { + var (specialFolder, relativePath) = FsConverter(Current.ArchiveFsRoot.FolderPath); + + // why: issue #797. Readability and consistency only. Both operands call a + // null-tolerant string extension and neither has a side effect, so replacing the + // single ampersand with the short-circuit form is behaviourally inert. It does not + // repair a fault. + if (specialFolder.IsNullOrEmpty() && relativePath.IsNullOrEmpty()) + { + return "Please select an archive"; + } + else + { + return $"{string.Join(" -> ", [specialFolder, relativePath]).Trim()}"; + } + } + return "Please select an archive"; + } + + /// + /// Removes the leading store-prefix backslash pair from an Outlook folder path so the + /// settings dialog can render the path without it (issue #797, AC7). Every other input, + /// including a null reference and the empty string, is returned unchanged. + /// + /// The folder path to trim. May be null. + /// The path without its leading store prefix, or the input unchanged. + internal static string? TrimStorePrefix(string? folderPath) + { + // why: issue #797 AC7. MAPIFolder.FolderPath is Outlook's native form and carries a + // leading pair of backslash characters naming the store, which is read straight into + // the label. This is a display-only trim, so every other input is returned unchanged, + // including a null reference and the empty string. Declared internal rather than + // private so the pure-function cases are reachable from UtilitiesCS.Test, to which this + // assembly already grants InternalsVisibleTo; internal does not widen the public + // surface of the controller. + if (folderPath is null || !folderPath.StartsWith(@"\\", StringComparison.Ordinal)) + { + return folderPath; + } + + return folderPath.Substring(2); + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs b/UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs index e6bc31864..feafbb1ed 100644 --- a/UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs +++ b/UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Reflection; using System.Text; using System.Threading.Tasks; using System.Windows.Forms; @@ -64,7 +63,7 @@ internal static StoreLaunchReadiness Ready( ) => new(StoreLaunchReadinessState.Ready, model, displayNames); } - public class StoreWrapperController + public partial class StoreWrapperController { internal static bool RunFolderSelectionDialog(Func selector) { @@ -276,75 +275,6 @@ internal bool PairwiseEquals(T a, T b) return a.Equals(b); } - internal void PopulateWithCurrent() - { - if (Viewer.InvokeRequired) - { - Viewer.Invoke(() => PopulateWithCurrent()); - return; - } - - // Mirror the current store into the controller before rendering labels. - ArchiveOutlook = Current.ArchiveRoot; - ArchiveFS = Current.ArchiveFsRoot; - JunkEmail = Current.JunkCertain; - JunkPotential = Current.JunkPotential; - - // Populate Form - Viewer.Inbox.Text = Current?.Inbox?.FolderPath ?? "Error Loading"; - Viewer.RootFolder.Text = Current?.RootFolder?.FolderPath ?? "Error Loading"; - Viewer.UserEmail.Text = Current?.UserEmailAddress ?? "Error Loading"; - Viewer.ArchiveOutlook.Text = ArchiveOutlook?.RelativePath ?? "Please select an archive"; - Viewer.ArchiveFS.Text = GetRelativeFsPath(); - //if (Current.ArchiveFsRoot is not null && !Current.ArchiveFsRoot.FolderPath.IsNullOrEmpty()) - //{ - // var (specialFolder, relativePath) = FsConverter(Current.ArchiveFsRoot.FolderPath); - // if (specialFolder.IsNullOrEmpty() & relativePath.IsNullOrEmpty()) - // { - // Viewer.ArchiveFS.Text = "Please select an archive"; - // } - // else - // { - // Viewer.ArchiveFS.Text = $"{string.Join(" -> ", [specialFolder,relativePath]).Trim()}"; - // } - //} - Viewer.JunkEmail.Text = JunkEmail?.RelativePath ?? "Please select a folder"; - Viewer.JunkPotential.Text = JunkPotential?.RelativePath ?? "Please select a folder"; - BindExcludeStoreCheckbox(); - } - - /// - /// Binds the ExcludeStore checkbox to the current store's membership in - /// Model.ExcludedStoreIds (issue #328, OrdinalIgnoreCase). When the current store's - /// StoreID is unreadable the checkbox is disabled and cleared (fail-safe per AC10) so it can - /// neither mislead the user nor mutate the exclusion set. - /// - internal void BindExcludeStoreCheckbox() - { - // Defensive: a viewer that does not expose the checkbox (e.g., a partial test double) - // has nothing to bind. Production viewers always supply it. - var excludeStore = Viewer?.ExcludeStore; - if (excludeStore is null) - { - return; - } - - var storeId = Current?.StoreId; - if (string.IsNullOrWhiteSpace(storeId)) - { - excludeStore.Enabled = false; - excludeStore.Checked = false; - return; - } - - excludeStore.Enabled = true; - excludeStore.Checked = - Model?.ExcludedStoreIds?.Any(id => - string.Equals(id, storeId, StringComparison.OrdinalIgnoreCase) - ) - ?? false; - } - internal void SaveChanges() { Current.ArchiveRoot = ArchiveOutlook; @@ -353,7 +283,11 @@ internal void SaveChanges() Current.ArchiveFsRoot = ArchiveFS; PersistJunkFolderSelections(); ApplyExcludeStoreSelection(); - Model.Serialize(); + + // why: issue #797 AC4. An explicit Save must not be lost if the host process exits + // inside the serializer's three-second deferred-write window, so this call site uses the + // guarded synchronous flush. Every other caller keeps the deferred behaviour. + Model.SerializeNow(); } /// @@ -396,25 +330,21 @@ internal void PersistJunkFolderSelections() return; } - var applyMethod = olObjects - .GetType() - .GetMethod( - "ApplyJunkFolderSelections", - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, - null, - [typeof(string), typeof(string)], - null - ); - - if (applyMethod is null) + // why: issue #797 AC5. This call site previously located the target by reflecting over a + // method name, so a rename would have degraded silently to a warn-and-return that no + // build or test could catch. The typed cast is compile-checked, and a globals + // implementation that does not provide the seam is now reported at error level rather + // than as a warning, so the failure is loud. + if (olObjects is not IJunkFolderSelectionSink sink) { - logger.Warn( - "Unable to persist junk-folder selections because the Outlook globals implementation does not expose ApplyJunkFolderSelections." + logger.Error( + "Unable to persist junk-folder selections because the Outlook globals " + + $"implementation does not implement {nameof(IJunkFolderSelectionSink)}." ); return; } - applyMethod.Invoke(olObjects, [JunkEmail?.RelativePath, JunkPotential?.RelativePath]); + sink.ApplyJunkFolderSelections(JunkEmail?.RelativePath, JunkPotential?.RelativePath); } internal virtual FolderMinimalWrapper? SelectFolder() @@ -453,26 +383,6 @@ internal void PersistJunkFolderSelections() return null; } - internal string GetRelativeFsPath() - { - if ( - Current.ArchiveFsRoot is not null - && !Current.ArchiveFsRoot.FolderPath.IsNullOrEmpty() - ) - { - var (specialFolder, relativePath) = FsConverter(Current.ArchiveFsRoot.FolderPath); - if (specialFolder.IsNullOrEmpty() & relativePath.IsNullOrEmpty()) - { - return "Please select an archive"; - } - else - { - return $"{string.Join(" -> ", [specialFolder, relativePath]).Trim()}"; - } - } - return "Please select an archive"; - } - #endregion Methods } } diff --git a/UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs b/UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs index 6a04d1c1b..aa8e2f410 100644 --- a/UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs +++ b/UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs @@ -439,11 +439,35 @@ public T DeserializeObject(string json, JsonSerializerSettings settings) #region Serialization + /// + /// Reports whether a write may proceed, and logs the reason at error level when it may not + /// (issue #797, AC2). The previous guard compared only against the empty string, so a null + /// file path passed it and reached the write path, and an empty path returned silently with + /// no diagnostic at all. Shared by the deferred and the explicit-save entry points so both + /// report the same diagnostic and neither fails silently. + /// + /// Receives the configured file path. + /// True when the configured path is neither null nor empty. + private bool TryGetSerializationPath(out string filePath) + { + filePath = Config.Disk.FilePath; + if (!string.IsNullOrEmpty(filePath)) + { + return true; + } + + logger.Error( + $"Cannot serialize {typeof(T)}: Config.Disk.FilePath is null or empty " + + $"(value: '{filePath}'), so the instance was not written to disk." + ); + return false; + } + public void Serialize() { - if (Config.Disk.FilePath != "") + if (TryGetSerializationPath(out var filePath)) { - RequestSerialization(Config.Disk.FilePath); + RequestSerialization(filePath); } } @@ -453,6 +477,27 @@ public void Serialize(string filePath) RequestSerialization(filePath); } + /// + /// Explicit-save entry point (issue #797, AC4). Callers that must not lose a write when the + /// host process exits inside the deferred three-second window call this instead of + /// . + /// + public void SerializeNow() + { + // why: issue #797 AC4. The deferred write is raised on a ThreadPool background thread + // three seconds after the request, and a background thread is not joined at process + // exit, so a save issued inside that window is lost when the host tears down the + // AppDomain, with no log entry. An explicit save therefore writes inline through the + // existing thread-safe write method, which takes the write lock, writes through the + // injectable stream-writer seam, and re-arms the single-shot guard in its finally block. + // The AC2 guard is evaluated first so this fix does not substitute one silent failure + // for another. Every other caller keeps the unchanged deferred behaviour. + if (TryGetSerializationPath(out var filePath)) + { + SerializeThreadSafe(filePath); + } + } + protected ReaderWriterLockSlim _readWriteLock = new(); public static JsonSerializerSettings GetDefaultSettings() diff --git a/UtilitiesCS/UtilitiesCS.csproj b/UtilitiesCS/UtilitiesCS.csproj index 75b0d7294..f17145977 100644 --- a/UtilitiesCS/UtilitiesCS.csproj +++ b/UtilitiesCS/UtilitiesCS.csproj @@ -751,6 +751,7 @@ + Form @@ -1031,6 +1032,7 @@ + diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/code-review.2026-09-07T22-40.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/code-review.2026-09-07T22-40.md new file mode 100644 index 000000000..4c85232c5 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/code-review.2026-09-07T22-40.md @@ -0,0 +1,250 @@ +# Code Quality Review — Issue #797 (Folder Settings never persist; User Email "Error Loading") + +- Date: 2026-09-07 +- Timestamp label: 2026-09-07T22-40 +- Base: `origin/main` at `c431dc3297e864041d829e8d79b348960b8d8019` +- Branch: `bug/folder-settings-never-persist-797` +- Source of record: `artifacts/797-source-review.patch` (`git diff origin/main HEAD`, `*.cs` and `*.csproj`), cross-read against the working-tree post-image of every production file discussed below. + +## Verdict + +**PASS.** Zero blocking findings. Six advisory findings (CR-1 through CR-6), none of which alters an acceptance-criteria verdict or requires remediation before merge. + +The change is well-constructed. Each of the two root causes is fixed at the narrowest correct site, the two silent-failure paths that made the defect undiagnosable are both converted to error-level diagnostics, the reflection binding is removed entirely rather than merely wrapped, and every new member is documented with a `// why:` comment that names the mechanism it repairs. The highest-risk edit — the deferred-write ordering change — is correct and does not lose writes, though it carries two secondary hazards recorded below. + +| Finding | Severity | Blocking | +|---|---|---| +| CR-1 — AC6 retry bound is stated more tightly than the code enforces | Medium | No | +| CR-2 — explicit save performs file I/O and an unbounded lock wait on the UI thread | Low | No | +| CR-3 — `SerializeNow` re-arms the single-shot guard early, permitting a redundant second timer | Low | No | +| CR-4 — `SmartSerializable.cs` grew 45 lines while already 113 lines over the cap | Low | No | +| CR-5 — the AC5 double-persistence path is retained, and divergence between the two stores is not itself loud | Low | No | +| CR-6 — the production seam forwarder has no test coverage | Low | No | + +--- + +## What was verified, and how + +This review traced each acceptance criterion to concrete code and to a named test, rather than accepting the executor's mapping. Four verifications required following a chain rather than reading a single line, and each is set out below because the conclusion depends on the chain holding at every link. + +### AC1 — the fresh-build path genuinely adopts the loader's disk configuration + +The fix is one statement at `TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs` lines 76-79: + +```csharp +if (configFound && StoresWrapper is not null) +{ + StoresWrapper.Config.CopyFrom(config.Config, true); +} +``` + +The question this raises is whether `StoresWrapper.Config` is the same object that `Serialize()` later reads, or a different configuration that happens to share a name. It is the same object, and the chain was walked in full: + +1. `StoresWrapper` is declared `public partial class StoresWrapper : SmartSerializable` (`UtilitiesCS/OutlookObjects/Store/StoresWrapper.cs` line 17), so `StoresWrapper.Config` resolves to `SmartSerializable.Config` at `SmartSerializable.cs` line 69 — the very property `TryGetSerializationPath` reads at line 453. +2. `NewSmartSerializableConfig.CopyFrom(other, deep: true)` at `NewSmartSerializableConfig.cs` lines 197-214 deep-copies `other` and then calls `Disk.CopyFrom(other.Disk)`. +3. `FilePathHelper.CopyFrom` at `FilePathHelper.cs` lines 449-458 assigns `_filePath = other._filePath` directly, alongside the folder path, file name, stem and extension. + +So the loader's materialised path reaches the exact field the serializer guard inspects. The fix also uses the identical idiom the successful-deserialize path already uses at `SmartSerializable.cs` lines 224, 246 and 302 (`instance.Config.CopyFrom(loader.Config, true)`), which is the right consistency choice and the reason design decision D1's claim that the shared overload needed no change holds. + +The refactor from `if (_globals.IntelRes.Config.TryGetValue(...))` to a hoisted `configFound` local is necessary rather than cosmetic: the fresh-build statement sits below the `if/else`, so the branch fact must survive to that point. The key-absent branch is correctly excluded by `configFound`, matching the criterion's stated scope, and made visible by AC2's new error log rather than left silent. + +Tests: `LoadStoresAsync_WhenConfigDeserializesToNull_FreshWrapperAdoptsLoaderDiskConfiguration` asserts the adopted path, and `LoadStoresAsync_WhenConfigKeyIsAbsent_FreshWrapperKeepsEmptyDiskPath` asserts the negative case. Neither touches the filesystem; both assert on the configuration value. + +### AC5 — the seam is genuinely typed and no reflection fallback survives + +Three separate checks, all of which had to pass: + +1. **The reflection is gone, not wrapped.** The `GetMethod` call with its `BindingFlags` and parameter-type array is deleted outright (patch lines 1594-1602), and the `using System.Reflection;` directive is removed from `StoreWrapperController.cs` (patch line 1488). A directive removal is meaningful evidence here, because the file would not compile if any other reflection use survived, and the analyzer and nullable rebuilds both exited 0. A pattern search over the patch for reflection APIs finds no replacement. +2. **The cast is compile-checked and the failure is loud.** `if (olObjects is not IJunkFolderSelectionSink sink)` followed by `logger.Error(...)` and a `return`, then `sink.ApplyJunkFolderSelections(JunkEmail?.RelativePath, JunkPotential?.RelativePath)`. The former `logger.Warn` is now `logger.Error`, which is what "fail loudly" requires. The message names the interface, so it is actionable. +3. **The explicit implementation does not recurse.** `AppOlObjects.JunkFolders.cs` lines 54-57 declare `void IJunkFolderSelectionSink.ApplyJunkFolderSelections(a, b) => ApplyJunkFolderSelections(a, b);`. This is only safe because an explicit interface implementation is excluded from the type's own member lookup, so the unqualified call inside the body binds to the `internal` method at lines 36-45 rather than to itself. It does, and that internal method exists with the matching signature. Had the implementation been implicit, the same body would have been infinite recursion. The choice of an explicit implementation is also the correct one for the stated goal of not widening the type's public surface. + +Module boundary. The interface is declared in `UtilitiesCS` and implemented in `TaskMaster`, so the one-way `TaskMaster` to `UtilitiesCS` project reference direction is preserved and `UtilitiesCS` gains no reference to `TaskMaster`. This matches the shape of the existing `IStoreDisableService` and `IStoreRehookService` interfaces in the same folder. + +Test-double design is notably good here. `NonSinkOlObjects` declares a `public void ApplyJunkFolderSelections(string, string)` with the historic name and signature but does **not** implement the interface. That is precisely the discriminating case: the old reflection lookup would have bound to it, the typed cast does not. The paired assertions — an error event exists, and `ApplyCallCount` is 0 — attribute the event to this test without relying on an exact global event count. + +### AC4 — deferred-write ordering: no lost-write window + +This was examined for the three hazards the caller flagged. The conclusion is that no write is lost, but two secondary hazards exist and are recorded as CR-2 and CR-3. + +The mechanism. `RequestSerialization` (`SmartSerializable.cs` lines 595-604) arms a single-shot three-second timer only when `_serializationRequested.CheckAndSetFirstCall` is true; the guard is reset only in `SerializeThreadSafe`'s `finally` block (line 543). `SerializeNow` (lines 485-499) bypasses the timer and calls `SerializeThreadSafe(filePath)` inline, after evaluating the AC2 guard. + +**Lost-write analysis.** Two interleavings were considered. + +- A deferred `Serialize()` at t=0 arms a timer that will fire at t=3; the user clicks Save at t=1. `SerializeNow` writes the current state inline. The pending timer still fires at t=3 and writes again. Two writes of the same state, no loss. +- `SerializeNow` at t=0 writes inline and re-arms the guard; a later `Serialize()` arms a fresh timer. No loss. + +There is no interleaving in which the explicit save is dropped, because `SerializeNow` never consults or consumes the single-shot guard before writing — it writes unconditionally once the path guard passes. That is the correct design for this requirement. + +**Re-entrancy.** `SerializeThreadSafe` acquires `_readWriteLock` (a `ReaderWriterLockSlim` constructed with the default non-recursive policy) via `TryEnterWriteLock(-1)`. Same-thread re-entry would throw `LockRecursionException`, but no re-entrant path exists: `SerializeToStream` serializes `_parent` through Newtonsoft and no property getter on `StoresWrapper` calls back into serialization. The realistic contention is cross-thread and is recorded as CR-2. + +**Disposal ordering.** `_parent.ThrowIfNull(...)` guards the write, and `StoresWrapper` sets the parent reference in both constructors, so the guard is satisfied on both the deserialized and the fresh-built model — the precondition D3 identified holds. The `StreamWriter` is created inside a `using` and explicitly closed; the `finally` releases the lock before resetting the guard, in the correct order. No disposal-ordering defect was found. The one disposal gap, `_timer` never being disposed, is pre-existing and is discussed under CR-3. + +**AC2 evaluated before any synchronous write.** `SerializeNow` calls `TryGetSerializationPath` first, so the fix does not substitute one silent failure for another. This was an explicit D3 requirement and it is honoured. + +Tests pin both halves: `SerializeNow_WithConfiguredPath_WritesWithoutFiringTimer` asserts one writer creation and `timerStub.Started == false`, while `Serialize_WithConfiguredPath_StillRequiresTimerFireToWrite` asserts the deferred path arms the timer, writes nothing until `FireElapsed()`, and then writes exactly once. Having both in one file makes the unchanged-behaviour claim directly evidenced rather than asserted, which is the right structure. + +### AC6 — the synchronous COM read, and whether the bound holds + +The retry is at `StoreWrapperController.Display.cs` lines 48-51: + +```csharp +if (Current is not null && Current.UserEmailAddress is null) +{ + Current.RefreshUserEmailAddress(); +} +``` + +The condition is correct as far as it goes — the lookup runs only when the address is null, and the null-check on `Current` means a null store selection cannot throw here. But the claimed bound of "at most once per dialog open" does not hold. See CR-1. + +The fallback chain in `StoreWrapper.GetSmtpAddressFromStore` was read in full and implements exactly the order the specification fixes: Exchange primary SMTP; then the address entry's own `Address` when it contains an at-sign; then `DisplayName` when it contains an at-sign; then null with `LastSmtpLookupError` set. Each of the first two steps carries its own `catch (COMException)` that captures the reason and continues rather than aborting, which is the substantive repair — the pre-change code had a single outer catch that converted any failure anywhere in the chain into `return null`. + +Two details are correct and easy to get wrong. First, the `addressEntry` local is hoisted above the first `try` so the second step can still use it after the first step throws; had it stayed inside the first block, the address-entry fallback would have been unreachable on exactly the path that needs it. Second, `LastSmtpLookupError` is cleared to null on every success path and set only on total failure, so the controller cannot render a stale reason alongside a successfully resolved address. + +`BuildUserEmailUnavailableText` renders `"Email address unavailable"` when no reason was captured and `"Email address unavailable: {reason}"` when one was. This satisfies the "specific message including the reason" requirement and correctly stops sharing the generic `"Error Loading"` literal with the Inbox and Root Folder labels. `PopulateWithCurrent_WhenRetryFails_RendersSpecificMessageWithReason` asserts both that the reason text appears and that the value is not `"Error Loading"`, which is the right pair of assertions. + +`RefreshUserEmailAddress` is safe when `RootFolder` is null because the chain's first read is null-conditional; `RefreshUserEmailAddress_WhenRootFolderIsNull_ReturnsNullAndDoesNotThrow` pins this. + +### AC7 and AC8 + +`TrimStorePrefix` is a pure static helper: it returns the input unchanged unless the input starts with exactly `\\`, in which case it returns `Substring(2)`. Null and empty both pass through unchanged. Six boundary cases cover null, empty, prefix-only, single backslash, no backslash and the ordinary case. + +One interaction worth confirming: the call site is `TrimStorePrefix(Current?.Inbox?.FolderPath) ?? "Error Loading"`. Because the helper returns null for a null input, the null-coalescing placeholder still fires exactly as it did before, and because it returns the empty string unchanged, an empty `FolderPath` still renders as an empty label — the same as the pre-change behaviour. The helper therefore introduces no rendering regression on any input. + +For AC8, all four previously unguarded dereferences at the top of `PopulateWithCurrent` are now null-conditional, matching the form the very next block already used, and `GetRelativeFsPath`'s dereference is guarded so it returns the same placeholder it already returned for an unset archive root. The two new tests plus the inverted existing test cover the populate path and the helper path. + +The in-scope cleanup at `GetRelativeFsPath` — `&` changed to `&&` — is behaviourally inert as claimed: both operands call the null-tolerant `IsNullOrEmpty` string extension and neither has a side effect. The in-code comment correctly declines to claim it repairs a fault. + +### The controller partial split + +The relocation of `PopulateWithCurrent`, `BindExcludeStoreCheckbox` and `GetRelativeFsPath` into `StoreWrapperController.Display.cs` is verbatim, verified by diffing the removed block in `StoreWrapperController.cs` against the added block in the new file: outside the deliberate AC6, AC7 and AC8 edits, the relocated text including the commented-out block is byte-identical. Both parts carry the `partial` keyword, the new file opens with `#nullable enable` matching the original, and both new production files have hand-added compile entries, which is essential in a non-SDK-style project because a missing entry produces a silently absent file rather than a build error. The result is 388 and 173 lines, both within the cap, which is the outcome D4 was written to produce. + +--- + +## Findings + +### CR-1 — The AC6 retry bound is stated more tightly than the code enforces (Medium, advisory) + +**Location.** `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs` lines 41-51, and the corresponding comment at `UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs` lines 214-219. + +**Rule.** General Code Change Policy section 5.3 — "Comment **why**, not what. Keep comments synchronized with behavior." Also the evidence-first wording requirement in `.claude/rules/tonality.md`: match the strength of the wording to the strength of the evidence. + +**The claim.** Both in-code comments state the retry is attempted "at most once per dialog open and only when the address is null, which bounds the added UI-thread latency to the single lookup startup already performs." `spec.md` lines 491-493, Non-Goals item 8 and risk 1 all repeat the same bound, and it is the stated basis on which the reintroduced synchronous COM read was accepted. + +**Verification basis.** `PopulateWithCurrent` has exactly one production call site, at `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs` line 169, inside `DisplayName_SelectedValueChanged`. That handler fires on **every** store selection change, not only at dialog open — a fact `spec.md` line 491 itself acknowledges when it says the method "runs both when the dialog opens and on every store re-selection." The retry gate is `Current.UserEmailAddress is null`, and when the lookup fails, `RefreshUserEmailAddress` assigns null back to `UserEmailAddress`, so the gate remains open. Consequently, for a store whose Exchange lookup keeps failing, every re-selection of that store runs the full synchronous COM chain again. + +**Impact.** The true bound is one blocking COM chain per `PopulateWithCurrent` invocation on a store whose address is still null — unbounded in the number of user selection changes, not one per dialog open. On the chain that `spec.md` documents as independently demonstrated capable of long UI-thread blocks, a user cycling the store combo box on a mailbox with a persistent Exchange failure incurs one such block per cycle. The mitigating facts are that a successful lookup populates the address and permanently closes the gate for that store within the session, and that the failing case is exactly the case the user is trying to diagnose. + +**Why this is not blocking.** The authoritative AC6 checkbox text requires only that "the lookup is retried when the dialog opens," which the code satisfies. The overstated bound is in the plan, the specification's supporting prose and the code comments, not in the criterion. + +**Recommendation.** Either add a per-store or per-controller attempted flag so the retry is genuinely once per dialog open, or correct the three comments and the specification prose to state the actual bound. The first is a small change: a `bool` field on the controller set on the first retry and reset in `Launch`. + +### CR-2 — The explicit save performs file I/O and an unbounded lock wait on the UI thread (Low, advisory) + +**Location.** `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs` line 290 (`Model.SerializeNow();` inside `SaveChanges`), reaching `UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs` lines 519-546. + +**Rule.** General Code Change Policy section 6.2 — isolate I/O; and section 3 — document non-obvious failure modes. + +**Verification basis.** `SaveChanges` is called from `ButtonSave_Click` and from `DisplayName_SelectedValueChanged`, both UI-thread paths. `SerializeThreadSafe` acquires the write lock with `TryEnterWriteLock(-1)`, an infinite timeout, and then performs `CreateStreamWriter(filePath)` — `File.CreateText` in production — plus a full Newtonsoft serialization of the whole stores wrapper, all inline on the calling thread. Before this change, `SaveChanges` called `Serialize()`, which returned immediately after arming a timer and performed no I/O on the caller's thread. + +**Impact.** The change deliberately trades a lost-write risk for a UI-thread stall risk, which is the correct trade for this requirement. The residual is that if the deferred timer callback is already inside the write lock on a ThreadPool thread, the UI thread waits with no timeout and no diagnostic. The window is short for a settings JSON of this size, and no evidence of an actual stall exists, so the severity is low. What is missing is acknowledgement: `SerializeNow`'s `// why:` comment describes the lock as a correctness mechanism but does not note that the caller now blocks on it. + +**Recommendation.** Record the accepted latency in the comment, or use a bounded `TryEnterWriteLock(timeout)` with an error log when acquisition fails, so a stall is diagnosable rather than silent. Note that `SerializeThreadSafe`'s existing `if (TryEnterWriteLock(-1))` already has a false branch that silently does nothing, which a bounded timeout would make reachable — that branch would need an error log to avoid reintroducing exactly the silent-failure class this issue exists to remove. + +### CR-3 — `SerializeNow` re-arms the single-shot guard early, permitting a redundant second timer (Low, advisory) + +**Location.** `UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs` line 543 (the `finally` reset), reached from `SerializeNow` at line 497; interacting with `RequestSerialization` at lines 595-604. + +**Rule.** General Code Change Policy section 1 — simplicity and avoidance of non-obvious state interactions; General Unit Test Policy — concurrency behaviour must be covered when relevant. + +**Verification basis.** `SerializeThreadSafe`'s `finally` replaces `_serializationRequested` with a fresh `ThreadSafeSingleShotGuard`. Before this change that reset occurred only from the timer callback, after the deferred write had completed. `SerializeNow` now performs the same reset up to three seconds early, while a timer armed by an earlier `Serialize()` may still be pending. In that window a subsequent `Serialize()` passes `CheckAndSetFirstCall` and arms a second timer, overwriting the `_timer` field at line 599 without disposing the first. Both timers then fire `SerializeThreadSafe` with their separately captured file paths. + +**Impact.** The consequence is a redundant write of current state, not a lost write and not a corrupted file, because each write serializes the live `_parent`. The `_timer` overwrite-without-dispose pattern is pre-existing: `_timer` is declared at line 584 and assigned at line 599, and a search of the file finds no `Dispose` call on it on any path. This change widens the window in which the overwrite can occur but does not create the pattern. + +**Why this is not blocking.** No lost or corrupted write results, and the redundant write is idempotent in effect. + +**Recommendation.** Consider stopping or disposing any pending `_timer` inside `SerializeNow` before writing, which would make the explicit save fully supersede the deferred one. If the interleaving is left as is, an interleaving test — deferred request, then explicit save, then fire the pending timer, asserting the write count — would pin the accepted behaviour. No such test exists today; both AC4 tests exercise one path at a time. + +### CR-4 — `SmartSerializable.cs` grew 45 lines while already 113 lines over the cap (Low, advisory) + +**Location.** `UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs`. + +**Rule.** General Code Change Policy section 4.1 and `.claude/rules/general-code-change.md` — "No production code, test code, or reusable script file may exceed 500 lines." + +**Verification basis.** 613 lines at the base commit, 658 after the change, a delta of +45. Independently corroborated by the patch's own two hunk headers for the file (`@@ -439,11 +439,35 @@` = +24, `@@ -453,6 +477,27 @@` = +21), which reconcile 613 to 658 exactly. + +**Disposition.** The overage is pre-existing and is deliberately not resolved here by design decision D5, Non-Goals item 1 and risk 5, on the stated ground that splitting a shared reusable-type-classes file during a parallel run would create merge contention with concurrently running sibling work items. That rationale is sound and the file is not made worse in any structural sense — the two additions are cohesive with the existing Serialization region and both are documented. + +**One wording correction.** `spec.md` line 195 states this change "adds a small number of lines to it." A 45-line addition, 7.3 percent growth on an already over-cap file, is more than that phrasing conveys. The evidence artifact `p5-t8` reports both counts accurately, so the record is not misleading; only the specification's prose understates the delta. + +**Recommendation.** None under this issue. The split should be raised as its own work item, as D5 states. + +### CR-5 — The AC5 double-persistence path is retained; divergence between the two stores is not itself loud (Low, advisory) + +**Location.** `TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs` lines 36-45, and `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs` lines 285-290. + +**Verification basis.** `SaveChanges` writes the junk selections into the JSON model (`Current.JunkCertain = JunkEmail; Current.JunkPotential = JunkPotential;`) and then calls `PersistJunkFolderSelections()`, which routes through the new seam into `ApplyJunkFolderSelections`, which writes `Properties.Settings.Default.OlJunkCertain` and `.JunkPotential` and calls `Save()`. Both mechanisms still run on every save. + +**Assessment.** AC5 is a disjunction — "either removed or made to fail loudly" — and design decision D2, which is recorded in the authoritative specification, selects the fail-loudly reading with an explicit rationale (removal would break the globals junk-folder accessors that read the settings values back). What the change makes loud is the seam-absence case. What is not made loud is a disagreement between the two stores once both are writing, which `spec.md` risk 4 records as a known and accepted rollout consequence and which step 9 of the AC3 manual procedure is written to check. + +**Why this is not a finding against the change.** The criterion is satisfied under the reading the authoritative AC source itself selects, and the residual divergence risk is disclosed rather than concealed. It is recorded here so the reviewer of the follow-up work item has the full picture. + +**Recommendation.** None under this issue. The removal of the second mechanism, which requires rehoming the globals junk-folder accessors onto the JSON model, belongs in a separate work item. + +### CR-6 — The production seam forwarder has no test coverage (Low, advisory) + +**Location.** `TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs` lines 54-57. + +**Verification basis.** The post-change Cobertura document measures this file at 21 of 69 lines and the file carries no class-level coverage-exclusion attribute, so the zero is real rather than an artefact of exclusion. The single executable changed line in this file — the explicit implementation's forwarding expression — is uncovered, because driving it would call `Properties.Settings.Default.Save()` and write to the .NET user settings store, which the unit test policy forbids. + +**Impact.** The forwarder's argument order is verified by code read only. This reviewer performed that read: the explicit implementation passes `(junkCertainRelativePath, junkPotentialRelativePath)` positionally to the internal method, whose body routes the first to `WriteJunkCertainSetting` and the second to `WriteJunkPotentialSetting`. The order is correct and the parameter names match the interface, so a transposition is not present. The seam contract itself is separately pinned on the UtilitiesCS side by `PersistJunkFolderSelections_PassesJunkCertainPathFirst`, which asserts distinguishable values in both positions against a recording double. + +**Why this is not blocking.** The uncovered line is a one-line positional pass-through, the contract is pinned on the calling side, and the aggregate changed-line figure of 91.09 percent clears the applicable gate. + +**Recommendation.** None under this issue. Introducing a settings seam on the TaskMaster side would make the forwarder testable and is a reasonable separate improvement. + +--- + +## Test quality assessment + +Measured against `.claude/rules/general-unit-test.md` and the C# Unit Test Policy. The result is strong. + +**What is done well.** + +- The log-capture helpers are the most delicate part of the new tests and are handled carefully. `AttachRootMemoryAppender` documents *why* it attaches to the root logger rather than a named one: the serializer's logger is initialised from the declaring type reported by reflection over a member of a generic type, which resolves to the generic type definition, so an appender attached to a closed constructed type's full name would capture nothing. That is a real trap, correctly identified and correctly avoided. Both helpers restore the previous logger level and the repository's `Configured` flag through a `restore` delegate invoked in a `finally`. +- Both assertions on captured log events filter by a name that occurs nowhere else in the test project — `SerializeGuardProbeItem` for the serializer and `IJunkFolderSelectionSink` for the controller — and assert existence rather than an exact count, with the stated reason that a concurrently running class could only add events. Both host classes additionally carry `[DoNotParallelize]`. The AC5 test pairs the event assertion with `ApplyCallCount.Should().Be(0)`, which is what actually attributes the event to that test rather than to a neighbour. +- `NonSinkOlObjects` is a genuinely discriminating double, as set out above. Retargeting the pre-existing negative test rather than deleting it preserves coverage of the loud-failure branch, which is the right call and avoids the common mistake of removing a test whose subject changed. +- The four `GetSmtpAddressFromStore_*` cases are properly table-shaped over the fallback order, each isolating one step, and case 4 asserts on `LastSmtpLookupError` rather than only on the null return, which pins the mechanism the controller actually consumes. +- No test can reach `MyBox.ShowDialog` or any live Outlook worker. The AC5 tests operate on the UtilitiesCS side against doubles and never enter `LoadJunkPotential` or `LoadJunkCertain`, the only dialog-raising members in the touched files. +- Every FluentAssertions call on a non-obvious expectation supplies a `because` reason, so a failure message states the criterion rather than only the mismatch. + +**Minor points, none rising to a finding.** + +- `SmartSerializableSerializeGuardTests` declares its own harness and probe type with a comment explaining why the established harness could not be reused (it is a private nested class in another file). That is the right justification to record, though it means the probe type must implement thirteen `ISmartSerializable` members that the tests never exercise. The `#pragma warning disable CS0067` on the probe's `PropertyChanged` event is unavoidable and correctly scoped to the single member. +- `SmartSerializableSerializeGuardTests` sits one directory level shallower than a strict mirror of the production file's path, matching the established local convention for this class's sibling test files. The "match the existing style" rule governs and is satisfied. +- The two fake paths use a `X:\` drive that does not exist, and neither is ever opened because all writes go through the injected seam. Using a non-existent root is a good defensive choice: if the seam injection were ever broken, the test would fail loudly rather than silently writing somewhere real. + +**Determinism.** No `Thread.Sleep`, `Task.Delay`, `DateTime.Now`, real wall-clock wait, or temporary file appears anywhere in the patch, verified by pattern search. The three-second deferred timer is advanced by an explicit `FireElapsed()` call on a manual-fire double injected through the existing `TimerFactory` seam. + +**Coverage exclusion.** Zero occurrences of `ExcludeFromCodeCoverage` in the patch. No production file was excluded from measurement, and both new production files are accounted for in the post-change Cobertura document — the display partial as a measured class at 100 percent line coverage, and the interface file legitimately absent because an interface declaration emits no IL. + +--- + +## Design and maintainability + +The design decisions hold up under review. + +- **D1 (fix at the call site, not the shared serializer)** is the right call. The shared overload's null return is a documented fail-soft contract relied on by a second production caller, and the mechanical argument in the specification is correct: on the file-absent path there is no instance to copy onto, so "adopt the loader's configuration" is not expressible inside that overload without changing its contract. +- **D2 (a dedicated interface rather than extending `IOlObjects`)** avoids forcing changes on four existing test stubs and keeps the public surface narrow through explicit implementation. Correct. +- **D3 (flush on the explicit save path, not a shutdown handler)** is well grounded: the VSTO shutdown event is documented in-repo as no longer raised, so a flush placed there would never run. Verified as a design rationale rather than accepted on assertion. +- **D4 (partial split)** produces two files comfortably within the cap and follows an established in-repo precedent. +- **D6 (declared test inversion)** is correctly framed as a strengthening and is disclosed in advance, which is exactly the treatment the General Code Change Policy's "existing tests are part of the spec" rule requires. + +The naming throughout is descriptive (`TryGetSerializationPath`, `RefreshUserEmailAddress`, `BuildUserEmailUnavailableText`, `LastSmtpLookupError`), the accessibility choices are deliberate and justified in comments (`internal static TrimStorePrefix` rather than private, with the `InternalsVisibleTo` rationale stated), and every non-obvious edit carries a `// why:` comment naming the mechanism it repairs rather than restating what the code does. + +One small note on `TryGetSerializationPath`: its `out string filePath` parameter is declared non-nullable but carries null on the failure path. Neither caller uses the value after a false return, so no defect arises, and the nullable rebuild passed. Declaring it `out string? filePath` would express the contract more precisely. + +--- + +## Conclusion + +**PASS. Zero blocking findings.** Six advisory findings recorded, of which CR-1 is the only one worth acting on in the near term and none of which requires remediation before merge. No remediation-inputs artifact is produced. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-analyzer-build.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-analyzer-build.2026-09-06T22-00.md new file mode 100644 index 000000000..48c9bb49d --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-analyzer-build.2026-09-06T22-00.md @@ -0,0 +1,34 @@ +# Phase 0 — Analyzer Rebuild Baseline (Issue #797) + +Timestamp: 2026-09-07T09-17 + +Command: `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-baseline-analyzers.log"` + +EXIT_CODE: 0 + +MSBuild was invoked through the absolute path vswhere resolved, per rule R3. The `/t:Rebuild` target +was used, not `/t:Build`, so `CoreCompile` ran on every project and the analyzers actually executed. + +## Discrimination, per rule R4 + +A successful msbuild run prints the substring "error" many times in ordinary output, so this gate +asserts two things: the process exit code, and the presence of the MSBuild summary count line. + +- Process exit code: 0. +- Summary line ` 0 Error(s)` is present in the file log, at log line 57770. +- Warning count from the summary: 0, recorded on the immediately preceding line as ` 0 Warning(s)`. +- The summary block reads `Build succeeded.` followed by the two count lines above. + +BASELINE-DIAGNOSTIC-IDS: + +(empty — the build is clean, so no compiler or analyzer diagnostic identifier was reported as an +error, and none as a warning either) + +Both branches of the rule R4 acceptance are recorded. The clean branch applies: the exit code is 0 and +the ` 0 Error(s)` summary line is present, so the non-clean branch, which would compare a recorded +diagnostic identifier set against this baseline set, is not entered. Phase 1 and Phase 5 nevertheless +carry the same two branches and will resolve against this empty set. + +Output Summary: The analyzer rebuild is clean at the baseline. Exit code 0, zero warnings, zero +errors, empty baseline diagnostic identifier set. The full detailed log was written to the git-ignored +coverage directory and is not committed; only these sanitized summary fields are recorded here. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md new file mode 100644 index 000000000..03d6b2551 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md @@ -0,0 +1,16 @@ +# Phase 0 — Base SHA (Issue #797) + +Timestamp: 2026-09-07T09-11 + +Command: `git merge-base HEAD origin/main` + +EXIT_CODE: 0 + +BASE-SHA: dc8ca6d3a93e8164406055881786907de1025d05 + +Output Summary: The merge base of this branch and origin/main is the forty-character value recorded on +the BASE-SHA line above. Every anchored diff in this plan derives the base SHA from that line rather +than from a pasted literal, per plan rule R1. The plan header records an earlier base commit as +descriptive metadata; origin/main was merged into this branch before execution began, so the operative +merge base is the value above. That value excludes the two sibling work items already merged into +origin/main from this item's change footprint. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-bootstrap.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-bootstrap.2026-09-06T22-00.md new file mode 100644 index 000000000..8ada03158 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-bootstrap.2026-09-06T22-00.md @@ -0,0 +1,58 @@ +# Phase 0 — Worktree Toolchain Bootstrap (Issue #797) + +Timestamp: 2026-09-07T09-15 + +All four commands ran with the working directory set to the repository root of this worktree, so no +script derived a repository root belonging to another checkout. + +## Command 1 — repository-local .NET SDK + +Command: `pwsh -NoProfile -File scripts/vscode/Install-RepoDotNetSdk.ps1` + +EXIT_CODE: 0 + +Reported: `Installed repo-local .NET SDK 8.0.205 to \.dotnet-sdk`. The SDK version the +command reports is 8.0.205, matching the pin in the repository-root global.json. + +## Command 2 — pinned tool manifest restore + +Command: `dotnet tool restore --tool-manifest dotnet-tools.json` + +EXIT_CODE: 0 + +Reported: `Tool 'csharpier' (version '1.2.6') was restored. Available commands: csharpier` followed by +`Restore was successful.` The manifest pins csharpier 1.2.6 and nothing else. + +## Command 3 — global dotnet-coverage tool + +Command: `dotnet-coverage --version` + +EXIT_CODE: 0 + +Reported version: `18.10.0+f4cc39224845ffa74bf246c9da2399d50e5d6342`. + +The recovery branch described by the plan task was not entered. The command exited 0 on its first +attempt, so no `dotnet tool install --global dotnet-coverage` was run and no fifth command exists. + +## Command 4 — packages.config package set restore + +Command: `msbuild TaskMaster.sln /t:Restore /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:RestorePackagesConfig=true` + +EXIT_CODE: 0 + +MSBuild was invoked through the absolute path vswhere resolved, per rule R3. MSBuild reported version +18.9.1 for .NET Framework and added the full packages.config package set to the worktree packages +directory. + +## Pre-state of each bootstrap target, observed before command 1 + +| Target | State before this step | State after this step | +|---|---|---| +| repository-local SDK directory `.dotnet-sdk` | absent | created by command 1 | +| csharpier manifest tool | not restored (no `.config` tool state in this worktree) | restored by command 2 at version 1.2.6 | +| global dotnet-coverage tool | already present on PATH | unchanged; version recorded above | +| `packages` directory | absent | created by command 4 | + +Output Summary: All four bootstrap commands exited 0. The repository-local SDK 8.0.205 and the +packages directory were created by this step; csharpier 1.2.6 was restored by this step; the global +dotnet-coverage tool 18.10.0 was already present and required no recovery install. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-coverage-measurability.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-coverage-measurability.2026-09-06T22-00.md new file mode 100644 index 000000000..bd2ffa3cd --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-coverage-measurability.2026-09-06T22-00.md @@ -0,0 +1,37 @@ +# Phase 0 — Coverage Measurability of the Seven Write Set Production Files (Issue #797) + +Timestamp: 2026-09-07T09-21 + +Command: `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Measurability -CoberturaPath coverage/plan797-baseline/coverage.cobertura.xml` + +EXIT_CODE: 0 + +The search looks for a `class` element in the baseline document whose `filename` attribute ends with a +path separator followed by the file name. The match is anchored on a path separator, taken by reading +the leaf of the filename attribute, so a file name cannot also select a differently named sibling that +ends with the same characters — for example `StoreWrapperController.cs` cannot be selected by a +lookup for `Controller.cs`, and `StoreWrapperController.Display.cs` is a distinct leaf from +`StoreWrapperController.cs`. + +| Production path | Verdict | Baseline covered lines | Baseline valid lines | +|---|---|---|---| +| TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs | MEASURABLE | 38 | 38 | +| TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs | MEASURABLE | 21 | 68 | +| UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs | MEASURABLE | 317 | 345 | +| UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs | MEASURABLE | 122 | 128 | +| UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs | MEASURABLE | 226 | 235 | +| UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs | NOT MEASURABLE — NOT YET CREATED | n/a | n/a | +| UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs | NOT MEASURABLE — NOT YET CREATED | n/a | n/a | + +The last two files do not exist at this point in the plan and are recorded as NOT YET CREATED. They +are re-evaluated against the Phase 5 document in P5-T7. The interface file is expected to remain +without a class element even after creation, because an interface declaration emits no IL; rule R10 +directs that such a file be reported as NOT APPLICABLE rather than as a zero. + +The five files that exist today are all measurable, so each produces a real per-file changed-line row +in the P5-T7 table. In particular TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs carries no +class-level coverage-exclusion attribute on its partial and is measurable at 21 of 68 lines. + +Output Summary: Five of the seven Write Set production files are measurable in the baseline document +with the counts above. The two files this change creates do not yet exist and are recorded as NOT YET +CREATED. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-coverage.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-coverage.2026-09-06T22-00.md new file mode 100644 index 000000000..a896c7947 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-coverage.2026-09-06T22-00.md @@ -0,0 +1,39 @@ +# Phase 0 — Baseline Coverage Document (Issue #797) + +Timestamp: 2026-09-07T09-21 + +Command: `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Coverage -FilterName All -OutputPath coverage/plan797-baseline/coverage.cobertura.xml -ResultsDirectory coverage/plan797-trx/baseline-coverage` + +The helper invoked `dotnet-coverage collect --output --output-format cobertura --settings + -- /Settings: /InIsolation +/TestCaseFilter: /Logger:trx /ResultsDirectory:`. + +EXIT_CODE: 0 + +ExpectedExitCode: 0 + +The clean branch applies. The collected run reproduced no failure at all: its results file records +5237 total, 5237 executed, 5237 passed, 0 failed, so the coverage collection had no inner test-run +exit code to propagate. + +## Output Summary + +LINES_COVERED=44426 LINES_VALID=83466 BRANCHES_COVERED=10877 BRANCHES_VALID=24323 BASELINE_LINE_PERCENT=53.23 BASELINE_ASSEMBLY_SCOPE=UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll,TaskMaster.Test/bin/Debug/TaskMaster.Test.dll + +The document-level branch rate, recorded as a non-asserted observation, is 44.72 percent. + +Both test assemblies were excluded from instrumentation by the derived coverage settings: the helper +reads the canonical coverage.config in memory, retains every existing third-party module exclusion, +and adds exactly one further module exclusion matching any module name ending in `.Test.dll`. The +canonical settings file was not written. The denominator therefore holds production code only. + +No field above carries the text UNVERIFIED or any placeholder; every value is a concrete integer or a +two-decimal number read from the Cobertura document root. + +This step records values and asserts no threshold. The repository's own 80 percent assertion is +written against a full-suite denominator, and this run's denominator is narrower, so the comparison +belongs in P5-T7. + +Output Summary: Baseline coverage collected cleanly. 44426 of 83466 lines covered, giving a +document-level line rate of 53.23 percent; 10877 of 24323 branches covered. The collected test run was +green at 5237 passed and 0 failed. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-csharpier-check.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-csharpier-check.2026-09-06T22-00.md new file mode 100644 index 000000000..ca6f8e8f3 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-csharpier-check.2026-09-06T22-00.md @@ -0,0 +1,25 @@ +# Phase 0 — CSharpier Formatting Baseline (Issue #797) + +Timestamp: 2026-09-07T09-16 + +Command: `dotnet tool run csharpier check .` + +EXIT_CODE: 0 + +Run from the repository root of this worktree, through `dotnet tool run` so the manifest-pinned +CSharpier 1.2.6 is used rather than any global installation. + +## Observed output + +```text +Checked 1601 files in 6988ms. +``` + +The `Checked` summary line reports 1601 files. The run reported no file as unformatted, and the +read-only check subcommand exited 0, which is a real signal rather than a write-mode exit code. + +PRE-EXISTING-FORMAT-DRIFT: NONE + +Output Summary: The formatting baseline is clean. 1601 files checked, zero unformatted files, exit +code 0. Phase 5 consumes this determination: because the baseline recorded no drift, every path +appearing in the P5-T1 before-and-after difference must be a Write Set path, and P5-T2 must exit 0. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-file-sizes.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-file-sizes.2026-09-06T22-00.md new file mode 100644 index 000000000..926a38d2c --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-file-sizes.2026-09-06T22-00.md @@ -0,0 +1,39 @@ +# Phase 0 — Pre-change Line Counts of Every Write Set C# File (Issue #797) + +Timestamp: 2026-09-07T09-20 + +Command: `Get-Content -LiteralPath | measure line count`, over the nine paths below, run from +the repository root of this worktree. + +EXIT_CODE: 0 + +| Path | Lines | +|---|---| +| TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs | 75 | +| TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs | 186 | +| UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs | 613 | +| UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs | 233 | +| UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs | 478 | +| UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs | 396 | +| UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs | 216 | +| UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs | 285 | +| TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs | 347 | + +All nine paths are listed with an integer line count each. + +PRE-EXISTING-OVER-CAP: + +- UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs — 613 lines, against the + 500-line cap in the general code change policy. This is the expected count and is the single entry. + Design decision D5 declares this file out of scope for splitting: splitting a shared + reusable-type-classes file during a parallel run would create merge contention with concurrently + running sibling work items. This change adds the minimum number of lines to that file and does not + resolve the pre-existing violation. + +The two files this change creates do not appear in this census because they do not yet exist. The +three project files are not enumerated, because the 500-line cap applies to production code, test code +and reusable script files and not to project files. + +Output Summary: Nine Write Set C# paths measured. One pre-existing over-cap file, the serializer at +613 lines, recorded under D5 as out of scope. The controller at 478 lines has 22 lines of headroom, +which is why D4 splits it into a display partial. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-helper-selfcheck.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-helper-selfcheck.2026-09-06T22-00.md new file mode 100644 index 000000000..d7f51a7f6 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-helper-selfcheck.2026-09-06T22-00.md @@ -0,0 +1,48 @@ +# Phase 0 — Session Helper Self-Check (Issue #797) + +Timestamp: 2026-09-07T09-14 + +Command: `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode SelfCheck` + +EXIT_CODE: 0 + +## Observed output + +```text +VSTEST-RESOLVED=C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe +MSBUILD-RESOLVED=C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe +HELPER-FUNCTIONS=Resolve-VsWherePath,Resolve-VsTestConsolePath,Resolve-MsBuildPath,Get-NamedTestCaseFilter,Get-DerivedCoverageSettingsXml,Invoke-ScopedVsTest,Invoke-ScopedCoverage,Get-CoberturaRootCounters,Get-TrxSummary,Get-TrxPassedNames,Get-CoberturaHitMap,Get-ChangedLineCoverage,Get-MeasurableClassFiles +``` + +Both resolved paths were produced by the Visual Studio installer's vswhere executable using its +`-find` switch, which enumerates existing files only, so each reported path exists. Thirteen function +names are listed, above the required minimum of six. + +## Helper properties required by rule R2 + +- The helper lives at the single fixed path coverage/plan797-helpers.ps1, inside a git-ignored + directory, so it never appears in a porcelain or diff scope gate. +- It builds its own dotnet-coverage argument list rather than calling the repository coverage runner + end to end. It supplies its own two-assembly list, its own combined test-case filter and its own + output path, and it performs no post-processing. +- It reproduces the repository runner's in-memory settings derivation, adding one module exclusion + matching any module name ending in `.Test.dll`, so both test assemblies are excluded from + instrumentation and the denominator holds production code only. The canonical coverage.config file + is read and never written. +- It reuses the repository runner's shape: `dotnet-coverage collect`, the cobertura output format, the + off-root CLI runsettings at scripts/vscode/TaskMaster.cli.runsettings, and the `/InIsolation` switch. +- No repository script under the vscode scripts directory is modified. +- It is a session-scoped throwaway created in Phase 0 and deleted in P6-T11, so no Pester test is + authored for it and it is not a production PowerShell file for coverage or budget purposes. + +## The three backslash-bearing literals authored verbatim into the helper + +```text +${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe +Common7\IDE\Extensions\TestPlatform\vstest.console.exe +MSBuild\**\Bin\MSBuild.exe +``` + +Output Summary: The helper self-check exited 0 and printed one `VSTEST-RESOLVED=` line, one +`MSBUILD-RESOLVED=` line and one `HELPER-FUNCTIONS=` line naming thirteen functions. Both resolved +executables are present on this workstation under Visual Studio 18 Community. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-instructions-read.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-instructions-read.2026-09-06T22-00.md new file mode 100644 index 000000000..e64d45180 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-instructions-read.2026-09-06T22-00.md @@ -0,0 +1,36 @@ +# Phase 0 — Policy Instructions Read (Issue #797) + +Timestamp: 2026-09-07T09-10 + +Policy Order: CLAUDE.md, then .claude/rules/general-code-change.md, then .claude/rules/general-unit-test.md, then .claude/rules/quality-tiers.md, then .claude/rules/csharp.md, then .claude/rules/tonality.md. + +## Files read, in the required order + +1. CLAUDE.md — 448 lines +2. .claude/rules/general-code-change.md — 81 lines +3. .claude/rules/general-unit-test.md — 106 lines +4. .claude/rules/quality-tiers.md — 52 lines +5. .claude/rules/csharp.md — 97 lines +6. .claude/rules/tonality.md — 81 lines + +All six files were read in full from this worktree at the current HEAD before any task in this plan +performed a write. + +## Constraints carried forward into execution + +- C# toolchain order: csharpier format, csharpier check, msbuild analyzer rebuild, msbuild + warnings-as-errors rebuild, vstest. Restart from step 1 on any failure or file change. +- Always `/t:Rebuild`, never `/t:Build`. Never add a solution-wide `/p:Nullable=enable`. +- MSTest, Moq, FluentAssertions. Arrange-Act-Assert. No temporary files in tests. No `Thread.Sleep`, + no `Task.Delay`, no real wall-clock waits. +- 500-line cap on production, test and reusable script files. Markdown documents are exempt. +- Coverage: CLAUDE.md sets the repository-wide line floor at 80 percent and requires 90 percent for + new and changed code, with no regression on changed lines. `.claude/rules/general-unit-test.md` + records 85 percent line and 75 percent branch figures; plan rule R8 governs which of these is the + binding gate for this change. +- Tone: professional, factual, neutral. No humor, hyperbole, or decorative metaphor. + +EXIT_CODE: 0 + +Output Summary: All six policy files were read in the required order and their line counts recorded. +No conflict requiring a halt was found between them and this plan. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-nullable-build.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-nullable-build.2026-09-06T22-00.md new file mode 100644 index 000000000..a77fe8183 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-nullable-build.2026-09-06T22-00.md @@ -0,0 +1,34 @@ +# Phase 0 — Nullable and Warnings-as-Errors Rebuild Baseline (Issue #797) + +Timestamp: 2026-09-07T09-17 + +Command: `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-baseline-nullable.log"` + +EXIT_CODE: 0 + +No solution-wide nullable enable property was supplied to this command. `/p:Nullable=enable` is +deliberately absent from CI and from this run: no project in this repository carries a `` +element, so the property is a solution-wide opt-in that would conscript every file that has never +adopted the per-file `#nullable enable` pragma. Nullable enforcement in this repository is per-file +opt-in and `/p:TreatWarningsAsErrors=true` promotes the `CS86xx` diagnostics of files that have opted +in. `/t:Rebuild` was used, not `/t:Build`, so the compile target ran on every project. + +## Discrimination, per rule R4 + +- Process exit code: 0. +- Summary line ` 0 Error(s)` is present in the file log, at log line 66779. +- Warning count from the summary: 0, on the immediately preceding line as ` 0 Warning(s)`. +- The summary block reads `Build succeeded.` followed by the two count lines above. + +BASELINE-DIAGNOSTIC-IDS: + +(empty — the build is clean, so no diagnostic identifier was reported as an error) + +Both branches are recorded. The clean branch applies, so the subset comparison the non-clean branch +would use is not entered here; P5-T4 carries the same two branches and resolves against this empty +set. + +Output Summary: The warnings-as-errors rebuild is clean at the baseline. Exit code 0, zero warnings, +zero errors, empty baseline diagnostic identifier set, and no solution-wide nullable property +supplied. The full detailed log was written to the git-ignored coverage directory and is not +committed; only these sanitized summary fields are recorded here. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-requirements-read.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-requirements-read.2026-09-06T22-00.md new file mode 100644 index 000000000..39bb36d34 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-requirements-read.2026-09-06T22-00.md @@ -0,0 +1,40 @@ +# Phase 0 — Requirements Sources Read (Issue #797) + +Timestamp: 2026-09-07T09-11 + +Command: `Get-FileHash -Algorithm SHA256 -Path ` + +EXIT_CODE: 0 + +## Sources read and their SHA-256 digests + +| Source (repository-relative) | SHA-256 | +|---|---| +| docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md | D1C87062FCF7FF6D61C2B4BD099C366B50A5587920298E3CD0E9322998493C23 | +| docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md | 7404868CE8EF64EC9353077362049F6231F4237046F8F8810D453DA83DD27EEB | +| docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/research/research-folder-settings-persistence.md | E73FE0CD8CAC6482E37D7488A305B5506DDC0E6D8D677BB9BBE993761AF0D3AA | + +All three files were read in full. + +## Acceptance-criteria section confirmation + +spec.md contains a section headed exactly `## Acceptance Criteria`. That section holds exactly eight +checkbox criteria, identified AC1 through AC8, each unchecked at the time of this read: + +1. AC1 — fresh-build path adopts the resource-defined disk configuration. +2. AC2 — the serializer logs an error rather than returning silently on an empty or null path. +3. AC3 — a saved value survives an Outlook restart (manual verification). +4. AC4 — an explicit Save is not lost inside the three-second deferred-write window. +5. AC5 — the junk-folder double-persistence path fails loudly and the reflection lookup is replaced by + a typed seam. +6. AC6 — User Email shows the SMTP address, with a specific failure message, a fallback chain, and a + retry on dialog open. +7. AC7 — Inbox and Root Folder are displayed without the leading backslash pair. +8. AC8 — a null current store selection renders the placeholder text instead of throwing. + +The identical eight criteria appear in issue.md under the `## Proposed Fix / Validation Ideas` +heading. Work Mode is `full-bug`, recorded in the issue.md metadata block, so spec.md is the single +authoritative acceptance-criteria source and issue.md carries the mirror. + +Output Summary: Three requirements sources read and digested. spec.md carries exactly eight criteria +AC1 through AC8 under a `## Acceptance Criteria` heading; issue.md carries the matching mirror. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-scope-baseline.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-scope-baseline.2026-09-06T22-00.md new file mode 100644 index 000000000..77af09d55 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-scope-baseline.2026-09-06T22-00.md @@ -0,0 +1,77 @@ +# Phase 0 — Anchored Change-Set Baseline for the Phase 5 Scope Gate (Issue #797) + +Timestamp: 2026-09-07T09-21 + +Commands: + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md' -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git diff --name-status $BaseSha HEAD +git status --porcelain --untracked-files=all +``` + +EXIT_CODE: 0 (for the anchored diff command) + +SCOPE-BASELINE-COMMITTED: + +```text +A docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md +A docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/plan.2026-09-06T22-00.md +A docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/research/research-folder-settings-persistence.md +A docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md +A docs/features/potential/promoted/2026-09-06-folder-settings-never-persist-and-user-email-error-loading.md +``` + +This listing covers work already committed on this branch before execution began: the five +preparation documents. No source or project file appears in it. + +SCOPE-BASELINE-WORKTREE: + +```text + M .claude/agent-memory/atomic-planner/MEMORY.md + M .claude/agent-memory/prd-feature/feedback_backticked_paths_are_the_change_footprint.md + M .claude/agent-memory/task-researcher/MEMORY.md + M docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/plan.2026-09-06T22-00.md +?? .claude/agent-memory/atomic-planner/project_797_folder_settings_persistence_plan_seams.md +?? .claude/agent-memory/task-researcher/project_folder_settings_persistence_797.md +?? docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-analyzer-build.2026-09-06T22-00.md +?? docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md +?? docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-bootstrap.2026-09-06T22-00.md +?? docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-csharpier-check.2026-09-06T22-00.md +?? docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-file-sizes.2026-09-06T22-00.md +?? docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-helper-selfcheck.2026-09-06T22-00.md +?? docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-instructions-read.2026-09-06T22-00.md +?? docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-nullable-build.2026-09-06T22-00.md +?? docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-requirements-read.2026-09-06T22-00.md +?? docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-vstest.2026-09-06T22-00.md +``` + +This listing covers uncommitted work. It includes the pre-existing agent-memory modifications left by +the preparation subagents, which are outside this item's Write Set and are neither edited nor +reverted nor committed by this execution, and the Phase 0 evidence artifacts written so far plus this +plan file's task check-offs. The session helper at coverage/plan797-helpers.ps1 and the coverage +outputs do not appear because the coverage directory is git-ignored. + +A path may appear in both listings — this plan file appears in both, as an addition in the committed +set and as a modification in the worktree set. The two listings are therefore overlapping rather than +complementary. Phase 5 subtracts the union of the two sets from its own working set, so both are +captured here rather than inferred later. + +PREPARATION-TRACKED: + +`git ls-files --error-unmatch` over the five preparation documents exited 0 and echoed all five paths, +confirming each is tracked in HEAD: + +```text +docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md +docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/plan.2026-09-06T22-00.md +docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/research/research-folder-settings-persistence.md +docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md +docs/features/potential/promoted/2026-09-06-folder-settings-never-persist-and-user-email-error-loading.md +``` + +No path was reported as untracked, so there is nothing to report to the caller before Phase 1 begins. + +Output Summary: The committed baseline holds five preparation documents and no source file. The +worktree baseline holds five pre-existing agent-memory residuals, this plan file, and the Phase 0 +evidence artifacts. All five preparation documents are tracked in HEAD. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-vstest.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-vstest.2026-09-06T22-00.md new file mode 100644 index 000000000..75d1d1c35 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-vstest.2026-09-06T22-00.md @@ -0,0 +1,78 @@ +# Phase 0 — Baseline Test Run (Issue #797) + +Timestamp: 2026-09-07T09-19 + +Command: `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Test -FilterName All -ResultsDirectory coverage/plan797-trx/baseline` + +The helper resolves vstest.console.exe through vswhere and invokes it over the two explicitly named +test assemblies UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll and +TaskMaster.Test/bin/Debug/TaskMaster.Test.dll, never by directory discovery, with `/InIsolation`, the +off-root CLI runsettings, and a TRX logger writing under coverage/plan797-trx/baseline. + +EXIT_CODE: 0 + +ExpectedExitCode: 0 + +## Output Summary + +Counts read from the results file, not from console text: + +- Total: 5237 +- Passed: 5237 +- Failed: 0 +- Skipped: 0 + +The skipped count is derived from the results-file counters as total minus executed (5237 - 5237 = 0), +not from console text. A green run prints no `Skipped` line and the results file writes its +not-executed counter as zero, so a console-derived figure would be unreadable on exactly this run. + +Exact filter expression used: + +```text +TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests +``` + +Four shell-icon test classes are excluded from this run for environmental reasons unrelated to this +change: `HelperClasses.ShellUtilities_Tests`, `HelperClasses.ShellUtilitiesStatic_Tests`, +`HelperClasses.SysImageListHelperTests` and `EmailIntelligence.OSBrowser_Tests`. They stall +vstest on this workstation. CI covers them. + +The results file itself is not committed; it is written to the git-ignored coverage directory because +a test results file carries `runUser` and `computerName` attributes. Only the sanitized counts above +are recorded here. + +BASELINE-FAILING-TESTS: NONE + +The run is green, so Phase 1 and Phase 5 subtract an empty set. Any test failing in a later run is +therefore attributable to this change unless it is separately identified. + +Note on a known intermittent failure recorded by the caller: the test +`UtilitiesCS.Test.Extensions.DfDeedle_COM_Tests.GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` +is tracked as a nondeterministic timing race under issue 803 and is outside this item's Write Set. It +passed in this baseline run, so it is not a member of `BASELINE-FAILING-TESTS:`. If it fails in a +later run it is handled as a known flake: the scoped invocation is re-run once, both attempts are +recorded, and issue 803 is named. + +BASELINE-CONTROLLER-SCOPE: + +Derived from this same results file by selecting the four named test classes, so no second run was +performed. + +- Total: 82 +- Passed: 82 +- Failed: 0 +- Failing test names within this narrower scope: none. + +Exact text of the narrower filter expression P1-T3 uses: + +```text +(FullyQualifiedName~StoreWrapperController_Tests&TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests)|(FullyQualifiedName~StoreWrapperControllerTests&TestCategory!=LiveOutlook)|(FullyQualifiedName~StoreWrapperTests&TestCategory!=LiveOutlook)|(FullyQualifiedName~StoreWrapperViewerTests&TestCategory!=LiveOutlook) +``` + +Per rule R6 the `TestCategory!=LiveOutlook` clause is repeated on every disjunct because `&` binds +tighter than `|` in a vstest filter expression. The four shell-icon exclusion clauses are conjunctive +and therefore bind to the first disjunct alone; that is inert here because no shell-icon test name +matches any selector used in the scoped runs. + +Output Summary: The baseline test run is green. 5237 total, 5237 passed, 0 failed, 0 skipped, exit +code 0. The narrower controller scope holds 82 tests, all passing. `BASELINE-FAILING-TESTS: NONE`. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/issue-updates/issue-797.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/issue-updates/issue-797.2026-09-06T22-00.md new file mode 100644 index 000000000..bb064d031 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/issue-updates/issue-797.2026-09-06T22-00.md @@ -0,0 +1,78 @@ +# POSTING BLOCKED — Issue #797 update mirror + +Timestamp: 2026-09-07T10-15 + +PostedAs: unknown + +Reason posting is blocked: the executing agent is directed not to create a pull request, not to run +GitHub CLI commands that create or edit issues or pull requests, and not to merge anything. Issue and +pull-request authoring is retained by the caller. The text below is therefore recorded as the intended +update and is not posted from this session. Because `PostedAs:` is not `body`, no additional mirror +into the feature-folder issue.md is made beyond the acceptance-criteria check-offs that P6-T2 through +P6-T9 already applied there. + +--- + +## Exact text intended for the issue + +Both root causes are fixed and seven of the eight acceptance criteria are verified by automated test. +AC3 requires a live Outlook restart and is handed over for manual verification. + +**Root cause 1 — the bootstrap gap between the loader and the serializer.** + +- AC1: `LoadStoresAsync` now applies the already-resolved loader configuration to the freshly built + stores wrapper, so a fresh install carries the resource-defined path instead of an empty one. The + fix is confined to the store-loading globals partial; the shared deserialize overload is unchanged, + because its null return is a load-bearing fail-soft contract for the folder-predictor load path. +- AC2: `SmartSerializable.Serialize()` now rejects a null or empty configured path with an + error-level log naming the serialized item type and the rejected value, instead of returning + silently. The previous guard compared only against the empty string, so a null path passed it. +- AC4: a new explicit-save entry point writes inline through the existing thread-safe write method + rather than through the three-second deferred timer, so a save is not lost when Outlook exits inside + that window. The deferred behaviour for every other caller is unchanged, and that is pinned by its + own test. The AC2 guard is evaluated first so the fix does not substitute one silent failure for + another. +- AC5: the junk-folder call site no longer locates its target by reflecting over a method name. A new + narrow interface in UtilitiesCS is implemented explicitly by the TaskMaster globals partial, so the + call is compile-checked and a globals implementation that does not provide the seam is reported at + error level rather than as a warning. + +**Root cause 2 — the unretried COM failure in the Exchange SMTP lookup.** + +- AC6: the lookup now falls back in a fixed order — the Exchange primary SMTP address, then the + address entry's own address when it contains an at-sign, then the store display name when it does — + with per-step COM handling instead of one outer catch. The failure reason is captured, the dialog + renders a specific unavailability message naming that reason instead of the generic placeholder, and + the lookup is retried once per dialog open when the address is null. + +**Adjacent defects in the same rendering method.** + +- AC7: Inbox and Root Folder are rendered without the leading store prefix. +- AC8: a null current store selection now renders the existing placeholder text instead of throwing. + One existing test that asserted the throw is deliberately inverted; the inverted assertion is + stricter, pinning specific rendered values rather than an exception type. + +**AC3 — manual verification outstanding.** A value saved in Folder Settings surviving an Outlook +restart cannot be verified without a live VSTO host. The automated tests establish that the disk path +is now populated and that the explicit save writes through the injectable seam, but not that the file +appears on disk in a live host. A nine-step manual procedure is recorded in the feature folder under +`evidence/other/`, together with a fail-before exception dossier explaining why no automated failing +run is possible. + +**Verification.** CSharpier format and check, the analyzer rebuild and the warnings-as-errors rebuild +all exit 0 with zero warnings and zero errors. The scoped test run over the two affected assemblies is +green at 5262 passed and 0 failed, up from 5237 at baseline; all sixteen tests that failed before the +fix now pass. Changed-line coverage over the executable, non-relocated changed lines is 91.09 percent, +and document-level line coverage did not regress, moving from 53.23 to 53.26 percent under the same +two-assembly scope. + +**Known limitations, recorded rather than resolved.** The serializer file remains over the 500-line +cap, a pre-existing condition this change deliberately does not resolve. The AC6 retry reintroduces one +synchronous Outlook COM read on the UI thread at dialog-open time, bounded to a single lookup and only +when the address is null; a genuinely non-blocking read is out of scope. The QuickFiler +recipient-resolution blocking hazard is a different caller of the same Outlook getter and is not fixed +here. Four shell-icon test classes are excluded from every local run for environmental reasons +unrelated to this change; CI covers them. + +Output Summary: The intended issue update is recorded verbatim above and was not posted from this +session, because issue and pull-request authoring is retained by the caller. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/other/p6-t1-ac3-manual-verification.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/other/p6-t1-ac3-manual-verification.2026-09-06T22-00.md new file mode 100644 index 000000000..4c73eecb6 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/other/p6-t1-ac3-manual-verification.2026-09-06T22-00.md @@ -0,0 +1,101 @@ +# AC3 — manual verification handoff + +Timestamp: 2026-09-07T10-13 +Task: [P6-T1] +Issue: #797 + +AC3-RESULT: BLOCKED-MANUAL + +## Status + +**BLOCKED, PENDING MANUAL.** AC3 is not checked off by this plan and its checkbox in spec.md and in +issue.md remains `- [ ]`. + +AC3 reads: "A value saved in Folder Settings is present after an Outlook restart (manual +verification)." It requires a live Outlook process with this build of the VSTO add-in loaded, a real +user profile directory, and a full process teardown and restart. This execution environment has no +live Outlook host, and the executing agent is directed not to start one, load the add-in, or drive any +user interface. The criterion is therefore verifiable only by a person driving the add-in, and this +document is the handoff to the maintainer. + +No AC3 result is fabricated. Every step of the procedure below is recorded as NOT PERFORMED, with the +reason, rather than given an invented observation. + +## Why no note is written beside the AC3 checkbox + +spec.md states that the eight criteria are reproduced verbatim from the criteria settled with the +maintainer on 2026-09-06 and are not renumbered, reordered, dropped, merged, split or reworded. The +acceptance-criteria-tracking skill independently permits exactly one edit to a criterion line: +changing `- [ ]` to `- [x]`. Annotating the AC3 line would violate both. The blocked status is +therefore recorded here and in the P6-T11 acceptance-criteria status summary instead of beside the +criterion. + +## What the automated evidence does and does not establish + +The automated tests delivered by this change establish the mechanism that produces the AC3 symptom and +the mechanism of its fix: + +- The fresh-build path now adopts the loader's disk configuration, so the wrapper carries the + resource-defined path rather than an empty one (AC1, proven by + `LoadStoresAsync_WhenConfigDeserializesToNull_FreshWrapperAdoptsLoaderDiskConfiguration`). +- The serializer no longer returns silently on an empty or null path (AC2). +- An explicit Save writes inline rather than through the three-second deferred timer, so the write is + not lost if the host exits inside that window (AC4). + +They do not establish that the file appears on disk in a live VSTO host. That residual is exactly what +the procedure below covers. The fail-before requirement for AC3 is discharged by the exception dossier +at `evidence/regression-testing/fail-before-exception.2026-09-06T22-00.md`. + +## The nine-step procedure, verbatim from the plan, with observed results + +Each step records `NOT PERFORMED` together with the reason. + +1. Confirm the settings file `StoresWrapper.json` does not exist under the local AppData TaskMaster + directory, recording the check without recording the absolute path of the user profile. + - **NOT PERFORMED.** Requires a real user profile directory on the machine that will run the + add-in. The executing environment is a build worktree with no add-in installation. +2. Build and load the add-in and start Outlook. + - **NOT PERFORMED.** No live Outlook host is available, and the executing agent is directed not to + start one or load the add-in. +3. Open Settings, then Folder Settings, and record the rendered Archive Root Outlook, Archive Root + File System, Junk Potential, Junk Email, User Email, Inbox and Root Folder values. + - **NOT PERFORMED.** Requires the dialog, which requires the live host. Driving any user interface + is out of scope for this execution. +4. Select an Archive Root Outlook value and click Save. + - **NOT PERFORMED.** Same reason as step 3. +5. Confirm the settings file now exists. + - **NOT PERFORMED.** Depends on step 4. +6. Close Outlook fully and reopen it. + - **NOT PERFORMED.** Depends on step 2. +7. Reopen Folder Settings and confirm the saved value is present. + - **NOT PERFORMED.** Depends on steps 4 and 6. This is the step that actually decides AC3. +8. Confirm the session log contains no serializer error and no line reporting an empty or null + settings path. + - **NOT PERFORMED.** Requires a session log from a live run. +9. Check the junk-folder rollout consideration recorded as risk 4 in spec.md by confirming whether the + junk selections shown agree with the .NET user settings. + - **NOT PERFORMED.** Requires the dialog and the live settings store. + +## Notes for the verifier + +- Step 9 is the check for the known, accepted rollout consequence of the AC5 reading. Once AC1 lands, + the per-store JSON mechanism begins writing for the first time on affected machines while the .NET + user settings already hold values written by the second mechanism. The two can disagree on a machine + where junk folders were last selected under a non-default store, because one resolves relative paths + against the selected store's root and the other against the default store's root. A first-run + disagreement is expected and is not a defect introduced by this change. +- Step 3 will show the User Email label carrying either the mailbox SMTP address or a specific + unavailability message that names the reason. It will no longer show the generic placeholder that + the Inbox and Root Folder labels use, because AC6 replaced that literal for this label only. +- Steps 3 and 7 will show the Inbox and Root Folder values without the leading pair of backslash + characters, per AC7. +- When all nine steps pass, AC3 may be checked off by changing `- [ ]` to `- [x]` on that line in + spec.md and mirroring the same single-character change in issue.md, and nothing else. If any step + fails, do not check it off; report the failure against issue #797. + +No absolute host path, user account name or machine name appears in this artifact. + +Output Summary: AC3 is recorded as BLOCKED-MANUAL. All nine procedure steps are NOT PERFORMED because +the criterion requires a live VSTO host that this execution environment does not have and is directed +not to create. The criterion is handed to the maintainer, and its checkbox is left unmarked and +byte-identical to its authored text in both spec.md and issue.md. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p4-t8-file-sizes-preformat.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p4-t8-file-sizes-preformat.2026-09-06T22-00.md new file mode 100644 index 000000000..f6fbc4f17 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p4-t8-file-sizes-preformat.2026-09-06T22-00.md @@ -0,0 +1,47 @@ +# P4-T8 — Pre-format File-size Census (Issue #797) + +Timestamp: 2026-09-07T09-55 + +Command: `Get-Content -LiteralPath | measure line count`, over the thirteen paths below, run +from the repository root of this worktree. + +EXIT_CODE: 0 + +This is a pre-format census taken before the Phase 5 formatter runs. The binding audit is P5-T8. + +| Path | Phase 0 lines | Current lines | +|---|---|---| +| TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs | 75 | 90 | +| TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs | 186 | 198 | +| UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs | 613 | 658 | +| UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs | 233 | 302 | +| UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs | 478 | 388 | +| UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs | 396 | 402 | +| UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs | 216 | 361 | +| UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs | 285 | 416 | +| TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs | 347 | 429 | +| UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs | not yet created | 29 | +| UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs | not yet created | 173 | +| UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs | not yet created | 349 | +| UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs | not yet created | 252 | + +Every listed C# path is at or below 500 lines except the serializer. + +## The one exception + +UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs is 658 lines against the +500-line cap. Its Phase 0 count was 613, already over the cap before any change here. Design decision +D5 declares that pre-existing violation out of scope for this work item: splitting a shared +reusable-type-classes file during a parallel run would create merge contention with concurrently +running sibling work items. This change adds 45 lines to that file — the shared path guard with its +documentation, the explicit-save entry point body, and the comments explaining both — and does not +split it. This change does not resolve the pre-existing violation and does not introduce a new +violation class. + +UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs fell from 478 to 388 lines because D4's +display partial took the three rendering members. The partial itself is 173 lines, so both files sit +comfortably under the cap, which is the outcome D4 was designed to produce. + +Output Summary: Twelve of the thirteen C# paths are at or below 500 lines. The serializer is at 658, +a pre-existing over-cap condition D5 declares out of scope. No project file is enumerated, because +the cap does not reach project files. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t1-csharpier-format.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t1-csharpier-format.2026-09-06T22-00.md new file mode 100644 index 000000000..ad63f6699 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t1-csharpier-format.2026-09-06T22-00.md @@ -0,0 +1,71 @@ +# P5-T1 — CSharpier Format (Issue #797) + +Timestamp: 2026-09-07T09-57 + +Commands, run from the repository root of this worktree: + +```powershell +git status --porcelain --untracked-files=all -- '*.cs' | Out-File -Encoding utf8 coverage/plan797-format-before.txt +dotnet tool run csharpier format . +git status --porcelain --untracked-files=all -- '*.cs' | Out-File -Encoding utf8 coverage/plan797-format-after.txt +``` + +EXIT_CODE: 0 + +## Summary count the formatter printed + +`Formatted 1605 files in 3488ms.` + +Per rule R5 that line reports the number of files processed, not the number rewritten, so it does not +by itself distinguish a clean run from a repairing one. The observations below supply that +discrimination. + +## Set difference between the after and the before listings + +The difference is empty: both listings contain the same thirteen entries. + +```text + M TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs + M TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs + M TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs + M UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs + M UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs + M UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs + M UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs + A UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs + M UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs + M UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs +?? UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs +?? UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs +?? UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs +``` + +The Phase 0 artifact recorded `PRE-EXISTING-FORMAT-DRIFT: NONE`, so the acceptance requires every path +in the difference to be a Write Set path. The difference is empty, so that condition holds. Every one +of the thirteen paths listed above is nevertheless a Write Set path, and no path outside the Write Set +appears in either listing. No path enumerated as pre-existing drift needed reverting, because none was +recorded. + +## Direct rewrite observation, beyond the porcelain listing + +A porcelain listing cannot report a rewrite of a file that is already marked modified: such a file +stays marked modified whether or not the formatter touched it. This step therefore also hashed each of +the thirteen Write Set C# files immediately before and immediately after the formatter ran, with +SHA-256, and compared the two hashes. + +```text +FORMAT-REWRITTEN-COUNT=0 +FORMAT-EXIT=0 +``` + +Zero Write Set files were rewritten by this run, which is the direct evidence that this formatter pass +is a clean pass rather than a repairing one. + +## Loop restart + +An earlier execution of this step, before the pass recorded here, did rewrite files. Under the Phase 5 +loop rule that outcome restarts the loop at P5-T1, and the pass recorded above is the restarted pass. +The restart is recorded in the P5-T10 clean-pass artifact. + +Output Summary: The formatter exits 0, processes 1605 files, rewrites zero Write Set files, and leaves +the C#-scoped porcelain listing unchanged. This is the clean formatter pass. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t10-clean-pass.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t10-clean-pass.2026-09-06T22-00.md new file mode 100644 index 000000000..b47430b4c --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t10-clean-pass.2026-09-06T22-00.md @@ -0,0 +1,36 @@ +# P5-T10 — Clean-pass Confirmation (Issue #797) + +Timestamp: 2026-09-07T10-13 + +The six commands of the Phase 5 loop, P5-T1 through P5-T6, in order, with the exit code each produced +on the final pass. + +1. `dotnet tool run csharpier format .` — EXIT_CODE: 0 +2. `dotnet tool run csharpier check .` — EXIT_CODE: 0 +3. `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-final-analyzers.log"` — EXIT_CODE: 0 +4. `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-final-nullable.log"` — EXIT_CODE: 0 +5. `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Test -FilterName All -ResultsDirectory coverage/plan797-trx/p5` — EXIT_CODE: 0 +6. `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Coverage -FilterName All -OutputPath coverage/plan797-final/coverage.cobertura.xml -ResultsDirectory coverage/plan797-trx/p5-coverage` — EXIT_CODE: 0 + +Command 5 is the plan's `vstest.console.exe /EnableCodeCoverage` step in the +form rule R6 requires: explicit assembly paths rather than directory discovery, the `/InIsolation` +switch CI uses, and the shell-icon exclusions. Command 6 supplies the coverage collection. + +## Loop restarts + +Number of loop restarts performed: 1. + +Reason for the restart: the first execution of P5-T1 rewrote files. The formatter is a write-mode +command, and under the Phase 5 loop rule a step that changes files restarts the loop at P5-T1. That +first run reformatted source that this change had edited but not yet formatted. + +The restarted pass is the one recorded above. Its formatter run rewrote zero Write Set files, verified +by hashing all thirteen Write Set C# files immediately before and immediately after the run with +SHA-256 and comparing, which reported `FORMAT-REWRITTEN-COUNT=0`. The read-only check subcommand then +exited 0 over 1605 files with no unformatted file reported. + +The final pass required no restart: no step failed against its declared expectation and no step +changed files. Every one of the six steps declared an expected exit code of 0 and produced 0. + +Output Summary: P5-T1 through P5-T6 completed in a single uninterrupted pass with all six exit codes +at 0, after exactly one restart caused by the first formatter run rewriting files. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t2-csharpier-check.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t2-csharpier-check.2026-09-06T22-00.md new file mode 100644 index 000000000..9f84023fd --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t2-csharpier-check.2026-09-06T22-00.md @@ -0,0 +1,26 @@ +# P5-T2 — CSharpier Check (Issue #797) + +Timestamp: 2026-09-07T09-58 + +Command, run from the repository root of this worktree: + +```text +dotnet tool run csharpier check . +``` + +EXIT_CODE: 0 + +## Output Summary + +`Checked 1605 files in 6628ms.` The `Checked` summary line reports 1605 files, four more than the +1601 recorded at the Phase 0 baseline, which is exactly the four C# files this change creates. The run +reported no unformatted file. + +The read-only check subcommand's exit code is a real signal, unlike the write-mode format +subcommand's, so this step rather than P5-T1 decides the formatting gate. The alternative acceptance +branch — a non-zero exit whose reported unformatted paths are a subset of a recorded +`PRE-EXISTING-FORMAT-DRIFT:` set — is not entered, because the P0-T6 artifact recorded +`PRE-EXISTING-FORMAT-DRIFT: NONE` and this run exited 0. No `ExpectedExitCode:` field is carried, +which is equivalent to declaring 0. + +Output Summary: Formatting is clean. Exit code 0, 1605 files checked, zero unformatted files. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t3-analyzer-build.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t3-analyzer-build.2026-09-06T22-00.md new file mode 100644 index 000000000..9065a83b1 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t3-analyzer-build.2026-09-06T22-00.md @@ -0,0 +1,30 @@ +# P5-T3 — Final Analyzer Rebuild (Issue #797) + +Timestamp: 2026-09-07T10-00 + +Command: `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-final-analyzers.log"` + +EXIT_CODE: 0 + +MSBuild was invoked through the absolute path vswhere resolved. `/t:Rebuild` was used, not `/t:Build`, +so `CoreCompile` ran on every project and the analyzers actually executed. + +## Discrimination, per rule R4 + +- Process exit code: 0. +- Summary line ` 0 Error(s)` is present in the file log, at log line 69616, preceded by + `Build succeeded.` and ` 0 Warning(s)`. +- Warning count: 0. The Phase 0 warning count was also 0, so the warning count is unchanged by this + change. + +The Phase 0 analyzer baseline was clean, so the primary acceptance branch applies. The alternative +branch — a recorded diagnostic identifier set that must be a subset of `BASELINE-DIAGNOSTIC-IDS:` and +contain no diagnostic attributed to a Write Set file — is not entered, because this run reported no +diagnostic at all. + +This run is the analyzer gate for the Phase 4 edits, including the removal of the `System.Reflection` +using directive from the store wrapper controller: an unused using would have been reported here, and +none was. + +Output Summary: The analyzer rebuild is clean after all four implementation phases. Exit code 0, zero +warnings, zero errors, and an unchanged warning count against the Phase 0 baseline. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t4-nullable-build.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t4-nullable-build.2026-09-06T22-00.md new file mode 100644 index 000000000..ccdc211df --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t4-nullable-build.2026-09-06T22-00.md @@ -0,0 +1,33 @@ +# P5-T4 — Final Type-check Rebuild (Issue #797) + +Timestamp: 2026-09-07T10-02 + +Command: `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-final-nullable.log"` + +EXIT_CODE: 0 + +No solution-wide nullable enable property was supplied to this command. `/p:Nullable=enable` is +deliberately absent from CI and from this run: no project in this repository carries a `` +element, so the property is a solution-wide opt-in that would conscript every file which has never +adopted the per-file `#nullable enable` pragma. Nullable enforcement here is per-file opt-in, and +`/p:TreatWarningsAsErrors=true` promotes the `CS86xx` diagnostics of the files that have opted in. +`/t:Rebuild` was used, not `/t:Build`. + +## Discrimination, per rule R4 + +- Process exit code: 0. +- Summary line ` 0 Error(s)` is present in the file log, at log line 69081, preceded by + `Build succeeded.` and ` 0 Warning(s)`. + +The Phase 0 nullable baseline was clean, so the primary acceptance branch applies and the subset +comparison of the alternative branch is not entered. + +Three of the five modified production files and both created production files carry the per-file +nullable pragma and therefore participate in this gate: the store wrapper, the store wrapper +controller, the new display partial and the serializer all open with `#nullable enable`. The new +display partial carries that pragma on its first line precisely so the annotations on the relocated +members keep their nullable context; without it the compiler would report CS8632 on each of them and +this gate would fail. + +Output Summary: The warnings-as-errors rebuild is clean after all four implementation phases. Exit +code 0, zero warnings, zero errors, and no solution-wide nullable property supplied. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t5-vstest.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t5-vstest.2026-09-06T22-00.md new file mode 100644 index 000000000..974628903 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t5-vstest.2026-09-06T22-00.md @@ -0,0 +1,83 @@ +# P5-T5 — Final Scoped Test Run (Issue #797) + +Timestamp: 2026-09-07T10-05 + +Command: `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Test -FilterName All -ResultsDirectory coverage/plan797-trx/p5` + +The helper resolves vstest.console.exe through vswhere and invokes it over the two explicitly named +test assemblies UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll and +TaskMaster.Test/bin/Debug/TaskMaster.Test.dll, never by directory discovery, with `/InIsolation`. + +EXIT_CODE: 0 + +ExpectedExitCode: 0 + +The declared expectation equals the exit code this run actually produced. Because the exit code is 0 +the failed count is 0, and the alternative branch — exit code 1 with every failing test a member of +`BASELINE-FAILING-TESTS:` — is not entered. The P0-T9 artifact recorded +`BASELINE-FAILING-TESTS: NONE`, so no baseline failing name needed to be checked for reproduction, and +`PRE-EXISTING-IN-WRITE-SET:` is empty. + +## Counts, read from the results file + +- Total: 5262 +- Passed: 5262 +- Failed: 0 +- Skipped: 0 (total minus executed) + +The skipped count is derived from the results-file counters as total minus executed, not from console +text, because a green run prints no `Skipped` line and the results file writes its not-executed +counter as zero. + +The baseline recorded 5237 tests. This change adds 25: two for AC1, four in the new serializer guard +file for AC2 and AC4, two for AC5, five in the store wrapper file for AC6 fallback ordering and the +retry entry point's null-root-folder safety, and twelve in the new controller display partial for AC6 +retry, AC7 and AC8. + +## Filter expression used + +```text +TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests +``` + +Four shell-icon test classes are excluded from this run for environmental reasons unrelated to this +change: `HelperClasses.ShellUtilities_Tests`, `HelperClasses.ShellUtilitiesStatic_Tests`, +`HelperClasses.SysImageListHelperTests` and `EmailIntelligence.OSBrowser_Tests`. CI covers them. + +The results file itself is not committed, because it carries `runUser` and `computerName` attributes. +Only the sanitized counts above are recorded here. + +## PASS-AFTER correspondence, complete rather than sampled + +The run executed 5262 tests with zero failures, so every one of the sixteen names recorded as a +`FAIL-BEFORE:` entry in the P1-T18 artifact passed. They are enumerated here in full. + +- PASS-AFTER: TaskMaster.Test.AppGlobals.AppOlObjectsCoverageTests.LoadStoresAsync_WhenConfigDeserializesToNull_FreshWrapperAdoptsLoaderDiskConfiguration +- PASS-AFTER: UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.Serialize_WithEmptyDiskPath_LogsErrorAndArmsNoTimer +- PASS-AFTER: UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.Serialize_WithNullDiskPath_LogsErrorAndArmsNoTimer +- PASS-AFTER: UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.SerializeNow_WithConfiguredPath_WritesWithoutFiringTimer +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperControllerTests.PersistJunkFolderSelections_WhenGlobalsAreNotTheTypedSink_LogsErrorAndDoesNotInvoke +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenPrimarySmtpThrows_FallsBackToAddressEntryAddress +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenPrimaryAndAddressEntryFail_FallsBackToDisplayName +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenEveryFallbackFails_ReturnsNullAndCapturesReason +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WhenUserEmailIsNull_RetriesLookupAndRendersAddress +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WhenRetryFails_RendersSpecificMessageWithReason +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.TrimStorePrefix_WithLeadingStorePrefix_RemovesIt +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.TrimStorePrefix_WithOnlyTheStorePrefix_ReturnsEmptyString +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_RendersInboxAndRootFolderWithoutStorePrefix +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_NullCurrent_SetsErrorLoadingText +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WithNullCurrent_RendersPlaceholdersAndDoesNotThrow +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.GetRelativeFsPath_WithNullCurrent_ReturnsPlaceholderAndDoesNotThrow + +No name was carried under `PRE-EXISTING-FAILURES:` in the P1-T18 artifact, so no name is excluded from +that correspondence and none is listed separately. + +PRE-EXISTING-IN-WRITE-SET: NONE + +Note on the intermittent failure recorded by the caller: the test +`UtilitiesCS.Test.Extensions.DfDeedle_COM_Tests.GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform`, +tracked as a nondeterministic timing race under issue 803 and outside this item's Write Set, passed in +this run as it did at baseline. No re-run was required. + +Output Summary: The final scoped test run is green. 5262 total, 5262 passed, 0 failed, 0 skipped, exit +code 0, and all sixteen fail-before tests pass. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t6-coverage.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t6-coverage.2026-09-06T22-00.md new file mode 100644 index 000000000..260d4864e --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t6-coverage.2026-09-06T22-00.md @@ -0,0 +1,41 @@ +# P5-T6 — Post-change Coverage Document (Issue #797) + +Timestamp: 2026-09-07T10-08 + +Command: `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Coverage -FilterName All -OutputPath coverage/plan797-final/coverage.cobertura.xml -ResultsDirectory coverage/plan797-trx/p5-coverage` + +The helper invoked `dotnet-coverage collect --output --output-format cobertura --settings + -- /Settings: /InIsolation +/TestCaseFilter: /Logger:trx /ResultsDirectory:`, identically to the Phase 0 +baseline run apart from the output path. + +EXIT_CODE: 0 + +ExpectedExitCode: 0 + +The clean branch applies. The collected run reproduced no failure: its results file records 5262 +total, 5262 executed, 5262 passed, 0 failed, so there was no inner test-run exit code to propagate +and no pre-existing failure to name. + +## Output Summary + +LINES_COVERED=44489 LINES_VALID=83537 BRANCHES_COVERED=10928 BRANCHES_VALID=24371 POSTCHANGE_LINE_PERCENT=53.26 POSTCHANGE_ASSEMBLY_SCOPE=UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll,TaskMaster.Test/bin/Debug/TaskMaster.Test.dll + +The document-level branch rate, recorded as a non-asserted observation, is 44.84 percent, against +44.72 percent at the baseline. + +Both test assemblies were excluded from instrumentation by the derived coverage settings: the helper +reads the canonical coverage.config in memory, retains every existing third-party module exclusion, +and adds exactly one further module exclusion matching any module name ending in `.Test.dll`. The +canonical settings file was not written. The denominator therefore holds production code only. + +No field above carries the text UNVERIFIED or any placeholder; every value is a concrete integer or a +two-decimal number read from the Cobertura document root. + +The assembly scope is the same two test assemblies P0-T10 recorded, and both documents were produced +by the same helper with no post-processing, so they are comparable with each other. The binding +comparison is made in P5-T7 against the Phase 0 baseline, not here. + +Output Summary: Post-change coverage collected cleanly. 44489 of 83537 lines covered, giving a +document-level line rate of 53.26 percent; 10928 of 24371 branches covered. The collected test run was +green at 5262 passed and 0 failed. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t7-coverage-delta.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t7-coverage-delta.2026-09-06T22-00.md new file mode 100644 index 000000000..2c0fc1ce6 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t7-coverage-delta.2026-09-06T22-00.md @@ -0,0 +1,153 @@ +# P5-T7 — Coverage Delta Report (Issue #797) + +Timestamp: 2026-09-07T10-10 + +Compares the Phase 0 baseline document coverage/plan797-baseline/coverage.cobertura.xml with the +Phase 5 document coverage/plan797-final/coverage.cobertura.xml, and computes changed-line coverage +over the seven Write Set production C# files from the anchored diff below. + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md' -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git add --intent-to-add UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs +git diff --unified=0 $BaseSha -- TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs +``` + +The intent-to-add staging is required because an anchored diff reports tracked changes only and would +otherwise be blind to the two files this change creates. + +## The three headline figures + +BASELINE_LINE_PERCENT=53.23 +POSTCHANGE_LINE_PERCENT=53.26 +CHANGED_LINE_PERCENT=91.09 + +## Comparability branch under rule R9 + +- Baseline `lines-valid`: 83466 +- Post-change `lines-valid`: 83537 +- Absolute difference: 71 lines, which is 0.085 percent of the baseline value, well inside the + 5 percent tolerance of 4173 lines. + +The comparable branch is selected. The document-level line rates are therefore compared directly, and +the comparison is recorded as comparable. + +- Baseline: 44426 of 83466 lines covered, line rate 53.23 percent. +- Post-change: 44489 of 83537 lines covered, line rate 53.26 percent. +- Branch counters, recorded as observations: baseline 10877 of 24323 (44.72 percent); post-change + 10928 of 24371 (44.84 percent). + +`POSTCHANGE_LINE_PERCENT=53.26` is not below `BASELINE_LINE_PERCENT=53.23`, so the no-regression rule +is satisfied. Under the comparable branch that rule is the binding repository-wide gate for this +change, alongside the changed-line figure. + +Both documents were produced by the same session helper with the same derived coverage settings, the +same two-assembly scope and no post-processing, so they are comparable with each other. Neither is +comparable with a document produced by the repository coverage runner, which post-processes its +output. + +## Changed-line table, per file + +`hits=non-executable` marks a changed line that emits no IL — a blank line, a brace-only line, a +`using` directive, an XML documentation comment or an interface member declaration. The percentage is +computed over executable changed lines only, per rule R10. + +| File | Executable changed lines | Covered | Percent | +|---|---|---|---| +| TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs | 9 | 9 | 100.00 | +| TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs | 1 | 0 | 0.00 | +| UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs | 19 | 19 | 100.00 | +| UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs | 36 | 28 | 77.78 | +| UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs | 6 | 6 | 100.00 | +| UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs | NOT APPLICABLE | NOT APPLICABLE | NOT APPLICABLE | +| UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs (new and modified lines only) | 30 | 30 | 100.00 | + +The complete per-line record, one `CHANGED-LINE file= line= hits=` entry for every +changed line across all seven files, was produced by the session helper's changed-line mode from the +anchored diff above and the Phase 5 Cobertura document. The per-file totals in the table are that +mode's own aggregation of those entries. + +### The interface file + +UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs has no `class` element in either Cobertura +document, because an interface declaration emits no IL. The Phase 0 measurability artifact recorded it +as NOT YET CREATED; the same determination run against the Phase 5 document reports it NOT MEASURABLE. +Rule R10 directs that such a file be reported as NOT APPLICABLE rather than as a zero, so all 29 of its +changed lines are excluded from the denominator. + +### The junk-folders partial + +TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs carries no class-level coverage-exclusion attribute +on its partial and is measurable, at 21 of 69 lines in the Phase 5 document. It therefore produces a +real per-file changed-line row. That row is 1 executable line, 0 covered: the single executable line +is the explicit interface implementation's forwarding expression. No automated test in this plan +drives the real settings-writing implementation that member forwards to, because doing so would write +to the .NET user settings store, so this row sitting at zero is expected and is not itself a failure. +The gate is the aggregate figure. + +### The store wrapper + +UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs sits at 77.78 percent. The eight uncovered executable +lines are the interior of the address-entry fallback step's own COM catch block and the debug-timing +statements on the paths a mocked chain does not enter. The step is exercised on its success path by +the AC6 case-2 test and on its skip path by cases 3 and 4; only the COM-throw branch inside that +second step is unexercised, because reaching it requires the address-entry read itself to throw while +the primary read has already thrown. + +## RELOCATED-UNMODIFIED + +The Phase 1 relocation moved `PopulateWithCurrent`, `BindExcludeStoreCheckbox` and `GetRelativeFsPath` +verbatim into the display partial, so the anchored diff reports every line of that new file as added +although the relocated lines did not change. Those lines are enumerated below with their post-change +hit counts recorded as observations, and they are excluded from the `CHANGED_LINE_PERCENT=` +denominator. Without this exclusion the denominator would carry pre-existing code this change does not +modify. Lines inside those three members that the Phase 3 and Phase 4 edits altered are not relocated +lines and remain in the denominator. + +Relocated, unmodified, executable lines in +UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs — 38 lines, every one of them +covered with a hit count of 1: + +- From `PopulateWithCurrent`, 11 lines: 18, 19, 20, 21, 22 (the method entry and the + `InvokeRequired` marshal), 52, 53 (the archive-outlook and archive-file-system label assignments), + 66, 67, 68 (the two junk label assignments and the checkbox binding call), 69 (the method exit). +- From `BindExcludeStoreCheckbox`, 18 lines: 95, 98, 99, 100, 101, 104, 105, 106, 107, 108, 109, 112, + 113, 114, 115, 116, 117, 118 — the whole member, which no phase altered. +- From `GetRelativeFsPath`, 9 lines: 121, 130, 131, 138, 139, 142, 143, 146, 147. + +The 30 remaining executable lines in that file are new or modified and stay in the denominator: the +four null-conditional mirror assignments (30-33) and the AC6 retry block (42-45) and the three trimmed +or replaced label assignments (48-51) inside `PopulateWithCurrent`; the whole of the new +`BuildUserEmailUnavailableText` helper (78-82, 85, 86); the modified `GetRelativeFsPath` condition +lines (126-129) and the short-circuit operator line (137); and the whole of the new `TrimStorePrefix` +helper (157, 165-167, 170, 171). All 30 are covered. + +## Aggregate computation + +- Aggregate before the relocated-line exclusion: 139 executable, 130 covered, 93.53 percent. +- Relocated and excluded: 38 executable, 38 covered. +- Aggregate after the exclusion: 101 executable, 92 covered. + +CHANGED_LINE_PERCENT=91.09 + +That is at or above the 90 percent figure CLAUDE.md requires of new and changed code, so the binding +changed-line gate passes. + +## The repository-wide floors, recorded rather than asserted + +CLAUDE.md, which is rank 1 in the policy compliance order, names an 80 percent repository-wide line +floor. Both percentages recorded here sit below it: `BASELINE_LINE_PERCENT=53.23` and +`POSTCHANGE_LINE_PERCENT=53.26`. Stated plainly, that condition is pre-existing under the +two-assembly scope this plan measures: the baseline was already at 53.23 percent before any change +here, this change neither creates nor resolves it, and the scope is narrower than the full-suite +denominator the 80 percent floor is written against. The binding gates for this change are therefore +the no-regression comparison, which passes, and the changed-line percentage, which passes. + +Recorded as non-asserted observations, `.claude/rules/general-unit-test.md` names an 85 percent line +figure and a 75 percent branch figure. The post-change document sits at 53.26 percent line and 44.84 +percent branch under this scope. Neither figure is the gate for this change. + +No coverage-exclusion attribute is introduced by this change. + +Output Summary: The comparable branch applies. Post-change line coverage 53.26 percent is not below +the baseline 53.23 percent, and the changed-line figure over executable, non-relocated lines is 91.09 +percent, above the 90 percent requirement. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t8-file-sizes-postformat.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t8-file-sizes-postformat.2026-09-06T22-00.md new file mode 100644 index 000000000..de1521978 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t8-file-sizes-postformat.2026-09-06T22-00.md @@ -0,0 +1,51 @@ +# P5-T8 — Binding Post-format File-size Audit (Issue #797) + +Timestamp: 2026-09-07T10-11 + +Command: `Get-Content -LiteralPath | measure line count`, over the thirteen C# paths enumerated +in P4-T8, taken after the Phase 5 formatter ran. + +EXIT_CODE: 0 + +| Path | Lines | At or below 500 | +|---|---|---| +| TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs | 90 | yes | +| TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs | 198 | yes | +| UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs | 658 | no — see below | +| UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs | 302 | yes | +| UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs | 388 | yes | +| UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs | 402 | yes | +| UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs | 361 | yes | +| UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs | 416 | yes | +| TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs | 429 | yes | +| UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs | 29 | yes | +| UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs | 173 | yes | +| UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs | 352 | yes | +| UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs | 252 | yes | + +Twelve of the thirteen paths are at or below the 500-line cap. + +## The single exception + +UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs is 658 lines after the +formatter, against its Phase 0 count of 613. It was already 45 lines over the cap before any change +here. Design decision D5 declares this pre-existing violation out of scope for this work item, because +splitting a shared reusable-type-classes file during a parallel run would create merge contention with +concurrently running sibling work items. This change adds the minimum — a shared path guard with its +documentation and the explicit-save entry point body — and does not split the file. It does not +resolve the pre-existing violation and does not introduce a new violation class. + +## The two controller files + +UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs is 388 lines and +UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs is 173 lines. Each is at or below +500, which is the outcome design decision D4 was written to produce: the controller stood at 478 lines +with 22 lines of headroom while four acceptance criteria landed in it, and the display partial absorbs +the rendering members. + +No project file is enumerated in this audit, because the 500-line cap applies to production code, test +code and reusable script files and does not reach project files. + +Output Summary: Twelve of thirteen C# paths are within the cap. The serializer at 658 lines is the +single exception, a pre-existing condition D5 declares out of scope, reported with both its Phase 0 +count of 613 and its post-change count. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t9-scope.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t9-scope.2026-09-06T22-00.md new file mode 100644 index 000000000..f37726e5b --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t9-scope.2026-09-06T22-00.md @@ -0,0 +1,109 @@ +# P5-T9 — Scope Gate (Issue #797) + +Timestamp: 2026-09-07T10-12 + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md' -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git add --all -- '*.cs' '*.csproj' +git diff --name-status $BaseSha -- '*.cs' '*.csproj' +git status --porcelain --untracked-files=all -- '*.cs' '*.csproj' +``` + +EXIT_CODE: 0 + +## ANCHORED-DIFF-LISTING + +```text +M TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs +M TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs +M TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs +M UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs +M UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs +A UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs +M UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs +A UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs +M UtilitiesCS.Test/UtilitiesCS.Test.csproj +A UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs +M UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs +A UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs +M UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs +M UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs +M UtilitiesCS/UtilitiesCS.csproj +``` + +## PORCELAIN-LISTING + +```text +M TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs +M TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs +M TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs +M UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs +M UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs +A UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs +M UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs +A UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs +M UtilitiesCS.Test/UtilitiesCS.Test.csproj +A UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs +M UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs +A UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs +M UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs +M UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs +M UtilitiesCS/UtilitiesCS.csproj +``` + +The porcelain listing is taken after the staging command, so it overlaps the anchored diff listing +rather than complementing it: every path appears in both. The two mechanisms are complementary in +general and each alone is wrong in one state — an anchored diff enumerates tracked changes only and +would be blind to a newly created file, while a porcelain listing goes empty once the change is +committed — so both are taken and their union is the working set. + +## Working set, and the subtraction + +The union of the two listings is the fifteen paths above. + +The Phase 0 scope-baseline sets are subtracted. `SCOPE-BASELINE-COMMITTED:` held five feature-folder +and promotion documents, and `SCOPE-BASELINE-WORKTREE:` held five agent-memory residuals, this plan +file and the Phase 0 evidence artifacts. Neither set contains any `.cs` or `.csproj` path, so the +subtraction removes nothing and the remaining working set is the same fifteen paths. + +Every remaining path is a member of the Write Set. Mapping them: + +- Production, modified: the store-loading partial, the junk-folders partial, the serializer, the store + wrapper, and the store wrapper controller — five paths, all claimed. +- Production, created: the junk-folder sink interface and the controller display partial — two paths, + both claimed. +- Tests, modified: the button-and-populate partial, the store controller tests, the store wrapper + tests, and the application-globals coverage tests — four paths, all claimed. +- Tests, created: the serializer guard tests and the controller display test partial — two paths, both + claimed. +- Project compile-entry carriers, modified: the UtilitiesCS project file and the UtilitiesCS test + project file — two paths, both claimed. + +The test is a subset test, not an equality test. TaskMaster.Test/TaskMaster.Test.csproj is claimed in +the Write Set but ends the change unmodified, because the AC1 tests were appended to the +already-registered AppOlObjectsCoverageTests.cs rather than placed in a new file. Its absence from the +listings therefore does not fail this gate. TaskMaster/TaskMaster.csproj needed no compile-entry change +either, because both files receiving the AC1 and AC5 production edits are already registered in it. + +## Explicit scope constraints, confirmed separately + +- Paths under the dot-claude, dot-codex or dot-agents trees in the working set: zero. +- Paths under the config directory in the working set: zero. +- GitHub workflow files in the working set: zero. +- Files at the repository root in the working set: zero. In particular the solution file and both + repository-root build property files are untouched. +- Files with an extension of resx, config, props or targets in the working set: zero. + +## Why the pathspec restricts the gate + +The pathspec restricts both commands to source and project files because this change also writes +feature-folder documents and evidence artifacts by design: the specification and the issue document +for acceptance-criteria check-off, this plan file for task check-off, and the timestamp-named evidence +artifacts. Those are excluded from the Write Set by spec.md's stated convention and are enumerated in +the plan's own prose. Including them here would report them as scope violations when they are +deliberate. The session helper and the coverage outputs do not appear in either listing because the +coverage directory is git-ignored. + +Output Summary: The working set is fifteen source and project paths, every one of them a member of the +Write Set, with no path under the dot-claude, dot-codex, dot-agents or config trees, no GitHub +workflow file, and no repository-root file. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p6-t11-ac-status.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p6-t11-ac-status.2026-09-06T22-00.md new file mode 100644 index 000000000..31eca2d11 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p6-t11-ac-status.2026-09-06T22-00.md @@ -0,0 +1,124 @@ +# P6-T11 — Acceptance-criteria Status Summary (Issue #797) + +Timestamp: 2026-09-07T10-16 + +## Acceptance Criteria Status + +- Source: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md (authoritative under full-bug work mode), mirrored in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md +- Total AC items: 8 +- Checked off (delivered): 7 +- Remaining (unchecked): 1 +- Items remaining: AC3 — "A value saved in Folder Settings is present after an Outlook restart (manual verification)." + +## Per-criterion detail + +| AC | Status | Implementing task | Verifying test or procedure | Evidence artifact | +|---|---|---|---|---| +| AC1 | PASS | P2-T1 | `LoadStoresAsync_WhenConfigDeserializesToNull_FreshWrapperAdoptsLoaderDiskConfiguration`, with `LoadStoresAsync_WhenConfigKeyIsAbsent_FreshWrapperKeepsEmptyDiskPath` as the negative case | evidence/regression-testing/p2-t5-root-cause-1-green.2026-09-06T22-00.md | +| AC2 | PASS | P2-T2 | `Serialize_WithEmptyDiskPath_LogsErrorAndArmsNoTimer`, `Serialize_WithNullDiskPath_LogsErrorAndArmsNoTimer` | evidence/regression-testing/p2-t5-root-cause-1-green.2026-09-06T22-00.md | +| AC3 | NOT MET — BLOCKED-MANUAL | none; not automatable | nine-step manual procedure requiring a live Outlook restart | evidence/other/p6-t1-ac3-manual-verification.2026-09-06T22-00.md, with the fail-before exception dossier at evidence/regression-testing/fail-before-exception.2026-09-06T22-00.md | +| AC4 | PASS | P2-T3 and P2-T4 | `SerializeNow_WithConfiguredPath_WritesWithoutFiringTimer`, with `Serialize_WithConfiguredPath_StillRequiresTimerFireToWrite` pinning the unchanged deferred path | evidence/regression-testing/p2-t5-root-cause-1-green.2026-09-06T22-00.md | +| AC5 | PASS | P4-T1 and P4-T2 | `PersistJunkFolderSelections_WhenGlobalsAreNotTheTypedSink_LogsErrorAndDoesNotInvoke`, `PersistJunkFolderSelections_PassesJunkCertainPathFirst`, and the retargeted `PersistJunkFolderSelections_WhenApplyMethodIsMissing_DoesNotThrow` | evidence/regression-testing/p4-t7-remaining-criteria-green.2026-09-06T22-00.md | +| AC6 | PASS | P3-T1, P3-T2 and P3-T3 | the four `GetSmtpAddressFromStore_*` fallback cases, `RefreshUserEmailAddress_WhenRootFolderIsNull_ReturnsNullAndDoesNotThrow`, and the three `PopulateWithCurrent_When*` retry cases | evidence/regression-testing/p3-t4-root-cause-2-green.2026-09-06T22-00.md | +| AC7 | PASS | P4-T3 | the six `TrimStorePrefix_*` pure-function cases and `PopulateWithCurrent_RendersInboxAndRootFolderWithoutStorePrefix` | evidence/regression-testing/p4-t7-remaining-criteria-green.2026-09-06T22-00.md | +| AC8 | PASS | P4-T4 and P4-T5 | `PopulateWithCurrent_NullCurrent_SetsErrorLoadingText` (inverted under D6), `PopulateWithCurrent_WithNullCurrent_RendersPlaceholdersAndDoesNotThrow`, `GetRelativeFsPath_WithNullCurrent_ReturnsPlaceholderAndDoesNotThrow` | evidence/regression-testing/p4-t7-remaining-criteria-green.2026-09-06T22-00.md | + +## The AC3 branch, recorded explicitly + +P6-T4 conditions the AC3 check-off on the P6-T1 artifact recording `AC3-RESULT: PASS`. That artifact +records `AC3-RESULT: BLOCKED-MANUAL`: this execution environment has no live Outlook host and the +executing agent is directed not to start one, load the add-in, or drive any user interface, so all +nine procedure steps are recorded as NOT PERFORMED with their reasons and no result is fabricated. +The AC3 checkbox is therefore left unmarked in both spec.md and issue.md, and the plan outcome for +that one criterion is remediation-required rather than complete: the criterion is handed to the +maintainer for manual verification. Every other criterion is genuinely verified by automated test, and +AC3 being blocked was not used to soften any of them. + +## Final coverage figures, restated from the P5-T7 artifact + +- BASELINE_LINE_PERCENT=53.23 +- POSTCHANGE_LINE_PERCENT=53.26 +- CHANGED_LINE_PERCENT=91.09 + +Rule R9 selected the comparable branch: the baseline `lines-valid` of 83466 and the post-change +`lines-valid` of 83537 differ by 71 lines, 0.085 percent of the baseline, inside the 5 percent +tolerance. Post-change line coverage is not below the baseline, so the no-regression rule is +satisfied. The changed-line figure of 91.09 percent, computed over the executable changed lines of +every measurable file with the relocated-unmodified lines excluded from the denominator, is at or +above the 90 percent figure CLAUDE.md requires of new and changed code. Both percentages sit below +CLAUDE.md's 80 percent repository-wide floor; that is a pre-existing condition under the narrower +two-assembly scope this plan measures, which this change neither creates nor resolves. + +## Excluded test classes + +Four shell-icon test classes are excluded from every local run in this plan for environmental reasons +unrelated to this change: `HelperClasses.ShellUtilities_Tests`, +`HelperClasses.ShellUtilitiesStatic_Tests`, `HelperClasses.SysImageListHelperTests` and +`EmailIntelligence.OSBrowser_Tests`. They stall vstest on this workstation. CI covers them. + +## Declared expectation changes + +1. **The D6 inversion.** `PopulateWithCurrent_NullCurrent_SetsErrorLoadingText`, in + UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs, previously + asserted that a null current store throws a `NullReferenceException`, contradicting its own name. + AC8 changes that behaviour, so the assertion is inverted to require that the act does not throw and + that the archive and junk labels carry their existing placeholder literals. The test name is + unchanged and the new assertion is stricter than the original: it pins specific rendered values + rather than merely an exception type. This is a declared expectation change, not a weakened test. +2. **The P3-T1 arrangement correction: none was required.** P3-T1 required the two pre-existing tests + `GetSmtpAddressFromStore_WhenExchangeUserIsUnavailable_ReturnsNull` and + `GetSmtpAddressFromStore_WhenExchangeLookupThrowsComException_ReturnsNull` to be re-derived against + the new fallback ordering. Both supply neither an at-sign-bearing address-entry address nor an + at-sign-bearing display name, so both still return null and both passed unchanged. No arrangement + was corrected and no second expectation change arises. + +A second, non-weakening retarget accompanies AC5: the reflection-era globals doubles in the store +controller tests are retargeted to the typed sink. The negative test asserting the +missing-implementation path does not throw is retargeted rather than deleted, so the loud-failure +branch retains coverage. + +## Session helper + +The session-scoped helper at coverage/plan797-helpers.ps1 was deleted before the commit, per rule R2. +It was created in Phase 0, rewritten in place as later tasks required, never committed, and lived in a +git-ignored directory throughout, so it satisfies the general code change policy's exemption for a +script created and deleted within an agent session and was never a production PowerShell file. + +## TERMINAL-PORCELAIN + +Verbatim output of `git status --porcelain --untracked-files=all`, observed immediately after the +P6-T11 commit: + +```text + M .claude/agent-memory/atomic-planner/MEMORY.md + M .claude/agent-memory/prd-feature/feedback_backticked_paths_are_the_change_footprint.md + M .claude/agent-memory/task-researcher/MEMORY.md +?? .claude/agent-memory/atomic-planner/project_797_folder_settings_persistence_plan_seams.md +?? .claude/agent-memory/task-researcher/project_folder_settings_persistence_797.md +``` + +Residual classification, one entry per line above: + +- All five lines belong to the agent-memory residual class, the second of the two classes the gate + admits. They are pre-existing modifications and additions left by the preparation subagents before + execution began, they are outside this item's Write Set, and this execution neither edited, reverted + nor committed them. Every path staged for the commit was named explicitly; no `git add -A` and no + `git add .` was used. +- No other path is present, so the gate passes. In particular the session helper is absent, because it + was deleted before the commit and lived in a git-ignored directory throughout. + +This section was written as an explicit placeholder before the commit and replaced with the observed +output afterwards; no value was predicted. The replacement is folded into the same commit by an +amend, which does not change the residual set, so the listing above remains accurate for the +post-commit state. No commit hash is quoted here, because an amend rewrites it and a quoted hash +would immediately become stale. + +The first admitted residual class, this plan file, is absent from the listing above because the plan +file was committed with its P6-T1 through P6-T10 check-offs already applied. Its P6-T11 check-off is +written after the commit, at which point it becomes the second residual, exactly as the gate +anticipates. + +Output Summary: Seven of eight acceptance criteria are delivered and checked off in both spec.md and +issue.md. AC3 is BLOCKED-MANUAL and left unmarked, handed to the maintainer with a nine-step +procedure. Changed-line coverage is 91.09 percent and document-level coverage did not regress. The +terminal porcelain listing holds only agent-memory residuals. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/fail-before-exception.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/fail-before-exception.2026-09-06T22-00.md new file mode 100644 index 000000000..1765bb4ef --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/fail-before-exception.2026-09-06T22-00.md @@ -0,0 +1,57 @@ +# Fail-before Exception Dossier — AC3 (Issue #797) + +Timestamp: 2026-09-07T09-40 + +Criterion: AC3 — "A value saved in Folder Settings is present after an Outlook restart (manual +verification)." + +WhyFailingRunImpossible: AC3 asserts that a saved value survives a restart of the Outlook host +process, which requires a live VSTO host with the add-in loaded, a real user profile directory, and a +process teardown and restart. No automated test in this repository can reproduce that: the unit test +policy prohibits external processes and temporary files, the settings write is exercised only through +an injectable stream-writer seam, and no test harness can start or restart Outlook. A failing +automated run for AC3 is therefore structurally impossible rather than merely absent. + +## Alternative proof that the defect is real and present before the fix + +The runtime log on the reporting machine records the same pair of lines once per Outlook start, at +17:29:59, 19:09:20 and 19:26:35 on 2026-09-06: + +```text +[VSTA_Main] WARN TaskMaster.AppOlObjects - StoresWrapper config deserialized to null; rebuilding from live stores. +[VSTA_Main] ERROR UtilitiesCS.OutlookObjects.Store.StoreWrapper - Error retrieving PrimarySmtpAddress from secondary inbox. The operation failed. +``` + +The warning recurs on every start, which is only possible if the settings file was never written. A +filesystem check on 2026-09-06 confirmed that the local application data TaskMaster directory contains +the other TaskMaster JSON files while the stores-wrapper settings file is absent, and a recursive +search of the user profile found it nowhere. The log lives outside the repository and was read +read-only; no absolute host path, user account name or machine name is reproduced here. + +The automated fail-before evidence recorded in +`p1-t18-fail-before.2026-09-06T22-00.md` covers the mechanism that produces the AC3 symptom: the +fresh-build path does not adopt the loader's disk configuration (AC1), and the serializer's guard +returns silently on the resulting empty path (AC2). Those two failing tests establish, without a live +host, that no write can occur. What they do not establish is that the file appears on disk in a live +VSTO host, which is exactly the residual that AC3's manual procedure covers. + +## Negative-evidence record + +SearchScope: +- docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/ +- docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/other/ +- docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/ +- docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/ + +SearchPatterns: +- `fail-before-exception.*.md` +- `*.trx` results containing a test whose name references an Outlook restart or a settings-file + round trip + +SearchResult: none. No automated fail-before run exists for AC3 anywhere under this feature folder's +evidence tree, and this dossier is the only `fail-before-exception.*.md` file present. + +## Disposition + +AC3 is verified by the written manual procedure recorded in P6-T1. No automated gate in this plan +claims to prove it. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t18-fail-before.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t18-fail-before.2026-09-06T22-00.md new file mode 100644 index 000000000..f93641f27 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t18-fail-before.2026-09-06T22-00.md @@ -0,0 +1,117 @@ +# P1-T18 — Fail-before Regression Run (Issue #797) + +Timestamp: 2026-09-07T09-39 + +Commands: + +1. `msbuild TaskMaster.sln /t:Build /m /p:Configuration=Debug "/p:Platform=Any CPU"` — exit 0. The + build must succeed: a compile error is not an acceptable fail-before signal. One compile error was + encountered and corrected before this run, `CS0104: 'Action' is an ambiguous reference between + 'Microsoft.Office.Interop.Outlook.Action' and 'System.Action'` in the store controller test file, + fixed by qualifying the type as `System.Action`. +2. `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Test -FilterName All -ResultsDirectory coverage/plan797-trx/p1` + +EXIT_CODE: 1 + +ExpectedExitCode: 1 + +## Counts, read from the results file + +- Total: 5261 +- Passed: 5245 +- Failed: 16 +- Skipped: 0 (total minus executed) + +The baseline recorded 5237 tests. This run adds the 24 tests written in Phase 1: two for AC1, four in +the new serializer guard file for AC2 and AC4, two for AC5, four for AC6 fallback ordering, and twelve +in the new controller display partial for AC6 retry, AC7 and AC8. + +PRE-EXISTING-FAILURES: NONE + +The P0-T9 artifact recorded `BASELINE-FAILING-TESTS: NONE`, so the subtracted set is empty and every +failure below is new and attributable to this change's not-yet-implemented behaviour. + +## FAIL-BEFORE enumeration + +Every name below is absent from the `BASELINE-FAILING-TESTS:` set in the P0-T9 artifact. + +AC1 — the fresh-build path does not yet adopt the loader configuration: + +- FAIL-BEFORE: TaskMaster.Test.AppGlobals.AppOlObjectsCoverageTests.LoadStoresAsync_WhenConfigDeserializesToNull_FreshWrapperAdoptsLoaderDiskConfiguration + +AC2 — the guard still returns silently and compares only against the empty string: + +- FAIL-BEFORE: UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.Serialize_WithEmptyDiskPath_LogsErrorAndArmsNoTimer +- FAIL-BEFORE: UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.Serialize_WithNullDiskPath_LogsErrorAndArmsNoTimer + +AC4 — the explicit-save entry point still forwards to the deferred path: + +- FAIL-BEFORE: UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.SerializeNow_WithConfiguredPath_WritesWithoutFiringTimer + +AC5 — the reflection lookup still succeeds against the non-sink double: + +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperControllerTests.PersistJunkFolderSelections_WhenGlobalsAreNotTheTypedSink_LogsErrorAndDoesNotInvoke + +AC6 — the single outer catch converts every failure to null, with no fallback, no captured reason and +no retry: + +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenPrimarySmtpThrows_FallsBackToAddressEntryAddress +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenPrimaryAndAddressEntryFail_FallsBackToDisplayName +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenEveryFallbackFails_ReturnsNullAndCapturesReason +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WhenUserEmailIsNull_RetriesLookupAndRendersAddress +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WhenRetryFails_RendersSpecificMessageWithReason + +AC7 — the trim helper is still the declaration-only placeholder and no call site uses it: + +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.TrimStorePrefix_WithLeadingStorePrefix_RemovesIt +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.TrimStorePrefix_WithOnlyTheStorePrefix_ReturnsEmptyString +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_RendersInboxAndRootFolderWithoutStorePrefix + +AC8 — the four unguarded dereferences and the one in the relative-path helper still throw: + +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_NullCurrent_SetsErrorLoadingText +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WithNullCurrent_RendersPlaceholdersAndDoesNotThrow +- FAIL-BEFORE: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.GetRelativeFsPath_WithNullCurrent_ReturnsPlaceholderAndDoesNotThrow + +The enumeration contains at least one failing test attributable to each of AC1, AC2, AC4, AC5, AC6, +AC7 and AC8. AC3 is not represented because it is not automatable; its fail-before requirement is +discharged by the exception dossier authored in P1-T19. + +## Tests added in Phase 1 that are green from the moment they were written + +These are additive coverage, not fail-before signals: the AC1 key-absent negative case, the AC4 +deferred-path-unchanged case, the AC5 argument-order case, the AC6 case in which the primary SMTP +address is present, the AC6 retry case in which the address is already populated, and the four AC7 +trim cases whose input carries no leading backslash pair. + +NEW-TEST-FILES-DISCOVERED: + +The two new test files registered in P1-T12 and P1-T16 both compiled into their assembly and were +executed, so no compile entry is missing. + +- From UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs: + `UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.Serialize_WithEmptyDiskPath_LogsErrorAndArmsNoTimer` +- From UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs: + `UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.TrimStorePrefix_WithLeadingStorePrefix_RemovesIt` + +## Filter expression used + +```text +TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests +``` + +The four shell-icon test classes named in that expression are excluded from every local run for +environmental reasons unrelated to this change. CI covers them. + +## Note on the helper + +The first attempt at this run reported the same 16 failures but returned exit code 0, because the +helper's vstest wrapper returned the external command's captured console output joined with the exit +code, so the caller could not propagate a real exit code. The helper was corrected to send the +external command's output to the host and return only the integer, and the run above is the corrected +run. The two earlier green runs, P0-T9 and P1-T3, are unaffected in substance: both were genuinely +green in their results files, so their recorded exit code of 0 remains correct. + +Output Summary: The build succeeds and the scoped run fails with exit code 1 and 16 failures, one or +more for each of AC1, AC2, AC4, AC5, AC6, AC7 and AC8, and none of them pre-existing. Both new test +files are discovered and executed. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t3-pure-move-green.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t3-pure-move-green.2026-09-06T22-00.md new file mode 100644 index 000000000..56e0ecb53 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t3-pure-move-green.2026-09-06T22-00.md @@ -0,0 +1,50 @@ +# P1-T3 — Pure-move Relocation Is Behaviour-Preserving (Issue #797) + +Timestamp: 2026-09-07T09-25 + +Commands: + +1. `msbuild TaskMaster.sln /t:Build /m /p:Configuration=Debug "/p:Platform=Any CPU"` — exit 0, every + project produced its output assembly, including UtilitiesCS, UtilitiesCS.Test and TaskMaster.Test. +2. `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Test -FilterName ControllerScope -ResultsDirectory coverage/plan797-trx/p1-t3` + +EXIT_CODE: 0 + +ExpectedExitCode: 0 + +The declared expectation equals the exit code this run actually produced. The exit code is 0, so the +alternative acceptance branch — exit code 1 with every failing test a member of the +`BASELINE-CONTROLLER-SCOPE:` failing set recorded in the P0-T9 artifact — is not entered. That failing +set is empty, so any failure at all would have been a gate failure attributable to the relocation. + +## Counts, read from the results file + +- Total: 82 +- Passed: 82 +- Failed: 0 +- Skipped: 0 (total minus executed) + +The passed count of 82 is at or above the passed count recorded under `BASELINE-CONTROLLER-SCOPE:` in +the P0-T9 artifact, which is also 82. Both figures were obtained with the identical filter expression +recorded there. + +## Filter expression used + +```text +(FullyQualifiedName~StoreWrapperController_Tests&TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests)|(FullyQualifiedName~StoreWrapperControllerTests&TestCategory!=LiveOutlook)|(FullyQualifiedName~StoreWrapperTests&TestCategory!=LiveOutlook)|(FullyQualifiedName~StoreWrapperViewerTests&TestCategory!=LiveOutlook) +``` + +The filter selects `StoreWrapperController_Tests`, `StoreWrapperControllerTests`, `StoreWrapperTests` +and `StoreWrapperViewerTests`, with the `TestCategory!=LiveOutlook` clause repeated on every disjunct +because `&` binds tighter than `|` in a vstest filter expression. + +Four shell-icon test classes — `HelperClasses.ShellUtilities_Tests`, +`HelperClasses.ShellUtilitiesStatic_Tests`, `HelperClasses.SysImageListHelperTests` and +`EmailIntelligence.OSBrowser_Tests` — are excluded from every local run in this plan. CI covers them. +Their exclusion clauses bind to the first disjunct alone, which is inert here because no shell-icon +test name matches any selector in this expression. + +Output Summary: The relocation of `PopulateWithCurrent`, `BindExcludeStoreCheckbox` and +`GetRelativeFsPath` into the new display partial is behaviour-preserving. The solution builds and the +82 tests in the controller and store scope all pass, matching the baseline exactly. No new test +existed at the time of this run. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t9-seam-build.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t9-seam-build.2026-09-06T22-00.md new file mode 100644 index 000000000..3687e9c1e --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t9-seam-build.2026-09-06T22-00.md @@ -0,0 +1,34 @@ +# P1-T9 — Phase 1 Seam Compilation (Issue #797) + +Timestamp: 2026-09-07T09-26 + +Command: `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-p1-analyzers.log"` + +EXIT_CODE: 0 + +The summary line ` 0 Error(s)` is present in the file log, at log line 70462, preceded by +`Build succeeded.` and ` 0 Warning(s)`. + +The Phase 0 analyzer baseline was clean, so the primary acceptance branch applies: exit code 0 with +the zero-error summary line present. The alternative branch — a recorded diagnostic identifier set +that must be a subset of `BASELINE-DIAGNOSTIC-IDS:` and contain no diagnostic attributed to a Write +Set file — is not entered, because this run reported no diagnostic at all. + +## Seams confirmed to compile by this run + +1. UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs — the new display partial + holding the three relocated members and the declaration-only trim helper. +2. UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs — the new typed sink interface. +3. UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs — the new + `public void SerializeNow()` entry point, forwarding to the existing deferred `Serialize()`. +4. UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs — the new `LastSmtpLookupError` property carrying + a JsonIgnore attribute and the new `RefreshUserEmailAddress()` entry point. +5. UtilitiesCS/UtilitiesCS.csproj — the two added compile entries, proven effective because the two + new source files participate in the build. + +Every seam is declaration-only and defect-preserving: it adds members and files and changes no +behaviour. Without them the new tests would fail to compile, which would redden the whole test +assembly and produce no attributable test result. + +Output Summary: The analyzer rebuild is clean after the Phase 1 seams. Exit code 0, zero warnings, +zero errors. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p2-t5-root-cause-1-green.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p2-t5-root-cause-1-green.2026-09-06T22-00.md new file mode 100644 index 000000000..04d7afb2f --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p2-t5-root-cause-1-green.2026-09-06T22-00.md @@ -0,0 +1,55 @@ +# P2-T5 — Root Cause 1 Automated Criteria Are Green (Issue #797, AC1, AC2, AC4) + +Timestamp: 2026-09-07T09-43 + +Commands: + +1. `msbuild TaskMaster.sln /t:Build /m /p:Configuration=Debug "/p:Platform=Any CPU"` — exit 0. +2. `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Test -FilterName RootCause1 -ResultsDirectory coverage/plan797-trx/p2` + +EXIT_CODE: 0 + +Failed count: 0. + +## PASS-AFTER correspondence + +One line per test name that appeared as a `FAIL-BEFORE:` entry for AC1, AC2 or AC4 in the P1-T18 +artifact. All four are now passing. + +- PASS-AFTER: TaskMaster.Test.AppGlobals.AppOlObjectsCoverageTests.LoadStoresAsync_WhenConfigDeserializesToNull_FreshWrapperAdoptsLoaderDiskConfiguration +- PASS-AFTER: UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.Serialize_WithEmptyDiskPath_LogsErrorAndArmsNoTimer +- PASS-AFTER: UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.Serialize_WithNullDiskPath_LogsErrorAndArmsNoTimer +- PASS-AFTER: UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.SerializeNow_WithConfiguredPath_WritesWithoutFiringTimer + +## Run totals, recorded as non-asserted observations + +Total tests 11, passed 11, failed 0, elapsed 4.73 seconds. The scope selects the two classes that +carry the AC1, AC2 and AC4 tests, so it also re-runs the pre-existing tests in those classes; all of +them passed, including the AC1 key-absent negative case, the AC4 deferred-path-unchanged case, and the +five pre-existing store-loading tests. + +## Filter expression used + +```text +(FullyQualifiedName~AppOlObjectsCoverageTests&TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests)|(FullyQualifiedName~SmartSerializableSerializeGuardTests&TestCategory!=LiveOutlook) +``` + +The four shell-icon test classes named in that expression are excluded from every local run in this +plan for environmental reasons unrelated to this change. CI covers them. + +## Implementation recorded by this evidence + +- AC1 lands only in TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs: the fresh-build branch now + applies the already-resolved loader configuration to the freshly built wrapper with a deep copy. + The shared deserialize overload in + UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs is unchanged, and + UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializableBase.cs is untouched. +- AC2 replaces the empty-string comparison with a null-or-empty check that logs at error level, + naming the serialized item type reported by `typeof` over the type parameter together with the + rejected path, and arms no timer on the rejecting path. +- AC4 replaces the placeholder body of the explicit-save entry point with the guarded synchronous + flush. The AC2 guard is evaluated first, so the fix does not substitute one silent failure for + another; only a non-empty, non-null path reaches the existing thread-safe write method. + +Output Summary: The root-cause-1 scope is green. Exit code 0, 11 of 11 passing, and all four AC1, AC2 +and AC4 fail-before tests now pass. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p2-t6-no-regression.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p2-t6-no-regression.2026-09-06T22-00.md new file mode 100644 index 000000000..f0e9b40af --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p2-t6-no-regression.2026-09-06T22-00.md @@ -0,0 +1,56 @@ +# P2-T6 — No Regression Outside the Root-Cause-1 Set (Issue #797) + +Timestamp: 2026-09-07T09-46 + +Command: `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Test -FilterName All -ResultsDirectory coverage/plan797-trx/p2-full` + +EXIT_CODE: 1 + +ExpectedExitCode: 1 + +A non-zero exit is the expected outcome at this point in the plan: the AC5, AC6, AC7 and AC8 tests are +still red by design, because Phase 3 and Phase 4 have not run. + +## Counts, read from the results file + +- Total: 5261 +- Passed: 5249 +- Failed: 12 +- Skipped: 0 (total minus executed) + +The passed count rose from 5245 to 5249, which is exactly the four AC1, AC2 and AC4 tests that Phase 2 +turned green. + +PRE-EXISTING-FAILURES: NONE + +The P1-T18 artifact recorded `PRE-EXISTING-FAILURES: NONE`, so no name is subtracted here and the +subtracted list is empty. + +## The failing set is a proper subset of the P1-T18 FAIL-BEFORE set + +Every one of the twelve names below appears in the `FAIL-BEFORE:` enumeration of the P1-T18 artifact, +and no failing test resides outside that set. The set is proper: the four AC1, AC2 and AC4 names in +the P1-T18 enumeration are absent here. + +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperControllerTests.PersistJunkFolderSelections_WhenGlobalsAreNotTheTypedSink_LogsErrorAndDoesNotInvoke +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenPrimarySmtpThrows_FallsBackToAddressEntryAddress +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenPrimaryAndAddressEntryFail_FallsBackToDisplayName +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenEveryFallbackFails_ReturnsNullAndCapturesReason +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WhenUserEmailIsNull_RetriesLookupAndRendersAddress +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WhenRetryFails_RendersSpecificMessageWithReason +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.TrimStorePrefix_WithLeadingStorePrefix_RemovesIt +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.TrimStorePrefix_WithOnlyTheStorePrefix_ReturnsEmptyString +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_RendersInboxAndRootFolderWithoutStorePrefix +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_NullCurrent_SetsErrorLoadingText +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WithNullCurrent_RendersPlaceholdersAndDoesNotThrow +- UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.GetRelativeFsPath_WithNullCurrent_ReturnsPlaceholderAndDoesNotThrow + +The existing test callers of the serializer that the research enumerated — across the serializer, +non-typed serializer, linked-list and stack test files in the UtilitiesCS test project and the two +application-globals test files in the TaskMaster test project — are all inside this run and none of +them appears above, so all of them continue to pass unchanged. That is the direct evidence that the +shared deserialize overload was not modified, per design decision D1. + +Output Summary: Exit code 1 as declared. Twelve failures remain, all of them members of the P1-T18 +fail-before set and all attributable to AC5, AC6, AC7 or AC8, which Phase 3 and Phase 4 address. No +test outside that set regressed. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p3-t4-root-cause-2-green.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p3-t4-root-cause-2-green.2026-09-06T22-00.md new file mode 100644 index 000000000..d0d77524f --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p3-t4-root-cause-2-green.2026-09-06T22-00.md @@ -0,0 +1,56 @@ +# P3-T4 — Root Cause 2 Automated Criterion Is Green (Issue #797, AC6) + +Timestamp: 2026-09-07T09-50 + +Commands: + +1. `msbuild TaskMaster.sln /t:Build /m /p:Configuration=Debug "/p:Platform=Any CPU"` — exit 0. +2. `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Test -FilterName RootCause2 -ResultsDirectory coverage/plan797-trx/p3` + +EXIT_CODE: 0 + +Failed count: 0. + +## PASS-AFTER correspondence + +One line per test name that appeared as a `FAIL-BEFORE:` entry for AC6 in the P1-T18 artifact. All +five are now passing. + +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenPrimarySmtpThrows_FallsBackToAddressEntryAddress +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenPrimaryAndAddressEntryFail_FallsBackToDisplayName +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperTests.GetSmtpAddressFromStore_WhenEveryFallbackFails_ReturnsNullAndCapturesReason +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WhenUserEmailIsNull_RetriesLookupAndRendersAddress +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WhenRetryFails_RendersSpecificMessageWithReason + +## Run totals, recorded as non-asserted observations + +Total tests 20, passed 20, failed 0, elapsed 2.90 seconds. + +## The two pre-existing tests re-derived against the new ordering + +P3-T1 required the two pre-existing tests in the store wrapper test file to be re-derived against the +new fallback order. Both passed unchanged and no arrangement correction was required, so no declared +expectation change arises from Phase 3: + +- `GetSmtpAddressFromStore_WhenExchangeUserIsUnavailable_ReturnsNull` — the Exchange user is null, so + no primary address is produced; the address entry supplies no at-sign-bearing address because its + address is unset on the mock; and the wrapper's display name is null. Every source therefore fails + and the method still returns null. +- `GetSmtpAddressFromStore_WhenExchangeLookupThrowsComException_ReturnsNull` — the Exchange lookup + throws, and the same two remaining sources yield nothing, so the method still returns null. + +`GetSmtpAddressFromStore_WhenRootFolderIsNull_ShouldReturnNull` likewise passed unchanged, and the new +`RefreshUserEmailAddress_WhenRootFolderIsNull_ReturnsNullAndDoesNotThrow` test added under P3-T2 +passed, which is the direct evidence that the retry entry point is safe when the root folder is null. + +## Filter expression used + +```text +(FullyQualifiedName~StoreWrapperTests&TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests)|(FullyQualifiedName~PopulateWithCurrent_WhenUserEmailIsNull_RetriesLookupAndRendersAddress&TestCategory!=LiveOutlook)|(FullyQualifiedName~PopulateWithCurrent_WhenUserEmailIsAlreadyPopulated_DoesNotRetryLookup&TestCategory!=LiveOutlook)|(FullyQualifiedName~PopulateWithCurrent_WhenRetryFails_RendersSpecificMessageWithReason&TestCategory!=LiveOutlook) +``` + +The four shell-icon test classes named in that expression are excluded from every local run in this +plan for environmental reasons unrelated to this change. CI covers them. + +Output Summary: The root-cause-2 scope is green. Exit code 0, 20 of 20 passing, and all five AC6 +fail-before tests now pass. The two pre-existing SMTP tests pass unchanged under the new ordering. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p3-t5-root-cause-1-still-green.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p3-t5-root-cause-1-still-green.2026-09-06T22-00.md new file mode 100644 index 000000000..e12a2c5a1 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p3-t5-root-cause-1-still-green.2026-09-06T22-00.md @@ -0,0 +1,29 @@ +# P3-T5 — Root Cause 1 Remains Green After the Phase 3 Edits (Issue #797) + +Timestamp: 2026-09-07T09-51 + +Command: `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Test -FilterName RootCause1 -ResultsDirectory coverage/plan797-trx/p3-rc1` + +EXIT_CODE: 0 + +Failed count: 0. + +The two root causes remain separately traceable: the Phase 3 edits to the SMTP fallback chain, the +captured failure reason and the dialog retry did not disturb the Phase 2 work. + +## The AC1, AC2 and AC4 test set recorded in the P2-T5 artifact + +Every one of the four tests recorded there as `PASS-AFTER:` passed again in this run: + +- TaskMaster.Test.AppGlobals.AppOlObjectsCoverageTests.LoadStoresAsync_WhenConfigDeserializesToNull_FreshWrapperAdoptsLoaderDiskConfiguration +- UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.Serialize_WithEmptyDiskPath_LogsErrorAndArmsNoTimer +- UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.Serialize_WithNullDiskPath_LogsErrorAndArmsNoTimer +- UtilitiesCS.Test.ReusableTypeClasses.SmartSerializableSerializeGuardTests.SerializeNow_WithConfiguredPath_WritesWithoutFiringTimer + +## Run totals, recorded as non-asserted observations + +Total tests 11, passed 11, failed 0, elapsed 3.21 seconds. The same eleven tests ran as in P2-T5, with +identical outcomes. + +Output Summary: The root-cause-1 scope is still green after Phase 3. Exit code 0 and a failed count of +0 over exactly the AC1, AC2 and AC4 test set recorded in the P2-T5 artifact. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p4-t7-remaining-criteria-green.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p4-t7-remaining-criteria-green.2026-09-06T22-00.md new file mode 100644 index 000000000..e65bf90a7 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p4-t7-remaining-criteria-green.2026-09-06T22-00.md @@ -0,0 +1,57 @@ +# P4-T7 — Remaining Automated Criteria Are Green (Issue #797, AC5, AC7, AC8) + +Timestamp: 2026-09-07T09-54 + +Commands: + +1. `msbuild TaskMaster.sln /t:Build /m /p:Configuration=Debug "/p:Platform=Any CPU"` — exit 0. +2. `pwsh -NoProfile -File coverage/plan797-helpers.ps1 -Mode Test -FilterName Remaining -ResultsDirectory coverage/plan797-trx/p4` + +EXIT_CODE: 0 + +Failed count: 0. + +## PASS-AFTER correspondence + +One line per test name that appeared as a `FAIL-BEFORE:` entry for AC5, AC7 or AC8 in the P1-T18 +artifact. All seven are now passing. + +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperControllerTests.PersistJunkFolderSelections_WhenGlobalsAreNotTheTypedSink_LogsErrorAndDoesNotInvoke +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.TrimStorePrefix_WithLeadingStorePrefix_RemovesIt +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.TrimStorePrefix_WithOnlyTheStorePrefix_ReturnsEmptyString +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_RendersInboxAndRootFolderWithoutStorePrefix +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_NullCurrent_SetsErrorLoadingText +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.PopulateWithCurrent_WithNullCurrent_RendersPlaceholdersAndDoesNotThrow +- PASS-AFTER: UtilitiesCS.Test.OutlookObjects.Store.StoreWrapperController_Tests.GetRelativeFsPath_WithNullCurrent_ReturnsPlaceholderAndDoesNotThrow + +## Run totals, recorded as non-asserted observations + +Total tests 17, passed 17, failed 0, elapsed 2.65 seconds. The scope also re-ran the retargeted +missing-implementation test, the AC5 argument-order test, the four AC7 trim cases that were green from +the moment they were written, and the four pre-existing relative-path tests, all of which passed. + +## Implementation recorded by this evidence + +- AC5: TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs declares the interface on the partial and + adds an explicit implementation forwarding both arguments in the certain-then-potential order; the + existing internal method keeps its accessibility and body, so the public surface of the globals + type does not widen. UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs replaces the + reflection lookup with a typed cast and logs at error level, naming the sink interface, when the + cast fails. The file contains no `GetMethod` call and the `System.Reflection` using directive was + removed, which is safe because the only other reflection reference in the file, in the static + logger initialiser, is fully qualified. Analyzer cleanliness after these edits is proven in P5-T3. +- AC7: the trim helper removes a leading pair of backslash characters and returns every other input + unchanged, including a null reference and the empty string, and is applied to the Inbox and Root + Folder label assignments. The shared archive stem contract type was not modified. +- AC8: the four dereferences at the top of the populate method and the one in the relative-path + helper are null-conditional, matching the null-conditional form the adjacent block already used. + The six placeholder literals are unchanged apart from the user-email literal that AC6 replaced. +- The readability correction changes the single-ampersand operator in the live relative-path + condition to the short-circuit form. It is behaviourally inert: both operands call a null-tolerant + string extension and neither has a side effect. It does not repair a fault. The single-ampersand + occurrence inside the commented-out block that moved with the populate method is dead commented + text and was left untouched, so an occurrence count over the file is not used as the gate; the + gate is that the four relative-path tests named in P4-T5 still pass, which they do. + +Output Summary: The remaining criteria scope is green. Exit code 0, 17 of 17 passing, and all seven +AC5, AC7 and AC8 fail-before tests now pass. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/feature-audit.2026-09-07T22-40.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/feature-audit.2026-09-07T22-40.md new file mode 100644 index 000000000..d581ecfb0 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/feature-audit.2026-09-07T22-40.md @@ -0,0 +1,222 @@ +# Feature Audit — Acceptance Criteria Verification, Issue #797 + +- Date: 2026-09-07 +- Timestamp label: 2026-09-07T22-40 +- Work Mode: `full-bug` (marker at `issue.md` line 12) +- Authoritative AC source: `spec.md` in this feature folder, `## Acceptance Criteria` section, lines 455-462. Under `full-bug` the acceptance-criteria-tracking skill resolves the AC source to `spec.md` only. `user-story.md` is correctly absent and its absence is not a finding. `issue.md` lines 96-103 carry a verbatim mirror of the same eight criteria; both files were checked and agree. +- Baseline: `origin/main` at `c431dc3297e864041d829e8d79b348960b8d8019` +- Branch: `bug/folder-settings-never-persist-797` +- Evidence base: `artifacts/797-source-review.patch`, the working-tree post-image of every production file cited, the committed evidence tree under `evidence/`, and the two session Cobertura documents read directly. + +## Verdict + +**PASS.** Seven of eight acceptance criteria are delivered and verified. One (AC3) is UNVERIFIED because it requires a live Outlook restart; it is correctly unchecked in both requirement files and handed to the maintainer with a nine-step procedure. **Zero blocking findings.** + +| Result | Count | +|---|---| +| PASS | 7 | +| PARTIAL | 0 | +| FAIL | 0 | +| UNVERIFIED | 1 | +| **Total** | **8** | + +--- + +## Acceptance criteria evaluation + +| AC | Criterion (abbreviated) | Verdict | Implementing code | Verifying test | +|---|---|---|---|---| +| AC1 | Fresh-build path adopts the resource-defined disk configuration; first Save creates the file | **PASS** | `AppOlObjects.StoreLoading.cs` 39-42, 76-79 | `LoadStoresAsync_WhenConfigDeserializesToNull_FreshWrapperAdoptsLoaderDiskConfiguration`; negative `LoadStoresAsync_WhenConfigKeyIsAbsent_FreshWrapperKeepsEmptyDiskPath` | +| AC2 | `Serialize()` logs an error on empty or null `Config.Disk.FilePath` | **PASS** | `SmartSerializable.cs` 451-472 | `Serialize_WithEmptyDiskPath_LogsErrorAndArmsNoTimer`; `Serialize_WithNullDiskPath_LogsErrorAndArmsNoTimer` | +| AC3 | Saved value present after an Outlook restart (manual verification) | **UNVERIFIED** | AC1, AC2 and AC4 supply the mechanism | Not automatable; manual handoff at `evidence/other/p6-t1-ac3-manual-verification.2026-09-06T22-00.md` | +| AC4 | An explicit Save is not lost inside the 3-second deferred window | **PASS** | `SmartSerializable.cs` 485-499; `StoreWrapperController.cs` 285-290 | `SerializeNow_WithConfiguredPath_WritesWithoutFiringTimer`; unchanged-path pin `Serialize_WithConfiguredPath_StillRequiresTimerFireToWrite` | +| AC5 | Double-persistence removed or made loud; reflection replaced by a typed seam | **PASS** | `IJunkFolderSelectionSink.cs`; `AppOlObjects.JunkFolders.cs` 19, 54-57; `StoreWrapperController.cs` 333-346 | `PersistJunkFolderSelections_PassesJunkCertainPathFirst`; `PersistJunkFolderSelections_WhenGlobalsAreNotTheTypedSink_LogsErrorAndDoesNotInvoke`; retargeted `PersistJunkFolderSelections_WhenApplyMethodIsMissing_DoesNotThrow` | +| AC6 | SMTP address shown; specific message with reason on failure; fallback source; retry on dialog open | **PASS** | `StoreWrapper.cs` 199-297; `StoreWrapperController.Display.cs` 41-51, 78-86 | four `GetSmtpAddressFromStore_*` cases; `RefreshUserEmailAddress_WhenRootFolderIsNull_ReturnsNullAndDoesNotThrow`; three `PopulateWithCurrent_When*` cases | +| AC7 | Inbox and Root Folder displayed without the leading `\\` | **PASS** | `StoreWrapperController.Display.cs` 48-50, 155-177 | six `TrimStorePrefix_*` cases; `PopulateWithCurrent_RendersInboxAndRootFolderWithoutStorePrefix` | +| AC8 | A null `Current` renders the placeholder instead of throwing | **PASS** | `StoreWrapperController.Display.cs` 30-33, 121-129 | `PopulateWithCurrent_WithNullCurrent_RendersPlaceholdersAndDoesNotThrow`; `GetRelativeFsPath_WithNullCurrent_ReturnsPlaceholderAndDoesNotThrow`; inverted `PopulateWithCurrent_NullCurrent_SetsErrorLoadingText` | + +--- + +## Per-criterion verification detail + +Each criterion below was traced to concrete code and to a named test rather than accepted from the executor's own mapping. Where the verdict depended on a chain of facts, the chain is set out so it can be re-checked. + +### AC1 — PASS + +> When `StoresWrapper.json` is absent, the fresh-build path adopts the resource-defined disk configuration so `Config.Disk.FilePath` resolves to `%LocalAppData%\TaskMaster\StoresWrapper.json`, and the first Save creates the file. + +**Implementation.** `TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs` hoists the `TryGetValue` result into a `configFound` local (lines 39-42) so the branch fact survives past the `if/else`, then applies the loader's configuration after the fresh build (lines 76-79): `StoresWrapper.Config.CopyFrom(config.Config, true);`. + +**Why the adoption reaches the field the serializer reads.** The chain was walked in full and holds at every link: + +1. `StoresWrapper : SmartSerializable` (`StoresWrapper.cs` line 17), so `StoresWrapper.Config` **is** `SmartSerializable.Config` at `SmartSerializable.cs` line 69 — the same property `TryGetSerializationPath` reads at line 453. There is no second configuration object. +2. `NewSmartSerializableConfig.CopyFrom(other, deep: true)` (`NewSmartSerializableConfig.cs` lines 197-214) deep-copies and calls `Disk.CopyFrom(other.Disk)`. +3. `FilePathHelper.CopyFrom` (`FilePathHelper.cs` lines 449-458) assigns `_filePath = other._filePath` directly. + +**Consistency check.** The statement uses the identical idiom already present on the successful-deserialize paths at `SmartSerializable.cs` lines 224, 246 and 302, so the fresh-build branch is now brought into line with the branch that already worked. This corroborates the specification's claim that the shared overload needed no change. + +**Scope correctness.** The `configFound` guard correctly excludes the key-absent branch, which the criterion places out of scope and which AC2's error log makes visible rather than silent. + +**Test.** The positive test injects a loader whose disk path is the fixed non-existent value `X:\FakeAppData\TaskMaster\StoresWrapper.json` and a deserialize stub returning null, then asserts the fresh wrapper's `Config.Disk.FilePath` equals that value. The negative test asserts the empty path is retained when the key is absent. Neither touches the filesystem. + +**Bounded residual.** "The first Save creates the file" is proven up to the seam, not to disk: the tests prove the path is populated and AC4's tests prove the write goes through the stream-writer seam, but no automated test writes a real file, correctly, because the unit test policy forbids it. The disk residual is exactly what the AC3 manual procedure covers. + +### AC2 — PASS + +> `SmartSerializable.Serialize()` logs an error (not a silent return) when invoked with an empty or null `Config.Disk.FilePath`. + +**Implementation.** `TryGetSerializationPath(out string filePath)` at `SmartSerializable.cs` lines 451-464 replaces the previous `if (Config.Disk.FilePath != "")` guard. It uses `string.IsNullOrEmpty`, which closes the null hole the criterion names, and on rejection calls `logger.Error` with a message naming the type and the offending value. `Serialize()` (lines 466-472) routes through it. + +**Both cases are genuinely covered.** The empty-string case and the null case are separate tests. The null case is not hypothetical: the pre-change guard compared only against `""`, and `FilePathHelper` can assign a null `_filePath`, so a null path previously passed the guard and reached the write path. + +**Factoring.** The guard is shared by `Serialize()` and `SerializeNow()`, so the deferred and explicit entry points cannot diverge in their diagnostic. This is the reason AC4's fix does not reintroduce a silent failure. + +**Test.** Both tests attach an in-memory log4net appender to the root logger of the repository owning the serializer, filter captured events by the test's own probe type name, assert at least one error-level event, and additionally assert `timerFactoryCallCount == 0` and `timerStub.Started == false` — proving the rejecting path arms no timer as well as logging. Both restore the logger state in a `finally`. + +**Coverage.** `TryGetSerializationPath` reads `line-rate="1" branch-rate="1"` in the post-change Cobertura document, verified directly by this reviewer. + +### AC3 — UNVERIFIED + +> A value saved in Folder Settings is present after an Outlook restart (manual verification). + +**Verdict basis.** The criterion requires a live Outlook process with this build of the VSTO add-in loaded, a real user profile directory, and a full process teardown and restart. None is available in an agent environment, and the executing agent was directed not to start one, load the add-in, or drive any user interface. + +**The handoff is honest and complete.** `evidence/other/p6-t1-ac3-manual-verification.2026-09-06T22-00.md` records `AC3-RESULT: BLOCKED-MANUAL`, reproduces all nine procedure steps verbatim from the plan, marks every one NOT PERFORMED with an individual reason, fabricates no observation, and explains why no annotation was added beside the criterion line (the AC-tracking skill permits exactly one edit to a criterion line — `- [ ]` to `- [x]` — and the specification forbids rewording). The checkbox is left unmarked and byte-identical to its authored text in both `spec.md` line 457 and `issue.md` line 98. Plan task P6-T4 is correspondingly left unchecked, which is the plan's own conditional branch behaving as designed rather than an incomplete execution. + +**What the automated evidence does establish.** The three mechanisms that produce the AC3 symptom are each independently proven: the fresh-build path now adopts a real disk path (AC1), the serializer no longer returns silently on an empty or null path (AC2), and an explicit Save writes inline rather than through the deferred timer (AC4). What is not established is that the file appears on disk in a live VSTO host. + +**Assessment.** Honest non-verification, correctly recorded, correctly not checked off. It is not evaluated as PASS and it is not treated as a blocking defect. It is handed to the maintainer. + +### AC4 — PASS + +> An explicit Save is not lost if Outlook closes within the 3-second deferred-write window (flush on save or on shutdown). + +**Implementation.** `SerializeNow()` at `SmartSerializable.cs` lines 485-499 evaluates the AC2 guard and then calls `SerializeThreadSafe(filePath)` inline. `StoreWrapperController.SaveChanges` line 290 switches from `Model.Serialize()` to `Model.SerializeNow()`. Every other caller of `Serialize()` keeps the deferred behaviour unchanged. + +**No lost-write window exists.** `SerializeNow` never consults or consumes the single-shot guard before writing — it writes unconditionally once the path guard passes — so no interleaving of a pending deferred timer with an explicit save can drop the explicit save. Both interleavings were traced: a deferred request followed by an explicit save produces two writes of current state, and an explicit save followed by a deferred request produces an inline write plus a later deferred one. Neither loses data. + +**Preconditions honoured.** `SerializeThreadSafe` requires a non-null `_parent`; `StoresWrapper` sets it in both constructors, so the guard is satisfied on both the deserialized and the fresh-built model. The AC2 guard is evaluated before the synchronous write, so the fix does not substitute one silent failure for another — an explicit D3 requirement. + +**The unchanged-behaviour claim is directly evidenced.** `Serialize_WithConfiguredPath_StillRequiresTimerFireToWrite` asserts the deferred path arms the timer, writes nothing until `FireElapsed()`, and then writes exactly once. Placing it in the same file as the explicit-save test means the "deferred path is unchanged" claim rests on an assertion rather than on prose. + +**Two secondary hazards, neither defeating the criterion.** The explicit save now performs file I/O and an infinite-timeout write-lock acquisition on the UI thread, and it re-arms the single-shot guard up to three seconds early, permitting a redundant second timer. Both are recorded as CR-2 and CR-3 in `code-review.2026-09-07T22-40.md`. Neither loses a write, which is what the criterion requires. + +**Coverage.** `SerializeNow` reads `line-rate="1" branch-rate="1"`, verified directly. + +### AC5 — PASS + +> The junk-folder double-persistence path is either removed or made to fail loudly; the reflection lookup is replaced by a typed seam. + +**The seam is genuinely typed.** `UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs` declares a single member whose XML documentation states the parameter order is part of the contract. `AppOlObjects` gains `: IJunkFolderSelectionSink` and an **explicit** implementation at lines 54-57, so the type's public surface does not widen. Project reference direction is preserved: declared in `UtilitiesCS`, implemented in `TaskMaster`. + +**No reflection fallback survives.** Three checks, all passed: the `GetMethod` call with its `BindingFlags` and parameter-type array is deleted outright; the `using System.Reflection;` directive is removed from `StoreWrapperController.cs`, which the file could not compile without if any other reflection use remained, and both the analyzer and nullable rebuilds exited 0; and a pattern search over the patch finds no replacement reflection API. + +**The double-persistence path fails loudly at the seam.** `if (olObjects is not IJunkFolderSelectionSink sink)` is followed by `logger.Error` — upgraded from the previous `logger.Warn` — naming the interface, then `return`. The criterion's disjunction is satisfied by the fail-loudly branch, which is the reading design decision D2 selects in the authoritative specification. + +**No infinite recursion in the forwarder.** The explicit implementation's body calls `ApplyJunkFolderSelections(a, b)` unqualified. This is safe only because an explicit interface implementation is excluded from the type's own member lookup, so the call binds to the `internal` method at lines 36-45. Verified that the internal method exists with the matching signature, and that argument order is preserved: the first parameter routes to `WriteJunkCertainSetting` and the second to `WriteJunkPotentialSetting`. + +**Argument order is pinned by test, as the criterion detail requires.** `PersistJunkFolderSelections_PassesJunkCertainPathFirst` uses distinguishable values (`Inbox\Certain Folder`, `Inbox\Potential Folder`) and asserts both positions plus an invocation count of 1. + +**The loud-failure branch retains coverage.** `NonSinkOlObjects` declares a `public void ApplyJunkFolderSelections(string, string)` with the historic name and signature but does not implement the interface — precisely the case that discriminates the old binding from the new one. The pre-existing negative test is retargeted onto it rather than deleted. + +**Recorded residual.** The second persistence mechanism itself is retained by design; a divergence between the JSON model and the .NET user settings is not made loud, only the seam-absence case is. `spec.md` risk 4 records this as an accepted rollout consequence and step 9 of the AC3 procedure is written to check it. Recorded as CR-5, advisory. + +### AC6 — PASS + +> User Email shows the SMTP address; on lookup failure it shows a specific message including the reason, falls back to an alternative source (the account SMTP address or the store display name when it is an SMTP address), and the lookup is retried when the dialog opens. + +Each of the four obligations was checked separately. + +**Shows the SMTP address.** `PopulateWithCurrent` renders `Current?.UserEmailAddress` when non-null. `GetSmtpAddressFromStore_WhenPrimarySmtpAddressIsPresent_ReturnsIt` pins the success path and additionally asserts `LastSmtpLookupError` is cleared to null, so a stale reason cannot accompany a resolved address. + +**Falls back to an alternative source, in the specified order.** `StoreWrapper.GetSmtpAddressFromStore` implements exactly: Exchange primary SMTP; then `addressEntry?.Address` when it contains an at-sign; then `DisplayName` when it contains an at-sign; then null. The substantive repair is that each of the first two steps carries its **own** `catch (COMException)` that captures the reason and continues, replacing a single outer catch that converted any failure anywhere in the chain into `return null`. One implementation detail is load-bearing and correct: the `addressEntry` local is hoisted above the first `try` so the second step can still use it after the first throws — had it stayed inside the first block, the address-entry fallback would have been unreachable on exactly the path that needs it. Three tests cover cases 2, 3 and 4 of the order. + +**Specific message including the reason.** `BuildUserEmailUnavailableText` returns `"Email address unavailable"` when no reason was captured and `"Email address unavailable: {reason}"` when one was, replacing the generic `"Error Loading"` for this label only. `PopulateWithCurrent_WhenRetryFails_RendersSpecificMessageWithReason` asserts both that the reason text appears and that the value is not `"Error Loading"` — the correct pair, since asserting only the former would not prove the generic placeholder was displaced. + +**Retried when the dialog opens.** The retry is gated on `Current is not null && Current.UserEmailAddress is null` at `StoreWrapperController.Display.cs` lines 48-51. `PopulateWithCurrent_WhenUserEmailIsNull_RetriesLookupAndRendersAddress` proves the retry runs, and `PopulateWithCurrent_WhenUserEmailIsAlreadyPopulated_DoesNotRetryLookup` proves it does not run when the address is already populated — the second test is constructed so the mocked chain would yield a *different* address, so an unchanged value is genuine proof that no lookup occurred rather than a coincidence. + +**The accepted COM limitation, and whether the bound holds.** The criterion reintroduces a synchronous Outlook COM property read on the UI thread. The condition `Current.UserEmailAddress is null` does bound the retry to the failing case only. However, the documented bound of "at most once per dialog open" does **not** hold: `PopulateWithCurrent`'s only production call site is `DisplayName_SelectedValueChanged` (`StoreWrapperController.cs` line 169), which fires on every store selection change, and a failed retry leaves `UserEmailAddress` null so the gate stays open. The true bound is one lookup per populate invocation on a store whose address is still null. This is recorded as CR-1 (Medium, advisory) in the code review. It does not defeat AC6, whose authoritative text requires only that the lookup be retried when the dialog opens — which it is. + +**Safety.** `RefreshUserEmailAddress` is safe when `RootFolder` is null because the chain's first read is null-conditional; `RefreshUserEmailAddress_WhenRootFolderIsNull_ReturnsNullAndDoesNotThrow` pins it. `LastSmtpLookupError` carries `[JsonIgnore]`, correctly, since it describes one runtime lookup rather than stored state. + +**Coverage.** `GetSmtpAddressFromStore` reads `line-rate="0.8710" branch-rate="0.9444"`; `RefreshUserEmailAddress` and `BuildUserEmailUnavailableText` both read 1.00 line and 1.00 branch. All verified directly against the post-change Cobertura document. + +### AC7 — PASS + +> Inbox and Root Folder are displayed without the leading `\\` (cosmetic). + +**Implementation.** `TrimStorePrefix(string?)` is a pure private-surface static helper: it returns the input unchanged unless it starts with exactly `\\` (ordinal comparison), in which case it returns `Substring(2)`. Both label assignments route through it. + +**No rendering regression on any input.** The call site is `TrimStorePrefix(Current?.Inbox?.FolderPath) ?? "Error Loading"`. Because the helper returns null for a null input, the placeholder still fires exactly as before; because it returns the empty string unchanged, an empty `FolderPath` still renders as an empty label, matching pre-change behaviour. The trim therefore changes only the case it is meant to change. + +**Boundary coverage.** Six pure-function cases: leading `\\`, no leading backslash, single leading backslash, empty string, null, and prefix-only. Plus `PopulateWithCurrent_RendersInboxAndRootFolderWithoutStorePrefix`, which asserts the rendered label text end to end. + +**Accessibility choice.** `internal static` rather than private, with an in-code rationale: the pure cases are reachable from `UtilitiesCS.Test`, to which the assembly already grants `InternalsVisibleTo`, and `internal` does not widen the controller's public surface. Reasonable and documented. + +**Coverage.** `TrimStorePrefix` reads `line-rate="1" branch-rate="1"`. + +### AC8 — PASS + +> A null `Current` store selection renders the placeholder text instead of throwing. + +**Implementation.** The four previously unguarded dereferences at the top of `PopulateWithCurrent` are now null-conditional (`Current?.ArchiveRoot`, `Current?.ArchiveFsRoot`, `Current?.JunkCertain`, `Current?.JunkPotential`), matching the form the immediately following block already used — the inconsistency inside a single method that the specification identified. `GetRelativeFsPath`'s dereference is guarded so it returns the same `"Please select an archive"` placeholder it already returned for an unset archive root. + +**All reachable dereferences are covered.** The AC6 retry block, which sits between the mirror assignments and the label assignments, is itself guarded by `Current is not null`, so it cannot reintroduce the throw the criterion removes. + +**Tests.** `PopulateWithCurrent_WithNullCurrent_RendersPlaceholdersAndDoesNotThrow` asserts no throw and all six rendered placeholder values. `GetRelativeFsPath_WithNullCurrent_ReturnsPlaceholderAndDoesNotThrow` covers the helper path. + +**The declared inversion.** `PopulateWithCurrent_NullCurrent_SetsErrorLoadingText` previously asserted `act.Should().Throw()` — codifying the defect while its name described the fix. It now asserts no throw plus four exact rendered values. This is a strengthening, not a weakening: the original pinned only an exception type, the replacement pins four exact strings. It was declared in advance as design decision D6 (`spec.md` lines 420-432), which is the correct treatment under the General Code Change Policy's rule that existing tests are part of the specification. + +--- + +## Regression surface + +The specification's D1 claim is that, because the shared deserialize overload is not modified, its behavioural regression surface is empty. This reviewer confirms the overload is untouched: the only hunks against `SmartSerializable.cs` in the patch are the two additions in the Serialization region (lines 1657-1718 of the patch), and neither touches `Deserialize` or `DeserializeJson`. The documented fail-soft null contract relied on by the folder-predictor load path is therefore preserved exactly. + +`spec.md` lines 611-615 states that any failure among the existing test callers that pin the overload's behaviour would indicate it had been modified contrary to D1. The final run was fully green at 5262 of 5262 with zero failures, so no such indication exists. + +The out-of-scope items the specification enumerates were each confirmed untouched: `SmartSerializableBase.cs` receives no hunk; `IOlObjects` is not extended; no VSTO add-in lifecycle file appears in the working set; no resource file appears; the QuickFiler recipient-resolution blocking hazard is not addressed; and the dead-branch observation at the former `StoreWrapperController.cs` line 466 is left as found. + +--- + +## Test execution and coverage summary + +- Final scoped run: 5262 total, 5262 passed, 0 failed, 0 skipped, exit code 0. +- Tests added: 25 (baseline 5237 to 5262, which reconciles exactly). +- Red-first: 16 tests recorded as failing before their fixes; all 16 enumerated as passing after, in full rather than sampled. +- Changed-line coverage: 91.09 percent over 101 executable changed lines, against the 90 percent requirement CLAUDE.md sets for new and changed code. +- No-regression: post-change 53.26 percent is not below the baseline 53.23 percent, on a comparable denominator (`lines-valid` moved 0.085 percent, inside rule R9's 5 percent tolerance). +- Both figures independently re-verified by this reviewer against the raw Cobertura root elements, and every new production member's own line and branch rate read directly. Details in `policy-audit.2026-09-07T22-40.md` section 5. +- No coverage-exclusion attribute is introduced and no production file is excluded from measurement. + +The absolute repository line coverage of 53.26 percent sits below both documented floors. This is pre-existing under the two-assembly measurement scope, was already 53.23 percent at baseline, and is recorded as an observation under plan rule R8 rather than raised as a finding. See the policy audit for the full authority analysis. + +--- + +## Findings affecting acceptance criteria + +None. Zero blocking findings. Six advisory findings are recorded in `code-review.2026-09-07T22-40.md`; the only one that touches a criterion's supporting prose is CR-1, which corrects an overstated bound in the AC6 documentation without defeating the criterion itself. + +No remediation-inputs artifact is produced. + +--- + +## Acceptance Criteria Status + +``` +### Acceptance Criteria Status +- Source: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md + (authoritative under full-bug work mode), mirrored verbatim in the same folder's issue.md +- Total AC items: 8 +- Checked off (delivered): 7 +- Remaining (unchecked): 1 +- Items remaining: AC3 — "A value saved in Folder Settings is present after an Outlook restart (manual verification)." +``` + +**Check-off action taken by this reviewer: none required.** All seven criteria evaluated PASS were already checked `- [x]` in both `spec.md` and `issue.md` by the executor, and both files agree. AC3, evaluated UNVERIFIED, is correctly left `- [ ]` in both files and its criterion text is byte-identical to the authored wording. No criterion text was altered and no criterion was added. + +## Outstanding work + +1. **AC3 manual verification.** Owner: the project maintainer. Procedure: the nine steps in `evidence/other/p6-t1-ac3-manual-verification.2026-09-06T22-00.md`. On success, change `- [ ]` to `- [x]` on the AC3 line in `spec.md` and mirror the same single-character change in `issue.md`, and nothing else. On failure, do not check it off; report against issue #797. Step 9 additionally checks the known junk-folder rollout consequence recorded as `spec.md` risk 4. +2. **CR-1**, the AC6 retry bound, is worth addressing in a follow-up: either add a per-store or per-controller attempted flag so the retry is genuinely once per dialog open, or correct the three code comments and the specification prose to state the actual bound. +3. **Pre-existing, out of scope by design.** The `SmartSerializable.cs` 500-line cap violation (D5), the removal of the second junk-folder persistence mechanism (D2), the non-blocking Outlook COM read (Non-Goals item 8), and the QuickFiler recipient-resolution blocking hazard (Non-Goals item 7) each remain open and each should be raised as its own work item rather than folded into a bug fix. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md new file mode 100644 index 000000000..bd5d0c883 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md @@ -0,0 +1,114 @@ +# folder-settings-never-persist-and-user-email-error-loading (Issue #797) + +- Date captured: 2026-09-06 +- Author: Dan Moisan +- Status: Promoted -> docs/features/active/folder-settings-never-persist-and-user-email-error-loading/ (Issue #797) + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +- Issue: #797 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/797 +- Last Updated: 2026-09-07 +- Work Mode: full-bug + +## Summary + +Values chosen in Settings -> Folder Settings (Archive Root Outlook, Archive Root File System, Junk Potential, Junk Email) survive only for the current Outlook session and are lost on restart, because the settings file `StoresWrapper.json` has never been created and the save path silently does nothing when no file path is configured. In the same dialog, User Email renders "Error Loading" because the Exchange SMTP lookup throws a COM exception that is caught, logged, and rendered as a generic placeholder with no fallback and no retry. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 +- Runtime: .NET Framework 4.8 VSTO Outlook add-in, debug build loaded from `TaskMaster\bin\Debug`, HEAD `c431dc32` (2026-09-06) +- Command/flags used: Outlook ribbon -> Settings -> Folder Settings (`RibbonController.FolderStoresSettings`, `StoreWrapperController.Launch`) +- Data source or fixture: live Exchange mailbox `dmoisan@realgoodfoods.com`; one included store, one Google Workspace store excluded by the GWSO filter + +## Steps to Reproduce + +1. Confirm `%LocalAppData%\TaskMaster\StoresWrapper.json` does not exist (it has never existed on this machine; every other TaskMaster JSON file is present in that folder). +2. Start Outlook, open Settings -> Folder Settings. Observe: Archive Root Outlook and File System show "Please select an archive"; Junk Potential and Junk Email show "Please select a folder"; User Email shows "Error Loading"; Inbox shows `\\dmoisan@realgoodfoods.com\Inbox`; Root Folder shows `\\dmoisan@realgoodfoods.com`. +3. Select a value for Archive Root Outlook and click Save. Reopen the dialog in the same session: the value is retained. +4. Close Outlook, reopen it, open Folder Settings again: the value is gone and the placeholder is back. +5. Confirm `StoresWrapper.json` still does not exist. + +## Expected Behavior + +- A saved Folder Settings value is written to `%LocalAppData%\TaskMaster\StoresWrapper.json` and restored on the next Outlook start. +- A save that cannot be written is reported as an error in the log, never silently dropped. +- User Email shows the mailbox SMTP address. When the Exchange user lookup fails, the dialog shows a specific unavailability message with the reason, falls back to another source for the address, and retries the lookup when the dialog is opened rather than only at startup. +- Cosmetic: Inbox and Root Folder display without the leading `\\` store prefix. + +## Actual Behavior + +- The file is never created; every Outlook start rebuilds an empty stores wrapper and the placeholders return. +- No error is logged when Save runs with no file path. +- User Email shows "Error Loading" every time. +- Inbox and Root Folder show Outlook's native `\\\` form. + +## Logs / Screenshots + +- [x] Attached minimal logs or screenshot +- Snippet (`TaskMaster\bin\Debug\logs\debug_2026-09-06.log`): + +``` +2026-09-06 17:29:59,517 [VSTA_Main] WARN TaskMaster.AppOlObjects - StoresWrapper config deserialized to null; rebuilding from live stores. +2026-09-06 17:29:59,560 [VSTA_Main] DEBUG UtilitiesCS.OutlookObjects.Store.StoresWrapper - [store-filter] displayName=dmoisan@realgoodfoods.com exchangeStoreTypeMs=0.0 filePathMs=0.0 included=true rule=Included +2026-09-06 17:29:59,592 [VSTA_Main] ERROR UtilitiesCS.OutlookObjects.Store.StoreWrapper - Error retrieving PrimarySmtpAddress from secondary inbox. The operation failed. + at UtilitiesCS.OutlookObjects.Store.StoreWrapper.GetSmtpAddressFromStore() in ...\UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs:line 184 +``` + +- Filesystem evidence (2026-09-06): `%LocalAppData%\TaskMaster` contains `ManagerFolder.json`, `9999999RecentsFile.json`, `UsedIDList.json`, etc. A recursive search of the user profile finds no `StoresWrapper.json` anywhere. + +## Impact / Severity + +- [ ] Blocker +- [x] High +- [ ] Medium +- [ ] Low + +The Folder Settings dialog cannot persist any per-store setting on a machine where the file has never been created, which is every fresh install. The archive root and junk folder settings it manages feed filing and junk-mail workflows. The silent no-op on save means the defect produces no diagnostic signal. + +## Suspected Cause / Notes + +Root cause 1 (verified by code read and by the log line above): a bootstrap gap between the loader and the serializer. + +- `TaskMaster\AppGlobals\AppOlObjects.StoreLoading.cs:35-65` (`LoadStoresAsync`) deserializes via `SmartSerializable.Deserialize(config)`. +- `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializableBase.cs:167-188` (`Deserialize(loader)`) calls `DeserializeJson(loader.Config.Disk, ...)`, which returns null when the file does not exist (`:335-342`). The loader's disk configuration is copied onto the instance only inside `if (instance is not null)` (`:176-180`), so it is discarded on the null path. +- The loader then calls `BuildFreshStoresWrapper()` (`:64`) = `new StoresWrapper(_globals).Init()`. The fresh instance's `Config.Disk.FilePath` is the `FilePathHelper` default `""` (`UtilitiesCS\HelperClasses\FileSystem\FilePathHelper.cs:72-102`); nothing assigns the resource-defined path (`UtilitiesCS\IntelligenceResources.resx:176-203`, `FileName: StoresWrapper.json`, `SpecialFolderName: AppData`). +- `StoreWrapperController.SaveChanges` (`UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs:348-357`) calls `Model.Serialize()`. `SmartSerializable.Serialize()` (`UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs:442-448`) is `if (Config.Disk.FilePath != "") RequestSerialization(...)`, so with an empty path it returns without writing and without logging. +- Because the file is never written, every subsequent start takes the same null path. In-session persistence works only because the values live in the in-memory `StoreWrapper` (`SaveChanges` lines 350-353). +- Contrast: the overload `Deserialize(loader, askUserOnError, altLoader)` at `SmartSerializableBase.cs:190-240` copies the loader config onto the instance regardless (`:236`) and writes the instance when `writeInstance` is set. `RecentFolders` uses the `askUserOnError: true` variant (`TaskMaster\AppGlobals\AppAutoFileObjects.cs:217-222`) and its file exists. + +Root cause 2 (verified by the log): `StoreWrapper.GetSmtpAddressFromStore` (`UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs:179-217`) threw `COMException` "The operation failed." at line 184 (`RootFolder?.Session?.CurrentUser`), caught and converted to null. `StoreWrapperController.PopulateWithCurrent` (`StoreWrapperController.cs:294-296`) renders null as "Error Loading". The lookup runs once in `StoreWrapper.Init` (`:83`) and is never retried. The Outlook-side cause of the COM failure is not determinable from the log. The same session's `ThreadMonitor` captured the UI thread inside `_ExchangeUser.get_PrimarySmtpAddress()` at 17:35:21, so a second caller of this chain also blocks on it. + +Not a defect: the `\\` prefix on Inbox and Root Folder is Outlook's native `MAPIFolder.FolderPath` read directly at `StoreWrapperController.cs:294-295`. Trimming is a cosmetic acceptance criterion only. + +Related latent defects in the same files, to be fixed in the same change: + +- `SmartSerializable.RequestSerialization` (`SmartSerializable.cs:550-559`) defers the write by a 3-second single-shot timer. Closing Outlook within that window loses the save. A shutdown flush or synchronous write on explicit Save is needed. +- `StoreWrapperController.PersistJunkFolderSelections` (`StoreWrapperController.cs:391-418`) reaches `AppOlObjects.ApplyJunkFolderSelections` by reflection and silently returns with only a warning when the method is not found; the junk folders are therefore persisted twice (per-store JSON and `.NET` user settings at `TaskMaster\AppGlobals\AppOlObjects.JunkFolders.cs:27-34`) by two mechanisms that can diverge. +- `StoreWrapperController.cs:169` can assign a null `Current`; `PopulateWithCurrent` dereferences it unguarded at `:288-291` before the null-safe reads at `:294-296`, so an unmatched store selection throws instead of showing the placeholder. +- `StoreWrapperController.GetRelativeFsPath` (`:456-474`) uses `&` rather than `&&` at `:464`. + +## Proposed Fix / Validation Ideas + +Acceptance criteria settled with the maintainer on 2026-09-06: + +- [x] AC1: When `StoresWrapper.json` is absent, the fresh-build path adopts the resource-defined disk configuration so `Config.Disk.FilePath` resolves to `%LocalAppData%\TaskMaster\StoresWrapper.json`, and the first Save creates the file. +- [x] AC2: `SmartSerializable.Serialize()` logs an error (not a silent return) when invoked with an empty or null `Config.Disk.FilePath`. +- [ ] AC3: A value saved in Folder Settings is present after an Outlook restart (manual verification). +- [x] AC4: An explicit Save is not lost if Outlook closes within the 3-second deferred-write window (flush on save or on shutdown). +- [x] AC5: The junk-folder double-persistence path is either removed or made to fail loudly; the reflection lookup is replaced by a typed seam. +- [x] AC6: User Email shows the SMTP address; on lookup failure it shows a specific message including the reason, falls back to an alternative source (the account SMTP address or the store display name when it is an SMTP address), and the lookup is retried when the dialog opens. +- [x] AC7: Inbox and Root Folder are displayed without the leading `\\` (cosmetic). +- [x] AC8: A null `Current` store selection renders the placeholder text instead of throwing. + +Validation: + +- [ ] Unit coverage areas: `SmartSerializableBase.Deserialize` null-file path copies loader config onto the fallback instance; `SmartSerializable.Serialize()` with empty path logs an error (Moq on the logger seam or an injectable log sink); `StoreWrapperController.PopulateWithCurrent` null-`Current` path; SMTP fallback ordering; `\\` trim helper. +- [ ] Integration scenario to retest: fresh profile with no `StoresWrapper.json`, save archive root, restart, reopen dialog. +- [ ] Manual verification notes: confirm the file is created on first Save and the log contains no serializer error; confirm User Email populates or shows the specific message. + +## Next Step + +- [ ] Promote to GitHub issue (bug-report template) +- [ ] Move to active fix folder / branch diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/plan.2026-09-06T22-00.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/plan.2026-09-06T22-00.md new file mode 100644 index 000000000..553712cfd --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/plan.2026-09-06T22-00.md @@ -0,0 +1,839 @@ +# Atomic Plan — Folder Settings never persist; User Email shows "Error Loading" (Issue #797) + +- Issue: #797 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/797 +- Branch: bug/folder-settings-never-persist-797 +- Base commit: c431dc3297e864041d829e8d79b348960b8d8019 +- Work Mode: full-bug +- Owner: drmoisan +- Last Updated: 2026-09-06T22-00 +- Status: Draft — pending atomic-executor preflight +- Version: 1.3 (preflight revision rounds 1, 2 and 3 applied; same file, no sibling plan created) +- Requirements source (authoritative): spec.md in this feature folder, section `## Acceptance Criteria` +- Supporting sources: issue.md and research/research-folder-settings-persistence.md in this feature folder + +Work mode is full-bug, so spec.md is the single authoritative acceptance-criteria source. This plan +does not introduce, renumber, reword, merge or split any criterion. This is not a minimal-audit plan; +the three-phase minimal-audit contract does not apply. + +--- + +## Formatting convention inherited from spec.md — do not "fix" it + +A downstream scheduler harvests backtick-delimited path tokens to derive the change footprint for a +parallel run against three concurrent sibling work items. This plan therefore reproduces spec.md's +discipline exactly: every file this change creates or modifies is backticked exactly once, inside the +`## Write Set` section below, and nowhere else. Every other file citation in this document is written +as plain prose or inside a fenced command block, without inline backticks, on purpose. Adding an +inline backtick to a citation outside the Write Set would inject a false write claim and needlessly +serialize the run; removing one inside the Write Set would drop a real file from the footprint. + +Two consequences follow, and both are deliberate: + +1. Task lines cite paths with forward slashes and no backticks. Forward slashes are accepted by + PowerShell, by MSBuild and by git, and a path carrying no backslash cannot be silently corrupted + by a doubled-backslash collapse in any tool that transports it. +2. Non-path inline code spans (identifiers, test names, literal strings, quoted runtime values) are + permitted, because they are not path tokens. spec.md's own verbatim acceptance-criteria block + contains such spans for the same reason. + +--- + +## Write Set + +Every file this change creates or modifies appears below as a concrete repository-relative path +inside backticks. This is the only section of this document containing backticked paths. It +reproduces spec.md's `## Write Set` section without addition or removal. + +### Production — modify + +- `TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs` +- `TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs` +- `UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs` +- `UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs` +- `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs` + +### Production — create + +- `UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs` +- `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs` + +### Tests — modify + +- `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs` +- `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs` +- `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs` +- `TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs` + +### Tests — create + +- `UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs` +- `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs` + +### Project compile-entry carriers — modify + +Every project in this solution is non-SDK-style, verified against this worktree: each project file +opens with a ToolsVersion attribute and the 2003 MSBuild namespace and closes with an import of the +C# targets, and no project file carries an Sdk attribute. A newly created C# file is therefore not +picked up by a wildcard and must be registered by a hand-added compile entry. + +- `UtilitiesCS/UtilitiesCS.csproj` +- `UtilitiesCS.Test/UtilitiesCS.Test.csproj` +- `TaskMaster.Test/TaskMaster.Test.csproj` + +The third entry is retained as a write claim to keep the parallel run schedule-safe. It is required +only if the AC1 tests are placed in a new file under the TaskMaster test project's AppGlobals +directory. This plan directs the executor to append the AC1 tests to the already-registered +AppOlObjectsCoverageTests.cs file, which is 347 lines and has ample headroom, so that project file is +expected to end the change unmodified. Claiming it unconditionally is the conservative choice, and +every scope gate in Phase 5 is written as a subset test rather than an equality test so that an +unmodified claimed file does not fail the gate. + +### Requirements document — modify + +- `docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md` + +This file is modified for acceptance-criteria check-off only, mirroring the check-offs made in +spec.md. No criterion text is altered. + +### Files written but excluded from the Write Set by spec.md's stated convention + +Stated in plain prose without backticks so that no false write claim is injected. This change also +writes, inside this feature folder only: the specification document spec.md, for acceptance-criteria +check-off only; this plan file, for task check-off only; and the timestamp-named evidence artifacts +under the feature folder's evidence directory. spec.md excludes itself and the timestamp-named +research and evidence artifacts from the Write Set by convention, and this plan reproduces that +convention. These paths carry no scheduling risk, because each concurrent sibling work item writes +into its own feature folder. + +### Files not written, stated in plain prose + +The TaskMaster production project file, TaskMaster dot c-s-p-r-o-j, needs no compile-entry change, +because the two files that receive the AC1 and AC5 production edits are already registered in it. The +Visual Studio solution file, TaskMaster dot s-l-n at the repository root, is not modified, because +every project that receives a new source file already exists in the solution and only its own project +file changes. No repository-root build property file is modified. + +One correction to spec.md is recorded here, because spec.md states that no Directory dot Build dot +props file and no Directory dot Build dot targets file exists anywhere in this repository. Both files +do exist at the repository root as of the base commit; Directory dot Build dot props sets a single +property, RxUseUnsupportedPackagesConfig, under issue #730. The scope constraint is unaffected and +strengthened: neither file is created, modified, or otherwise involved by this change. No file at the +repository root is written by this change. + +No file with an extension of r-e-s-x, config, props or targets is created or modified. The resource +entry that defines the settings file name and its AppData special folder already carries the correct +values, per D7. + +### Explicit scope constraints + +- Nothing under the dot-claude, dot-codex or dot-agents trees is edited. +- Neither published JSON file under the config directory is edited. +- No GitHub workflow file is edited. +- The footprint stays inside the Write Set above, plus the feature-folder documents named in the + preceding subsection. + +--- + +## Acceptance Criteria (reproduced verbatim from spec.md) + +The eight criteria below are reproduced verbatim from spec.md. They are the same criteria, not +additional ones. Check-off is performed in spec.md and mirrored into issue.md, one criterion per +task, in Phase 6. The acceptance-criteria-tracking skill's one-at-a-time and evidence-before-check-off +rules are satisfied by Phase 6's structure: one task per criterion, each gated on a named evidence +artifact, each asserting that no other checkbox changes state. The skill's timing guidance is met by +that gating rather than by interleaving, because every criterion's verifying artifact exists before +its check-off task runs and no check-off is batched. + +AC1: When `StoresWrapper.json` is absent, the fresh-build path adopts the resource-defined disk configuration so `Config.Disk.FilePath` resolves to `%LocalAppData%\TaskMaster\StoresWrapper.json`, and the first Save creates the file. + +AC2: `SmartSerializable.Serialize()` logs an error (not a silent return) when invoked with an empty or null `Config.Disk.FilePath`. + +AC3: A value saved in Folder Settings is present after an Outlook restart (manual verification). + +AC4: An explicit Save is not lost if Outlook closes within the 3-second deferred-write window (flush on save or on shutdown). + +AC5: The junk-folder double-persistence path is either removed or made to fail loudly; the reflection lookup is replaced by a typed seam. + +AC6: User Email shows the SMTP address; on lookup failure it shows a specific message including the reason, falls back to an alternative source (the account SMTP address or the store display name when it is an SMTP address), and the lookup is retried when the dialog opens. + +AC7: Inbox and Root Folder are displayed without the leading `\\` (cosmetic). + +AC8: A null `Current` store selection renders the placeholder text instead of throwing. + +### Root-cause traceability + +Root cause 1 is the bootstrap gap between the loader and the serializer. It carries AC1, AC2, AC3, +AC4 and AC5. Its automatable implementation is Phase 2 (AC1, AC2, AC4) and the AC5 portion in Phase 4. +Root cause 2 is the unretried COM failure in the Exchange SMTP lookup. It carries AC6 alone and its +implementation is Phase 3. The two root causes are kept in separate phases and separate evidence +artifacts so that a failure is attributable to exactly one of them. AC7 and AC8 are adjacent defects +in the same rendering method and land in Phase 4. + +### AC3 is manual and is not automated + +AC3 requires an Outlook restart and is not automatable in this environment. No automated gate in this +plan claims to prove it. AC3 is verified by the written manual procedure in Phase 6, its result is +recorded in a manual-verification evidence artifact, and its fail-before requirement is discharged by +a fail-before exception dossier authored in Phase 1, per the evidence-and-timestamp-conventions +skill. Every other criterion has a real automated test. + +--- + +## Settled design decisions (D1 through D7, not reopened) + +These are recorded in spec.md as the chosen approach and are not re-opened by this plan. + +D1. AC1 is fixed only in the TaskMaster store-loading globals partial, by applying the already-in-scope +loader configuration to the freshly built wrapper. The shared deserialize overload is not changed; its +null return is a load-bearing fail-soft contract for the folder-predictor load path. + +D2. AC5 introduces a new dedicated interface for the junk-folder sink in the UtilitiesCS interfaces +tree, implemented explicitly by the TaskMaster junk-folders globals partial, replacing the reflection +lookup with a typed cast that logs an error when the cast fails. The member is deliberately not added +to the existing IOlObjects interface. + +D3. AC4 is satisfied by a synchronous flush on the explicit Save path. The deferred three-second +behaviour for all other callers is unchanged. The VSTO add-in lifecycle file is not modified. + +D4. The store wrapper controller is 478 lines against a 500-line cap and four criteria land in it, so +it is split into a new display partial and the class declaration gains the partial keyword. + +D5. The serializer file is already 613 lines, over the same cap, before any change. It is not split. +This is a pre-existing condition this change does not resolve. No task in this plan splits it. + +D6. The existing test named `PopulateWithCurrent_NullCurrent_SetsErrorLoadingText` asserts that a null +current store throws a `NullReferenceException`, contradicting its own name. AC8 changes that +behaviour, so this plan includes an explicit task to invert that test, declared as a deliberate +test-expectation change. + +D7. No resource file change is required. + +### One recorded reading of D4, not a re-opening + +D4 describes the AC7 trim helper as a small pure private static method on the display partial. The +criterion-to-evidence map in spec.md requires direct pure-function tests over that helper across five +cases. A `private` member is not reachable from the test assembly, so this plan specifies `internal +static`. UtilitiesCS already grants `InternalsVisibleTo("UtilitiesCS.Test")` in its assembly +information file, and `internal` does not widen the public surface of the type, so both stated intents +are satisfied. This is a mechanical accessibility reading, not a change of approach. + +--- + +## Plan-wide execution rules + +These rules apply to every task and are restated here so the executor does not have to infer them. + +### R1 — no shell variable survives between tasks + +Every fenced block runs in its own shell. Re-bind any value the block needs at the top of that block. +In particular, re-derive the base SHA from the Phase 0 artifact rather than pasting a literal: + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md' -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +``` + +An unbound base SHA silently degrades an anchored `git diff` into the ref-less form, which passes +vacuously once the change is committed. Every `git diff` in this plan carries an explicit ref operand. + +### R2 — one fixed helper script path + +Snippets that need a backslash-bearing literal, XML parsing, or vswhere resolution are authored into +one fixed helper at coverage/plan797-helpers.ps1 and invoked with pwsh and the File switch. That path +is inside a git-ignored directory, so it never appears in a porcelain or diff scope gate. The helper +builds its own dotnet-coverage argument list rather than calling the repository coverage runner script +end to end. Three properties of that script make it unusable here, each verified against this worktree +at the base commit: it discovers every test assembly whose name ends in the test dll suffix under its +search root and offers no way to restrict the run to two assemblies; the argument list it builds pins +the vstest test-case filter to the live-Outlook category exclusion alone, so the four shell-icon +exclusions rule R6 requires cannot be added; and it asserts an 80 percent document-level line rate and +throws below it, which would convert a recording step into a gate. The helper therefore reuses that +script's shape — dotnet-coverage collect, cobertura output format, the off-root CLI runsettings, the +isolation switch — while supplying its own assembly list, its own combined test-case filter and its own +output path. It does not pass the repository coverage settings file unmodified. That file excludes +third-party modules only; the repository runner derives a settings document in memory that adds one +further module exclusion matching any module name ending in dot Test dot dll. The helper performs the +same derivation and passes the derived document, so both test assemblies are excluded from +instrumentation and the denominator holds production code only. The helper performs no other +post-processing, so both the Phase 0 and the Phase 5 documents retain any third-party module the +settings did not exclude; they are produced identically and are therefore comparable with each other, +and are not comparable with a document produced by the repository runner. No repository script under +the vscode scripts directory is modified. + +The helper is a session-scoped throwaway: it is created in Phase 0, rewritten in place as later tasks +require, and deleted in P6-T11 before the commit, so it satisfies the general code change policy's +exemption for a script created and deleted within an agent session. Because it is never committed and +is not a repository deliverable, it is not a production PowerShell file for the purposes of the +PowerShell change budget, the PowerShell testing standards or the coverage denominator, and no Pester +test is authored for it. It consumes one of the three per-session production PowerShell slots the +repository's batch-budget hook enforces, and it is the only PowerShell file this plan creates. + +### R3 — toolchain order and the two mandatory constraints + +The C# toolchain runs in exactly this order, and the loop restarts from step 1 if any step fails or +changes files: + +```text +dotnet tool run csharpier format . +dotnet tool run csharpier check . +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true +vstest.console.exe UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll TaskMaster.Test/bin/Debug/TaskMaster.Test.dll /EnableCodeCoverage +``` + +Always the Rebuild target, never Build. MSBuild's up-to-date check does not invalidate on a +command-line property change, so a warm Build returns exit 0 with the compile target skipped and the +analyzer gate becomes vacuous. Never add a solution-wide nullable enable property to the third +command: it is deliberately absent from CI, no project in this repository carries a nullable element, +and forcing it conscripts every file that has never adopted the per-file pragma. A `dotnet tool +restore` is required once per worktree before the first csharpier invocation. + +Neither msbuild nor vstest.console.exe is on PATH in a plain shell in this worktree. Both are resolved +through vswhere before use, by the same mechanism the repository's own build and test scripts under +the vscode scripts directory already use: query the Visual Studio installer's vswhere executable for +the latest installation and take the MSBuild and Test Platform executables it reports. Every msbuild +line written in this plan is shorthand for an invocation of the resolved absolute MSBuild path with +the arguments exactly as written. + +### R4 — how a build gate discriminates pass from fail + +A successful msbuild run prints the substring "error" many times in ordinary output, so a grep for +"error" cannot discriminate pass from fail. Every msbuild acceptance condition in this plan asserts +two things: the process exit code, and the presence of the MSBuild summary count line whose text is +` 0 Error(s)`. When a baseline run is not clean, the acceptance condition instead asserts that the +recorded set of compiler diagnostic identifiers (the `CS` and `CA` codes) is a subset of the Phase 0 +baseline set and contains no diagnostic attributed to a Write Set file. Both branches are stated on +every build task so that neither is left unmeasured. + +### R5 — how a write-mode formatter gate discriminates pass from fail + +The csharpier format subcommand rewrites files and still exits 0, and its summary line reports the +number of files processed rather than the number rewritten, so neither the exit code nor that line +distinguishes a clean run from a repairing one. Every csharpier format task in this plan is paired +with a before-and-after tree observation using `git status --porcelain --untracked-files=all` scoped +to C# sources, and with the read-only check subcommand whose exit code is a real signal. + +### R6 — local vstest invocation + +Test runs use explicit assembly paths, never directory discovery, so no assembly from a sibling +worktree can be discovered. Every run additionally passes the /InIsolation switch that CI uses. +Four shell-icon test classes in the UtilitiesCS test project stall vstest on this workstation for +environmental reasons unrelated to this change: `HelperClasses.ShellUtilities_Tests`, +`HelperClasses.ShellUtilitiesStatic_Tests`, `HelperClasses.SysImageListHelperTests` and +`EmailIntelligence.OSBrowser_Tests`. Every run in this plan excludes them by +`FullyQualifiedName!~` clauses and repeats the `TestCategory!=LiveOutlook` clause on every disjunct +where a disjunction is used, because `&` binds tighter than `|` in a vstest filter expression. The +four shell-icon exclusion clauses are themselves conjunctive, so in a disjunctive filter they bind to +the first disjunct alone and do not constrain the remaining disjuncts. That is inert here, because no +shell-icon test name matches any selector used in the scoped runs, so no disjunct other than the first +could select one; the exclusions are load-bearing only for the unfiltered whole-assembly runs, whose +filter expression carries no disjunction. The filter text recorded verbatim in each evidence artifact +should be read with that binding in mind. CI covers the four excluded classes; their exclusion is +recorded in every test evidence artifact. + +### R7 — evidence artifacts + +Every evidence artifact path resolves under +docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/ +in one of the canonical kind subdirectories: baseline, regression-testing, qa-gates, issue-updates, +other. No evidence is written under an artifacts directory. Every command-step artifact carries the +fields `Timestamp:`, `Command:`, `EXIT_CODE:` and `Output Summary:`. An artifact for a step whose acceptance +admits a non-zero outcome additionally carries `ExpectedExitCode:` with a concrete integer value, one +gate per artifact file. The steps that admit either 0 or 1 depending on the recorded baseline are +P0-T9, P0-T10, P1-T3, P5-T5 and P5-T6, and each carries the field unconditionally with the value its +own run produced; an explicit `ExpectedExitCode: 0` is equivalent to omitting the field and keeps the +row a pass. P5-T2 is a further step that admits a non-zero outcome, on the branch where the P0-T6 +artifact recorded pre-existing format drift; that outcome is the formatter check's own unformatted-file +code rather than a test-failure code, so it is stated separately from the enumeration above and is +carried under the general rule in the preceding sentence. Raw run output that carries host tokens (a test results file carries `runUser` and +`computerName` attributes) is written to the git-ignored coverage directory and never committed; only +sanitized summary fields are copied into an evidence artifact. + +The fixed label 2026-09-06T22-00 in every artifact file name is a plan-wide naming label, not an +observation. Each artifact's `Timestamp:` field carries the actual ISO-8601 time at which that +artifact was written, read from the clock at write time, and will therefore differ from the label. + +### R8 — coverage thresholds and their authority + +The repository-wide line-coverage floor named by CLAUDE.md, which is rank 1 in the policy compliance +order, is 80 percent. Every coverage run in this plan is scoped to the two test assemblies this change +touches, which is a narrower denominator than the full-suite denominator that floor is written +against, so the two binding gates for this change are the no-regression comparison between the +same-scope Phase 0 baseline and the Phase 5 post-change figure, and the 90 percent changed-line +figure CLAUDE.md requires of new and changed code. The 80 percent floor is recorded against both +percentages in the Phase 5 delta artifact rather than asserted, and a baseline already below it under +this scope is recorded as a pre-existing condition this change neither creates nor resolves. The 85 +percent line and 75 percent branch figures in .claude/rules/general-unit-test.md are recorded in the +same artifact as observations and are not the gate. No coverage-exclusion attribute is introduced by +this change. + +### R9 — repo-wide Cobertura comparability + +A repository-wide Cobertura line rate is not stable across runs when the instrumented denominator +moves. Every coverage comparison in this plan therefore branches explicitly: when the post-change +`lines-valid` differs from the baseline `lines-valid` by at most 5 percent of the baseline value, the +document-level line rates are compared directly and the comparison is recorded as comparable; +otherwise the comparison is recorded as non-comparable, the covered and valid counters are reported +for both runs, and the binding gate becomes the changed-line coverage figure. Both branches are +recorded, so neither is left unmeasured. + +### R10 — changed-line coverage admits a non-executable outcome + +Cobertura emits a line element only for a line carrying IL. Blank lines, brace-only lines, `using` +directives, XML documentation comments and interface member declarations therefore appear in a +`git diff --unified=0` changed-line set and in no coverage map. The changed-line report marks such a +line `hits=non-executable` and the percentage is computed over executable changed lines only. A file +whose type carries a class-level coverage-exclusion attribute produces no class element at all; the +Phase 0 measurability determination records which Write Set production files are measurable, and a +file determined not measurable is reported as NOT APPLICABLE rather than as a zero. + +### R11 — no line in this document may begin with a left square bracket after a hyphen and a space, +except a task line + +The plan validator treats every line beginning with a hyphen, a space and a left square bracket as a +task line. Acceptance criteria, checklists and bullet lists in this document therefore use other +prefixes. + +--- + +## Test policy for this change + +MSTest with `[TestClass]` and `[TestMethod]`, Moq for mocking, FluentAssertions for assertions, +Arrange-Act-Assert structure, descriptive names. Creating temporary files in tests is prohibited, and +`Thread.Sleep`, `Task.Delay` and real wall-clock waits are banned. Tests must not require a live +Outlook process and must not trigger any user interface. Test files live in the mirroring test project +tree and never beside production source. + +The serializer already exposes five injectable protected seams that the new tests drive instead of +touching disk: the read-all-text seam, the disk-exists seam, the stream-writer seam, the dialog seam +and the timer factory. A deterministic manual-fire timer double already exists in the UtilitiesCS test +project's test-helpers directory and raises its elapsed event synchronously. The established harness +that exposes those seams is a private nested class inside the existing serializer test file and is +therefore not reachable from a new file, so the new serializer guard test file declares its own +equivalent harness and its own minimal test item type. No new production seam is introduced for AC1, +AC3, AC5, AC6, AC7 or AC8. + +AC2 is the one genuine gap: the serializer's logger is a private static log4net logger and is not +injectable. AC2 is asserted through an in-memory log4net appender, following the same shape the +TaskMaster test project's startup-timing and app-events helper files already use: activate the +appender, take a logger from the default repository hierarchy, set its level to Debug, mark the +repository configured, add the appender, and remove it again on teardown. The +attachment point differs for AC2 and only for AC2: the serializer's logger name is derived from the +declaring type reported by reflection over a member of a generic type, so no closed constructed type +name is a reliable attachment point and P1-T11 attaches to the root logger of that hierarchy instead, +selecting the events it asserts on by level and by a message fragment unique to that test file. The +AC5 loud-failure appender in P1-T13 attaches to a named logger in the ordinary way, because the +controller type is not generic. The UtilitiesCS test project already carries a direct log4net +reference, so the same helper shape compiles there. Every appender is detached in a finally block so +tests remain independent. The same finally block restores the logger's previous level and the +repository's previous configured flag, because attaching to the root logger and marking the repository +configured are process-wide mutations that would otherwise outlive the test and change the behaviour of +concurrently running classes in the same assembly. This adds no production surface to an already +over-cap shared file. + +--- + +### Phase 0 — Policy reads, toolchain bootstrap and baseline capture + +- [x] [P0-T1] Read, in the required order, CLAUDE.md, then .claude/rules/general-code-change.md, then .claude/rules/general-unit-test.md, then .claude/rules/quality-tiers.md, then .claude/rules/csharp.md, then .claude/rules/tonality.md, and record the read in the artifact named below. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-instructions-read.2026-09-06T22-00.md + Acceptance: the artifact exists and carries `Timestamp:`, `Policy Order:` and an explicit list naming all six files above, each with its line count as read. + +- [x] [P0-T2] Read the three requirements sources in this feature folder — spec.md, issue.md and research/research-folder-settings-persistence.md — and record their SHA-256 digests. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-requirements-read.2026-09-06T22-00.md + Acceptance: the artifact records one SHA-256 digest per source file and confirms that spec.md contains a `## Acceptance Criteria` section holding exactly eight criteria identified AC1 through AC8. + +- [x] [P0-T3] Record the base SHA into docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md as a single line whose first ten characters are `BASE-SHA: ` followed by the forty lowercase hexadecimal characters of the merge base, derived with the command below. + +```powershell +git merge-base HEAD origin/main +``` + + Acceptance: the artifact contains exactly one line matching the pattern `^BASE-SHA: [0-9a-f]{40}$`, and that value equals the merge base printed by the command. The artifact also carries `Timestamp:`, `Command:`, `EXIT_CODE:` and `Output Summary:`. + +- [x] [P0-T4] Author the fixed helper script at coverage/plan797-helpers.ps1 providing the functions the later tasks invoke: resolve vstest through vswhere, resolve msbuild through vswhere, run a scoped vstest invocation, run a coverage collection by building its own dotnet-coverage argument list as rule R2 describes, read the four Cobertura root counters, and compute changed-line coverage. The three backslash-bearing literals the helper needs are given below and must be authored verbatim into the helper file. + +```text +${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe +Common7\IDE\Extensions\TestPlatform\vstest.console.exe +MSBuild\**\Bin\MSBuild.exe +``` + + Acceptance: running the helper in its SelfCheck mode through pwsh with the NoProfile and File switches exits 0 and prints one line beginning `VSTEST-RESOLVED=` followed by an existing file path, one line beginning `MSBUILD-RESOLVED=` followed by an existing file path, and one line beginning `HELPER-FUNCTIONS=` listing at least the six function names. Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-helper-selfcheck.2026-09-06T22-00.md + +- [x] [P0-T5] Bootstrap this worktree in four steps, run with the working directory set to the repository root of this worktree, because a script that derives the repository root from the current directory would otherwise act on the session's root checkout. Step 1 installs the repository-local .NET SDK: the repository-root global.json pins SDK 8.0.205 and lists a repository-local .dotnet-sdk directory first in its paths list, and that directory does not exist here at the base commit. Step 2 restores the pinned tool manifest, the file named dotnet-tools.json at the repository root, which pins csharpier to 1.2.6 and nothing else. Step 3 confirms the global dotnet-coverage tool, which the repository coverage runner requires and which the local manifest does not carry. Step 4 restores the packages.config package set, because no packages directory exists here at the base commit. Commands, in the fenced text block below: pwsh with the NoProfile and File switches over scripts/vscode/Install-RepoDotNetSdk.ps1; then dotnet tool restore against the root manifest dotnet-tools.json; then dotnet-coverage with the version switch; then msbuild TaskMaster.sln with the Restore target, the parallel switch, the Debug configuration, the Any CPU platform and the RestorePackagesConfig property. + +```text +pwsh -NoProfile -File scripts/vscode/Install-RepoDotNetSdk.ps1 +dotnet tool restore --tool-manifest dotnet-tools.json +dotnet-coverage --version +msbuild TaskMaster.sln /t:Restore /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:RestorePackagesConfig=true +``` + + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-bootstrap.2026-09-06T22-00.md + Acceptance: all four commands exit 0; the artifact records four EXIT_CODE values and, in Output Summary, states for each of the repository-local SDK directory, the csharpier tool, the global dotnet-coverage tool and the packages directory whether it was already present or was created by this step, and records the SDK version the first command reports and the version the third command prints. The third command's exit-0 requirement may be satisfied only after the recorded recovery install described next, in which case the artifact records the initial non-zero exit, the recovery install and the exit-0 re-run, and the requirement is read against the re-run rather than against the first attempt. If the third command exits non-zero, the executor runs dotnet tool install with the global switch for dotnet-coverage once, records it as a fifth command with its own EXIT_CODE, and re-runs the version command. A non-zero exit on any other command halts the plan and is reported, not worked around. + +- [x] [P0-T6] Capture the csharpier formatting baseline for the whole tree by running the read-only check subcommand from the repository root of this worktree, recording the result into docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-csharpier-check.2026-09-06T22-00.md. + +```text +dotnet tool run csharpier check . +``` + + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-csharpier-check.2026-09-06T22-00.md + Acceptance: the artifact records `EXIT_CODE:` and, in `Output Summary:`, the exact count printed on the `Checked` summary line plus the complete list of any file paths the run reported as unformatted. If the exit code is 0 the artifact states `PRE-EXISTING-FORMAT-DRIFT: NONE`; otherwise it states `PRE-EXISTING-FORMAT-DRIFT:` followed by the enumerated paths. Phase 5 consumes this determination and neither branch is left unrecorded. + +- [x] [P0-T7] Capture the analyzer baseline by running the analyzer rebuild against TaskMaster.sln and writing the full log to coverage/plan797-baseline-analyzers.log. + +```text +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-baseline-analyzers.log" +``` + + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-analyzer-build.2026-09-06T22-00.md + Acceptance: the artifact records `EXIT_CODE:`, states whether the MSBuild summary line ` 0 Error(s)` is present, records the warning count from the summary, and enumerates under a heading `BASELINE-DIAGNOSTIC-IDS:` the distinct diagnostic identifiers reported as errors (empty when the build is clean). Both the clean and the non-clean branch are recorded. + +- [x] [P0-T8] Capture the nullable and warnings-as-errors baseline by running the type-check rebuild against TaskMaster.sln and writing the full log to coverage/plan797-baseline-nullable.log. + +```text +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-baseline-nullable.log" +``` + + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-nullable-build.2026-09-06T22-00.md + Acceptance: the artifact records `EXIT_CODE:`, states whether ` 0 Error(s)` is present, and enumerates under `BASELINE-DIAGNOSTIC-IDS:` the distinct diagnostic identifiers reported as errors. No solution-wide nullable enable property is added to this command; the artifact states that explicitly. + +- [x] [P0-T9] Capture the baseline test result for the two assemblies this change touches, UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll and TaskMaster.Test/bin/Debug/TaskMaster.Test.dll, using the helper at coverage/plan797-helpers.ps1 with the filter and isolation switch from rule R6, writing the results file under coverage/plan797-trx/baseline. The narrower controller-scope counts the acceptance requires are derived from this same results file by selecting the four named test classes, so no second run is performed. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-vstest.2026-09-06T22-00.md + Acceptance: the artifact records `EXIT_CODE:` and, in `Output Summary:`, the total, passed, failed and skipped counts read from the run, the exact filter expression used, and the sentence naming the four excluded shell-icon classes and stating that CI covers them. The results file itself is not committed; only these sanitized counts are recorded. The artifact additionally enumerates, under a heading `BASELINE-FAILING-TESTS:`, the fully qualified name of every test that failed in this run, and states `BASELINE-FAILING-TESTS: NONE` when the run is green. Phase 1 and Phase 5 subtract this set, so a pre-existing failure is neither reported as a fail-before signal nor demanded as a pass-after. The artifact additionally records, under a heading `BASELINE-CONTROLLER-SCOPE:`, obtained with the narrower filter P1-T3 uses — the one selecting the store wrapper controller, store wrapper controller tests, store wrapper and store wrapper viewer test classes — the total, passed and failed counts, the fully qualified name of every test that failed within that narrower scope, and the exact text of that filter expression. The skipped count is derived from the results file counters as the total minus the executed count, not from console text: a green run prints no `Skipped` line and the results file writes its not-executed counter as zero, so a console-derived figure would be unreadable on exactly the run this baseline expects. The artifact carries `ExpectedExitCode:` with the integer this run produced. + +- [x] [P0-T10] Capture the baseline coverage document at coverage/plan797-baseline/coverage.cobertura.xml using the helper at coverage/plan797-helpers.ps1, and record the numeric coverage values. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-coverage.2026-09-06T22-00.md + Acceptance: `EXIT_CODE: 0`, or a non-zero exit code recorded together with `ExpectedExitCode:` carrying that same integer when the collected run reproduced only failures enumerated under `BASELINE-FAILING-TESTS:` in the P0-T9 artifact, because the coverage collection propagates the inner test run's exit code; the artifact states which branch applies. In `Output Summary:` the artifact carries one single line with the four space-separated assignments `LINES_COVERED=`, `LINES_VALID=`, `BRANCHES_COVERED=` and `BRANCHES_VALID=` in that order, each immediately followed by a concrete integer, plus `BASELINE_LINE_PERCENT=` carrying the document-level line rate multiplied by 100 and rendered to two decimal places, plus `BASELINE_ASSEMBLY_SCOPE=` naming the two test assemblies the run covered, and states that both test assemblies were excluded from instrumentation by the derived coverage settings. No field carries the text UNVERIFIED or any placeholder. This step records values and asserts no threshold: the repository's own 80 percent assertion is written against a full-suite denominator and this run's denominator is narrower, so the comparison belongs in P5-T7. + +- [x] [P0-T11] Determine coverage measurability for each of the seven Write Set production C# files by searching the baseline document coverage/plan797-baseline/coverage.cobertura.xml for a class element whose filename attribute ends with a path separator followed by that file name. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-coverage-measurability.2026-09-06T22-00.md + Acceptance: the artifact lists each of the seven production paths with a verdict of MEASURABLE or NOT MEASURABLE and, for each MEASURABLE entry, the baseline covered and valid line counts. The trailing filename match is anchored on a path separator so that a file name cannot also select a differently named sibling that ends with the same characters. The two files that do not yet exist at this point are recorded as NOT YET CREATED. + +- [x] [P0-T12] Record the pre-change line count of every Write Set C# file, using a line count over each of the following paths: TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs, TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs, UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs, UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs, UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs, UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs, UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs, UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs, TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-file-sizes.2026-09-06T22-00.md + Acceptance: the artifact lists all nine paths with an integer line count each, and records under a heading `PRE-EXISTING-OVER-CAP:` the single entry for the serializer file, whose count is expected to be 613 and which D5 declares out of scope for splitting. The project files are not enumerated in this census, because the 500-line cap applies to production code, test code and reusable script files and not to project files. + +- [x] [P0-T13] Record the anchored change-set baseline for the Phase 5 scope gate by listing the tracked paths already differing from the base SHA on this branch, using the anchored diff and the porcelain companion below, into docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-scope-baseline.2026-09-06T22-00.md. + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md' -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git diff --name-status $BaseSha HEAD +git status --porcelain --untracked-files=all +``` + + Acceptance: the artifact records both listings under the headings `SCOPE-BASELINE-COMMITTED:` and `SCOPE-BASELINE-WORKTREE:`, states that the first listing covers work already committed on this branch and the second covers uncommitted work, that a path may appear in both, and that Phase 5 subtracts the union, and records `EXIT_CODE:` for the diff command. Phase 5 subtracts the union of these two sets, so both are captured here rather than inferred later. The artifact additionally records, under a heading `PREPARATION-TRACKED:`, the result of `git ls-files --error-unmatch` over the five preparation documents — the issue document, the specification, the research artifact, this plan file and the promoted feature entry — confirming each is tracked in HEAD. Any path reported as untracked is recorded and reported to the caller before Phase 1 begins, because P6-T11's terminal porcelain gate admits no residual other than this plan file and the agent-memory tree. + +--- + +### Phase 1 — Enabling seams and regression tests written first + +Phase 1 establishes the file layout and the declaration-only production seams needed so that the new +tests compile, then writes every regression test, then runs them and records a failing result. The +seams are declaration-only and defect-preserving: they add members and a file, and they change no +behaviour. Without them the new tests would fail to compile, which reddens the entire test assembly +and produces no attributable test result at all. + +Tests that are red before the Phase 2 to Phase 4 fixes: the AC1 loader-adoption test, both AC2 +error-log tests, the AC4 synchronous-flush test, the AC5 loud-failure test, the three AC6 fallback +cases that exercise a failing primary lookup and the two AC6 retry cases, the two AC7 trim cases whose +input carries a leading backslash pair together with the AC7 populate test, and the three AC8 cases +including the inverted D6 test. Tests that are additive coverage and green from the moment they are +written: the AC1 key-absent negative case, the AC4 deferred-path-unchanged case, the AC5 +argument-order case, the AC6 fallback case in which the primary SMTP address is present, and the four +AC7 trim cases whose input carries no leading backslash pair — no leading backslash, a single leading +backslash, the empty string and null — because the P1-T8 placeholder returns its argument unchanged +and each of those four expects its argument unchanged. The `[expect-fail]` tag is carried by the run +task, which is the task with a binary observable outcome. + +- [x] [P1-T1] Create UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs as a pure move: relocate `PopulateWithCurrent`, `BindExcludeStoreCheckbox` and `GetRelativeFsPath` verbatim out of UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs, and add the `partial` keyword to the class declaration in both files. The new file's first line is the nullable enable pragma, matching the first line of the file the members are moved out of. Without it the moved annotations lose their nullable context, the compiler reports CS8632 on each of them, and the Phase 5 type-check gate, which treats warnings as errors, fails. No behavioural edit in this task. + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md' -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git add --intent-to-add UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs +git diff $BaseSha -- UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs +``` + + Acceptance: the three member bodies are byte-identical to their pre-move text apart from indentation; the class declaration in each file reads `public partial class StoreWrapperController`; and the anchored diff above, which compares the base commit to the working tree and therefore reports uncommitted work, shows in the controller file only deletions of the moved members plus the single declaration-line change, and in the new file only additions. + +- [x] [P1-T2] Register the new file by adding one compile entry for OutlookObjects\Store\StoreWrapperController.Display.cs to UtilitiesCS/UtilitiesCS.csproj, beside the existing entry for the controller. + Acceptance: the project file contains exactly one compile entry naming that file, and the anchored, staged listing below reports the project file as modified. + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md' -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git add --intent-to-add UtilitiesCS/UtilitiesCS.csproj UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs +git diff --name-status $BaseSha -- UtilitiesCS/UtilitiesCS.csproj UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs +``` + +- [x] [P1-T3] Prove the relocation is behaviour-preserving by building the solution and running the existing controller and store test classes through the helper at coverage/plan797-helpers.ps1, before any new test exists. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t3-pure-move-green.2026-09-06T22-00.md + Acceptance: the artifact carries an `ExpectedExitCode:` equal to the exit code this run actually produced; that exit code is 0, or 1 with every failing test a member of the failing set recorded under `BASELINE-CONTROLLER-SCOPE:` in the P0-T9 artifact. An exit code of 1 carrying any failing test outside that set is a gate failure and is attributable to the relocation. The artifact records a passed count at or above the passed count recorded under `BASELINE-CONTROLLER-SCOPE:`, obtained with the identical filter expression recorded there. The filter selects `StoreWrapperController_Tests`, `StoreWrapperControllerTests`, `StoreWrapperTests` and `StoreWrapperViewerTests`, with the `TestCategory!=LiveOutlook` clause repeated on every disjunct. + +- [x] [P1-T4] Create UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs declaring a public interface in namespace `UtilitiesCS` with the single member `void ApplyJunkFolderSelections(string junkCertainRelativePath, string junkPotentialRelativePath);`, with XML documentation naming the parameter order and stating that the certain path is first. + Acceptance: the file exists, declares exactly one interface and exactly one member with that signature, takes no dependency on the TaskMaster project, and the declaration is syntactically well formed; compilation of every Phase 1 seam is proven once, in P1-T9. + +- [x] [P1-T5] Register the new interface by adding one compile entry for Interfaces\IGlobals\IJunkFolderSelectionSink.cs to UtilitiesCS/UtilitiesCS.csproj, beside the existing entries for the store disable and store rehook service interfaces. + Acceptance: the project file contains exactly one compile entry naming that file, inside an ItemGroup that already carries the compile entries for the store disable and store rehook service interfaces. Compilation is proven once, in P1-T9. + +- [x] [P1-T6] Add the declaration-only, defect-preserving guarded flush entry point to UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs: a new public method named `SerializeNow` whose body in this task calls the existing deferred `Serialize()` and nothing else, with an in-code comment stating that Phase 2 replaces the body with the guarded synchronous flush. + Acceptance: the method exists with signature `public void SerializeNow()`, the existing `Serialize()`, `Serialize(string)`, `SerializeThreadSafe(string)` and `RequestSerialization(string)` members are unchanged, and the declaration is syntactically well formed; compilation of every Phase 1 seam is proven once, in P1-T9. + +- [x] [P1-T7] Add the declaration-only, defect-preserving retry entry point and failure-reason property to UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs: an `internal string? LastSmtpLookupError { get; private set; }` carrying a JsonIgnore attribute, and an `internal string? RefreshUserEmailAddress()` whose body in this task assigns `UserEmailAddress = GetSmtpAddressFromStore();` and returns it, with an in-code comment stating that Phase 3 adds the fallback chain and the captured reason. + Acceptance: both members exist with those signatures, `GetSmtpAddressFromStore` is otherwise unchanged, and the declaration is syntactically well formed; compilation of every Phase 1 seam is proven once, in P1-T9. + +- [x] [P1-T8] Add the declaration-only, defect-preserving trim helper to UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs: `internal static string? TrimStorePrefix(string? folderPath)` whose body in this task returns its argument unchanged, with an in-code comment stating that Phase 4 supplies the real trim. + Acceptance: the method exists with that exact signature and accessibility, no call site yet references it, and the declaration is syntactically well formed; compilation of every Phase 1 seam is proven once, in P1-T9. + +- [x] [P1-T9] Build the solution with the analyzer rebuild to confirm every Phase 1 seam compiles, writing the log to coverage/plan797-p1-analyzers.log. + +```text +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-p1-analyzers.log" +``` + + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t9-seam-build.2026-09-06T22-00.md + Acceptance: `EXIT_CODE: 0` and the summary line ` 0 Error(s)` is present. If the Phase 0 analyzer baseline was not clean, the acceptance is instead that the recorded diagnostic identifier set is a subset of `BASELINE-DIAGNOSTIC-IDS:` from the Phase 0 artifact and contains no diagnostic attributed to a Write Set file. + +- [x] [P1-T10] Append the AC1 tests to TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs, driving the existing `TestableAppOlObjects` harness. Positive case: a loader whose `Config.Disk.FilePath` is a fixed fake AppData path is added to the configuration dictionary under the key `StoresWrapper`, the deserialize stub returns null, and after `LoadStoresAsync()` the fresh wrapper's `Config.Disk.FilePath` equals the loader's path. Negative case: the configuration key is absent, the fresh build occurs, and the path remains the empty string. No filesystem access in either case. + Acceptance: two new `[TestMethod]` members exist with descriptive names, the file compiles, and the positive case fails at this point in the plan because the fresh-build branch does not yet adopt the loader configuration. + +- [x] [P1-T11] Create UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs holding the AC2 and AC4 tests, with its own harness subclass exposing the stream-writer and timer-factory seams and its own minimal test item type, because the existing harness is a private nested class in another file. AC2: with an in-memory log4net appender attached to the serializer's declaring type, calling `Serialize()` with an empty `Config.Disk.FilePath` and, in a separate test, with a null `Config.Disk.FilePath`, each produces at least one error-level event whose rendered message names this file's own test item type, and arms no timer. AC4: with a manual-fire timer double injected and a memory-stream-backed writer, the explicit-save entry point writes without the timer firing, and a separate test asserts the pre-existing deferred path still requires a timer fire. The appender is detached in a finally block. The serializer initialises its logger from the declaring type reported by MethodBase.GetCurrentMethod, which for a member of a generic type resolves to the generic type definition rather than to any closed constructed type, so one logger serves every instantiation and its name carries no type argument; an appender attached to the full name of a closed constructed serializer type is a different logger and captures nothing. To be correct under either resolution, the tests attach the memory appender to the root logger of the default log4net repository, set that logger's level to Debug, mark the repository configured, and select captured events by an error level together with a rendered message naming this file's own test item type; the appender is removed from the root logger in a finally block that also restores the root logger's previous level and the repository's previous configured flag. The minimal test item type this file declares carries a name occurring nowhere else in the UtilitiesCS test project, so a concurrently running class cannot contribute a matching event to the existence assertion. The assertion is existence, not an exact count, because the run settings this plan uses impose a class-level parallel scope on every assembly and concurrent classes can only add events. The MSTest attribute opting the class out of parallel execution is still applied, but it is not the isolation mechanism: the unique message fragment and the paired behavioural assertions, that no write reached the stream-writer seam and no timer was armed, are. + Acceptance: the file exists in the mirroring test directory, declares four or more `[TestMethod]` members covering the four scenarios named above, creates no temporary file, uses no `Thread.Sleep` or `Task.Delay`, and compiles. + +- [x] [P1-T12] Register the new serializer test file by adding one compile entry for ReusableTypeClasses\SmartSerializableSerializeGuardTests.cs to UtilitiesCS.Test/UtilitiesCS.Test.csproj, beside the existing serializer test entries. + Acceptance: the project file contains exactly one compile entry naming that file, beside the existing serializer test entries. Whether the entry pulls the file into the assembly is proven by the NEW-TEST-FILES-DISCOVERED: enumeration required of P1-T18. + +- [x] [P1-T13] Add the AC5 tests to UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs. Retarget the existing `RecordingOlObjects` double to implement the new sink interface in addition to its stub base, and add a new double implementing only the globals interface while still declaring a public method named `ApplyJunkFolderSelections` with the same two string parameters. Add an argument-order test asserting the certain path arrives first and the potential path second, and a loud-failure test asserting that with the non-sink double the controller records at least one error-level event whose rendered message names the new sink interface, through an in-memory appender attached to the controller's declaring type, and does not invoke the method. The assertion is existence, not an exact count: the controller's logger is a static field shared with every other controller test class in this assembly, the run settings this plan uses impose a class-level parallel scope, and the opt-out attribute on one class does not exclude writers in sibling classes. That attribute is still applied, and the paired assertion that the non-sink double records no invocation is what attributes the event to this test. + Acceptance: both new `[TestMethod]` members exist, the existing test named `PersistJunkFolderSelections_WhenApplyMethodIsMissing_DoesNotThrow` is retargeted to the typed seam rather than deleted, the two existing tests named `SaveChanges_PersistsBothSettingsAndRefreshesActiveJunkFolders` and `ButtonCancel_Click_LeavesStoredSettingsAndActiveFoldersUnchanged` still compile, and the loud-failure test fails at this point because the reflection lookup still succeeds against the non-sink double. + +- [x] [P1-T14] Add the AC6 fallback-ordering tests to UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs, table-driven over the mocked Outlook folder chain already built by the private helper `CreateRootFolderWithPrimarySmtpAddress` in that file. Four cases: the primary SMTP address is present; the primary SMTP read throws and the address entry address contains an at-sign; both fail and the display name contains an at-sign; all fail, producing a null result and a non-empty captured failure reason. + Acceptance: four new `[TestMethod]` members exist, none requires a live Outlook process, and cases two, three and four fail at this point because the single outer catch converts every failure to null with no fallback. + +- [x] [P1-T15] Create UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs as a new partial of the existing `StoreWrapperController_Tests` class, holding the AC6 retry tests, the AC7 trim tests and the AC8 guard tests. AC6 retry: the retry runs on a populate when the address is null and does not run when the address is already populated, and the user-email label carries the specific unavailability message containing the captured reason. AC7: six pure-function cases over the trim helper covering a leading double backslash, no leading backslash, a single leading backslash, the empty string, null, and a path consisting only of the double backslash; plus one populate test asserting the rendered Inbox and Root Folder label text. AC8: a null current store renders the existing placeholder literals and does not throw, and the relative-path helper returns its placeholder rather than throwing. + Acceptance: the file declares `public partial class StoreWrapperController_Tests` in the same namespace so the existing private helpers are reachable, holds nine or more `[TestMethod]` members covering the scenarios above, and compiles. + +- [x] [P1-T16] Register the new controller display test file by adding one compile entry for OutlookObjects\Store\StoreWrapperController_Tests.Display.cs to UtilitiesCS.Test/UtilitiesCS.Test.csproj, beside the existing controller test partial entries. + Acceptance: the project file contains exactly one compile entry naming that file, beside the existing controller test partial entries. Whether the entry pulls the file into the assembly is proven by the NEW-TEST-FILES-DISCOVERED: enumeration required of P1-T18. + +- [x] [P1-T17] Invert the deliberate D6 test expectation in UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs: the test named `PopulateWithCurrent_NullCurrent_SetsErrorLoadingText` currently asserts that the act throws a `NullReferenceException`; replace that assertion with one asserting that the act does not throw and that the archive and junk labels carry their existing placeholder literals. Replace the two misleading in-body comments with a comment naming this as the declared AC8 expectation inversion under D6. + Acceptance: the test name is unchanged, the assertion no longer references `NullReferenceException`, the new assertion pins specific rendered values rather than an exception type, and the test fails at this point because the four unguarded dereferences still throw. + +- [x] [P1-T18] [expect-fail] Build the solution and run the scoped regression set through the helper at coverage/plan797-helpers.ps1 against UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll and TaskMaster.Test/bin/Debug/TaskMaster.Test.dll, writing the results file under coverage/plan797-trx/p1. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p1-t18-fail-before.2026-09-06T22-00.md + Acceptance: the artifact carries `ExpectedExitCode: 1` and an `EXIT_CODE:` equal to 1, and its `Output Summary:` enumerates one `FAIL-BEFORE:` line per failing test whose fully qualified name is absent from the `BASELINE-FAILING-TESTS:` set in the P0-T9 artifact, enumerates separately under `PRE-EXISTING-FAILURES:` every failing test whose name is present in that set, and records the passed and failed counts. The `FAIL-BEFORE:` enumeration must contain at least one failing test attributable to each of AC1, AC2, AC4, AC5, AC6, AC7 and AC8. The `Output Summary:` also names, under `NEW-TEST-FILES-DISCOVERED:`, at least one test method from each of the two new test files registered in P1-T12 and P1-T16, so a missing compile entry is caught here rather than silently dropping a file. The build itself must succeed: a compile error is not an acceptable fail-before signal and halts the plan. + +- [x] [P1-T19] Author the AC3 fail-before exception dossier at docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/fail-before-exception.2026-09-06T22-00.md. + Acceptance: the dossier carries `Timestamp:`, a `WhyFailingRunImpossible:` statement of one to three sentences explaining that persistence across an Outlook restart requires a live VSTO host and cannot be reproduced by any automated test in this environment, an alternative-proof section citing the runtime log evidence that the settings file has never been created on the reporting machine, and the negative-evidence fields `SearchScope:`, `SearchPatterns:` and `SearchResult:` recording that no automated fail-before run exists for AC3. + +--- + +### Phase 2 — Root cause 1: the bootstrap gap, the serializer guard and the flush (AC1, AC2, AC4) + +- [x] [P2-T1] Implement AC1 in TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs alone: in `LoadStoresAsync`, on the branch where the configuration key was found and the deserialize returned null, apply the already-in-scope loader configuration to the freshly built wrapper by calling the configuration copy with a deep copy, after the fresh build assignment. The branch where the configuration key is not found must remain a fresh build with an empty path. + Acceptance: the shared deserialize overload in UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs is unchanged, UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializableBase.cs is unchanged, and the AC1 positive test added in P1-T10 passes while the AC1 negative test continues to pass. + +- [x] [P2-T2] Implement AC2 in UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs: change the guard in `Serialize()` from a comparison against the empty string to a null-or-empty check, and log at error level, naming the serialized item type reported by typeof over the type parameter and the empty or null path, when the guard rejects. No timer is armed on the rejecting path. + Acceptance: the two AC2 tests added in P1-T11 pass, and the existing test callers of the serializer that the research enumerated across the serializer, non-typed serializer, linked-list and stack test files continue to pass unchanged. + +- [x] [P2-T3] Implement AC4 in UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs: replace the placeholder body of `SerializeNow` with the guarded synchronous flush. The AC2 empty-or-null-path error must be evaluated first, so that the fix does not substitute one silent failure for another; only when the path is non-empty and non-null does the method call the existing thread-safe write method directly, which takes the write lock, writes through the injectable stream-writer seam and re-arms the single-shot guard in its finally block. + Acceptance: the AC4 synchronous-flush test passes, the AC4 deferred-path-unchanged test still passes, `Serialize()` and `RequestSerialization(string)` retain their existing three-second single-shot deferred behaviour with the first caller's path captured, and the serializer file is not split. + +- [x] [P2-T4] Wire the AC4 flush at the explicit Save path in UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs by replacing the deferred serialize call at the end of `SaveChanges` with the guarded synchronous flush entry point. The VSTO add-in lifecycle file is not modified. + Acceptance: `SaveChanges` calls the flush entry point exactly once, the existing tests named `SaveChanges_SetsCurrentProperties` and `SaveChanges_PersistsBothSettingsAndRefreshesActiveJunkFolders` pass unchanged, and neither raises because the guard rejects an empty path with an error log rather than an exception. + +- [x] [P2-T5] Run the root-cause-1 automated criteria through the helper at coverage/plan797-helpers.ps1, scoped to the AC1, AC2 and AC4 test names, writing the results file under coverage/plan797-trx/p2. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p2-t5-root-cause-1-green.2026-09-06T22-00.md + Acceptance: `EXIT_CODE: 0`, a failed count of 0, and one `PASS-AFTER:` line per test name that appeared as a `FAIL-BEFORE:` entry for AC1, AC2 or AC4 in the P1-T18 artifact. The run's own totals are recorded separately as non-asserted observations. + +- [x] [P2-T6] [expect-fail] Run the full scoped suite over UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll and TaskMaster.Test/bin/Debug/TaskMaster.Test.dll to confirm that no test outside the AC1, AC2 and AC4 set regressed, writing the results file under coverage/plan797-trx/p2-full. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p2-t6-no-regression.2026-09-06T22-00.md + Acceptance: the artifact carries `ExpectedExitCode: 1` and an `EXIT_CODE:` equal to 1, because the AC5, AC6, AC7 and AC8 tests are still red at this point by design, and its `Output Summary:` states that the set of failing tests, after subtracting every name recorded under `PRE-EXISTING-FAILURES:` in the P1-T18 artifact, is a proper subset of the P1-T18 `FAIL-BEFORE:` set and contains no test outside it, and lists the subtracted names separately under `PRE-EXISTING-FAILURES:`. + +--- + +### Phase 3 — Root cause 2: the SMTP lookup fallback and retry (AC6) + +- [x] [P3-T1] Implement the AC6 fallback chain inside `GetSmtpAddressFromStore` in UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs, replacing the single outer catch with per-step handling in the order the specification fixes: the Exchange primary SMTP address; then the address entry's address when it contains an at-sign; then the store display name when it contains an at-sign; then null. Each step carries its own handling for a COM failure, mirroring the existing in-repo helper in the application globals that already implements exactly this ordering. + Acceptance: the four AC6 fallback tests added in P1-T14 pass, and the two pre-existing tests in that file named `GetSmtpAddressFromStore_WhenExchangeUserIsUnavailable_ReturnsNull` and `GetSmtpAddressFromStore_WhenExchangeLookupThrowsComException_ReturnsNull` are re-derived against the new ordering: each supplies neither an at-sign-bearing address entry address nor an at-sign-bearing display name, so each still returns null and must pass unchanged. If either would now return a non-null value, the test arrangement is corrected in this task and the correction is recorded as a declared expectation change with its reason. + +- [x] [P3-T2] Implement the AC6 captured reason and the retry entry point in UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs: the failure-reason property added in P1-T7 records the caught exception's message when every fallback step fails and is cleared when a lookup succeeds; the retry entry point re-runs the chain and assigns the result, and is safe to call when the root folder is null. + Acceptance: the AC6 test asserting a non-empty captured reason on total failure passes, and calling the retry entry point on a store wrapper with a null root folder returns null without throwing. + +- [x] [P3-T3] Implement the AC6 retry and the specific unavailability message in UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs: inside `PopulateWithCurrent`, when the current store's user email address is null, invoke the retry entry point at most once per dialog open, then render either the address or a specific unavailability message that includes the captured reason, replacing the generic placeholder for the user-email label only. The Inbox and Root Folder placeholders are unchanged in this task. + Acceptance: the two AC6 retry tests pass, the retry does not run when the address is already populated, the message rendered on total failure contains the captured reason, and the existing test named `PopulateWithCurrent_ShowsCurrentJunkSelectionsInViewer`, which constructs the controller with a null globals reference, still passes because every new dereference on this path is null-conditional. + +- [x] [P3-T4] Run the root-cause-2 automated criterion through the helper at coverage/plan797-helpers.ps1, scoped to the AC6 test names, writing the results file under coverage/plan797-trx/p3. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p3-t4-root-cause-2-green.2026-09-06T22-00.md + Acceptance: `EXIT_CODE: 0`, a failed count of 0, and one `PASS-AFTER:` line per test name that appeared as a `FAIL-BEFORE:` entry for AC6 in the P1-T18 artifact. + +- [x] [P3-T5] Re-run the root-cause-1 scoped set through the helper at coverage/plan797-helpers.ps1 to confirm the two root causes remain separately traceable and that the Phase 3 edits did not disturb Phase 2, writing the results file under coverage/plan797-trx/p3-rc1. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p3-t5-root-cause-1-still-green.2026-09-06T22-00.md + Acceptance: `EXIT_CODE: 0` and a failed count of 0 over exactly the AC1, AC2 and AC4 test set recorded in the P2-T5 artifact. + +--- + +### Phase 4 — Remaining folded defects (AC5, AC7, AC8) and the readability correction + +- [x] [P4-T1] Implement the AC5 typed sink in TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs: declare the partial class as implementing the new interface, and add an explicit interface implementation that forwards to the existing internal method. Explicit implementation is required so the public surface of the globals type does not widen; the existing internal method keeps its accessibility and its body. + Acceptance: the file declares the interface on the partial, the explicit implementation forwards both arguments in the certain-then-potential order, the existing internal method is otherwise unchanged, and the project reference direction remains one-way from TaskMaster to UtilitiesCS. + +- [x] [P4-T2] Replace the reflection lookup with the typed cast in UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs: `PersistJunkFolderSelections` casts the globals Outlook objects reference to the new interface and calls the member directly; when the cast fails it logs at error level, not warning, and returns. The rendered message must name the new sink interface, because the P1-T13 loud-failure test selects the event by that name fragment on a logger shared with every other controller test class. Remove the `using System.Reflection;` directive from that file if no other member in it uses reflection. + Acceptance: the AC5 loud-failure test and the AC5 argument-order test both pass, the retargeted test named `PersistJunkFolderSelections_WhenApplyMethodIsMissing_DoesNotThrow` passes, the file contains no `GetMethod` call. Analyzer cleanliness after the Phase 4 edits is proven in P5-T3. + +- [x] [P4-T3] Implement AC7 in UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs: replace the placeholder body of the trim helper with a pure trim that removes a leading pair of backslash characters and returns every other input unchanged, including null and the empty string; then apply it to the Inbox and Root Folder label assignments in `PopulateWithCurrent`. + Acceptance: the six AC7 pure-function cases pass, the AC7 populate test asserts the rendered label text with no leading backslash pair, the helper performs no allocation-free assumption about a null input, and the shared archive stem contract type is not modified. + +- [x] [P4-T4] Implement the AC8 guards at the top of `PopulateWithCurrent` in UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs: make the four dereferences of the current store null-conditional so that they match the null-conditional form the very next block already uses, and confirm the existing placeholder literals render for the Inbox, Root Folder, the two archive fields and the two junk fields. + Acceptance: the inverted D6 test passes, the AC8 null-current test in the new display partial passes, and the six placeholder literals are unchanged apart from the user-email literal that AC6 replaced in P3-T3. + +- [x] [P4-T5] Implement the AC8 guard in `GetRelativeFsPath` in UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs so that a null current store returns the existing archive placeholder rather than throwing. + Acceptance: the AC8 relative-path test passes, and the three pre-existing tests in UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs named `GetRelativeFsPath_ArchiveFsWithEmptyPath_ReturnsPlaceholder`, `GetRelativeFsPath_ArchiveFsWithPath_ConverterReturnsEmpty_ReturnsPlaceholder` and `GetRelativeFsPath_ArchiveFsWithPath_ConverterReturnsValues_ReturnsFormatted`, plus the test in UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.cs named `GetRelativeFsPath_NullArchiveFsRoot_ReturnsPlaceholder`, all pass unchanged. + +- [x] [P4-T6] Apply the readability correction in UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs: change the single-ampersand operator in the relative-path condition to the short-circuit form. This is behaviourally inert, because both operands call a null-tolerant string extension and neither has a side effect; the change description must not claim it repairs a fault. + Acceptance: the four relative-path tests named in P4-T5 still pass, and the only operator changed is the one in the live condition. The commented-out block relocated with `PopulateWithCurrent` also contains a single-ampersand occurrence; it is dead commented text and is left untouched, so an occurrence count over the file is not used as the gate. + +- [x] [P4-T7] Run the remaining automated criteria through the helper at coverage/plan797-helpers.ps1, scoped to the AC5, AC7 and AC8 test names, writing the results file under coverage/plan797-trx/p4. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/regression-testing/p4-t7-remaining-criteria-green.2026-09-06T22-00.md + Acceptance: `EXIT_CODE: 0`, a failed count of 0, and one `PASS-AFTER:` line per test name that appeared as a `FAIL-BEFORE:` entry for AC5, AC7 or AC8 in the P1-T18 artifact. + +- [x] [P4-T8] Record the pre-format line count of every Write Set C# file and the two created files into docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p4-t8-file-sizes-preformat.2026-09-06T22-00.md, using the same nine paths as P0-T12 plus UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs, UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs, UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs and UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs. + Acceptance: every listed C# path is at or below 500 lines, except the serializer file, whose pre-existing overage D5 declares out of scope and which is reported with both its Phase 0 count and its current count. This is a pre-format census; the binding audit is P5-T8, after the formatter runs. + +--- + +### Phase 5 — Final QA loop with coverage comparison + +The loop below runs in order. If any step fails against its declared expectation or changes files, the +loop restarts at P5-T1. A step whose artifact declares a non-zero `ExpectedExitCode:` and whose +observed exit code matches that declaration has not failed and does not restart the loop. The plan is +not complete until P5-T1 through P5-T6 complete in a single uninterrupted pass. + +- [x] [P5-T1] Run the formatter over the tree from the repository root of this worktree, capturing the C#-scoped worktree state into coverage/plan797-format-before.txt immediately before and into coverage/plan797-format-after.txt immediately after, so the write-mode run is observable beyond its exit code. + +```powershell +git status --porcelain --untracked-files=all -- '*.cs' | Out-File -Encoding utf8 coverage/plan797-format-before.txt +dotnet tool run csharpier format . +git status --porcelain --untracked-files=all -- '*.cs' | Out-File -Encoding utf8 coverage/plan797-format-after.txt +``` + + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t1-csharpier-format.2026-09-06T22-00.md + Acceptance: `EXIT_CODE: 0`; the artifact records the summary count the formatter printed, and the set difference between the after and before listings. If the Phase 0 artifact recorded `PRE-EXISTING-FORMAT-DRIFT: NONE`, every path in that difference must be a Write Set path. If it recorded pre-existing drift, the difference may additionally contain exactly the paths that artifact enumerated, and those paths are reverted in this task so the change does not carry unrelated reformatting. + +- [x] [P5-T2] Verify formatting read-only from the repository root of this worktree so that a real signal, rather than a write-mode exit code, decides the gate, recording the result into docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t2-csharpier-check.2026-09-06T22-00.md. + +```text +dotnet tool run csharpier check . +``` + + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t2-csharpier-check.2026-09-06T22-00.md + Acceptance: `EXIT_CODE: 0` and the `Output Summary:` records the count printed on the `Checked` summary line and states that the run reported no unformatted file; or, when the P0-T6 artifact recorded pre-existing drift and P5-T1 reverted those paths, a non-zero `EXIT_CODE:` whose reported unformatted paths are a subset of the `PRE-EXISTING-FORMAT-DRIFT:` set enumerated in the P0-T6 artifact and contain no Write Set path. In that branch the artifact carries `ExpectedExitCode:` with the observed integer, records the drift as a pre-existing condition this change neither creates nor resolves, and the Phase 5 loop does not restart on it. + +- [x] [P5-T3] Run the analyzer rebuild against TaskMaster.sln, writing the log to coverage/plan797-final-analyzers.log. + +```text +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-final-analyzers.log" +``` + + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t3-analyzer-build.2026-09-06T22-00.md + Acceptance: `EXIT_CODE: 0` and the summary line ` 0 Error(s)` is present. If the Phase 0 analyzer baseline was not clean, the acceptance is instead that the recorded diagnostic identifier set is a subset of `BASELINE-DIAGNOSTIC-IDS:` from the Phase 0 artifact and that no diagnostic in it is attributed to a Write Set file. The warning count is recorded and compared to the Phase 0 warning count. + +- [x] [P5-T4] Run the type-check rebuild against TaskMaster.sln, writing the log to coverage/plan797-final-nullable.log. + +```text +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true "/flp:Verbosity=detailed;LogFile=coverage/plan797-final-nullable.log" +``` + + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t4-nullable-build.2026-09-06T22-00.md + Acceptance: `EXIT_CODE: 0` and ` 0 Error(s)` is present; or, when the Phase 0 nullable baseline was not clean, the recorded diagnostic identifier set is a subset of the Phase 0 set and contains no diagnostic attributed to a Write Set file. The artifact states explicitly that no solution-wide nullable enable property was supplied. + +- [x] [P5-T5] Run the full scoped test suite over UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll and TaskMaster.Test/bin/Debug/TaskMaster.Test.dll through the helper at coverage/plan797-helpers.ps1, writing the results file under coverage/plan797-trx/p5. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t5-vstest.2026-09-06T22-00.md + Acceptance: the artifact carries an `ExpectedExitCode:` equal to the exit code this run actually produced, and that exit code is either 0, or 1 with every failing test a member of the `BASELINE-FAILING-TESTS:` set in the P0-T9 artifact and no failing test residing in any Write Set test file. An exit code of 1 when the P0-T9 artifact recorded `BASELINE-FAILING-TESTS: NONE` is a gate failure. When the exit code is 0 the failed count is 0, and when the P0-T9 artifact recorded failing names the artifact states which of them did not reproduce. The `Output Summary:` records total, passed, failed and skipped counts, the exact filter expression used, and the sentence naming the four excluded shell-icon classes and stating that CI covers them. As in P0-T9, the skipped count is derived from the results file counters as the total minus the executed count, not from console text, because a green run prints no `Skipped` line and the results file writes its not-executed counter as zero. It additionally records one `PASS-AFTER:` line for every fully qualified test name that appeared as a `FAIL-BEFORE:` entry in the P1-T18 artifact, so the fail-before to pass-after correspondence is complete rather than sampled; names carried under `PRE-EXISTING-FAILURES:` there are excluded from that correspondence and are listed separately. A failing test that is a member of `BASELINE-FAILING-TESTS:` and also resides in a Write Set test file does not restart the Phase 5 loop. It is recorded under `PRE-EXISTING-IN-WRITE-SET:` with the reason it was not repaired, and the plan outcome is remediation-required rather than complete. + +- [x] [P5-T6] Collect post-change coverage into coverage/plan797-final/coverage.cobertura.xml through the helper at coverage/plan797-helpers.ps1 and record the numeric values. + Artifact: docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t6-coverage.2026-09-06T22-00.md + Acceptance: `EXIT_CODE: 0`; or a non-zero exit code carried as `ExpectedExitCode:` with that same integer when the collected run reproduced only failures already recorded under `BASELINE-FAILING-TESTS:` in the P0-T9 artifact, in which case the artifact names those tests and states that the Cobertura document was produced despite them. The coverage collection propagates the exit code of the inner test run, so a reproduced pre-existing failure does not fail this step and does not restart the Phase 5 loop. The `Output Summary:` carries one single line with the four space-separated assignments `LINES_COVERED=`, `LINES_VALID=`, `BRANCHES_COVERED=` and `BRANCHES_VALID=` in that order, each immediately followed by a concrete integer, plus `POSTCHANGE_LINE_PERCENT=` carrying the document-level line rate multiplied by 100 to two decimal places, plus `POSTCHANGE_ASSEMBLY_SCOPE=` naming the same two test assemblies P0-T10 recorded, and states that both test assemblies were excluded from instrumentation by the derived coverage settings. No field carries the text UNVERIFIED or any placeholder. The binding comparison is made in P5-T7 against the same-scope Phase 0 baseline, not here. + +- [x] [P5-T7] Produce the coverage delta report at docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t7-coverage-delta.2026-09-06T22-00.md, comparing the Phase 0 baseline document coverage/plan797-baseline/coverage.cobertura.xml with the Phase 5 document coverage/plan797-final/coverage.cobertura.xml, and computing changed-line coverage over the seven Write Set production C# files from the anchored diff below. + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md' -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git add --intent-to-add UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs +git diff --unified=0 $BaseSha -- TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs +``` + + Acceptance: the artifact reports three figures explicitly — `BASELINE_LINE_PERCENT=`, `POSTCHANGE_LINE_PERCENT=` and `CHANGED_LINE_PERCENT=` — each a concrete number, none carrying the text UNVERIFIED. It records the comparability branch chosen under rule R9 and the two `lines-valid` values that decided it. It records the changed-line table per file with a `hits` value per changed executable line and a `hits=non-executable` marker for each changed line that emits no IL, computes the percentage over executable changed lines only, and reports NOT APPLICABLE for any file the Phase 0 measurability artifact recorded as not measurable. The AC5 explicit interface implementation lands in TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs, whose partial class carries no class-level coverage-exclusion attribute, so that file is measurable and produces a real per-file changed-line row; no automated test in this plan drives the real settings-writing implementation that member forwards to, so that row is expected to sit at or near zero. The Phase 1 relocation moves PopulateWithCurrent, BindExcludeStoreCheckbox and GetRelativeFsPath verbatim into the display partial, so the anchored diff reports every line of those three members as added although none of them changed. Those lines are enumerated in the artifact under `RELOCATED-UNMODIFIED:` with their post-change hit counts recorded as observations, and they are excluded from the `CHANGED_LINE_PERCENT=` denominator. Lines inside those members that the Phase 3 and Phase 4 edits altered are not relocated lines and remain in the denominator. Without this exclusion the denominator would carry pre-existing partially covered code that this change does not modify. The gate is the aggregate `CHANGED_LINE_PERCENT=` figure computed over the executable changed lines of every measurable file in the table, and a low per-file row on that one file is expected and is not itself a failure. `CHANGED_LINE_PERCENT=` must be at or above 90, and, when rule R9 selected the comparable branch, `POSTCHANGE_LINE_PERCENT=` must not be below `BASELINE_LINE_PERCENT=`, which is then the no-regression rule and the binding repository-wide gate for this change. When rule R9 selected the non-comparable branch, the artifact records both percentages and both covered-and-valid counter pairs as observations, states that the denominator moved by more than 5 percent, and the sole binding gate is `CHANGED_LINE_PERCENT=`. The artifact records whether each of the two percentages is at or above the 80 percent floor in CLAUDE.md, which is rank 1 in the policy compliance order, and when the baseline is already below that floor it states plainly that the condition is pre-existing under the two-assembly scope, that this change neither creates nor resolves it, and that the binding gates are therefore the no-regression comparison and the changed-line percentage. The artifact additionally records, as non-asserted observations, the 85 percent line and 75 percent branch figures from .claude/rules/general-unit-test.md. + +- [x] [P5-T8] Perform the binding post-format file-size audit over the thirteen C# paths enumerated in P4-T8, writing docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t8-file-sizes-postformat.2026-09-06T22-00.md. + Acceptance: every listed C# path is at or below 500 lines except UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs, which is reported with its Phase 0 count of 613 and its post-change count, and is declared a pre-existing condition this change does not resolve under D5. In particular UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs and UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs are each at or below 500. No project file is enumerated in this audit, because the cap does not reach project files. + +- [x] [P5-T9] Produce the scope gate at docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t9-scope.2026-09-06T22-00.md, listing every source path the change touches and confirming it is a subset of the Write Set. + +```powershell +$BaseSha = (Select-String -Path 'docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/baseline/phase0-base-sha.2026-09-06T22-00.md' -Pattern '^BASE-SHA: ([0-9a-f]{40})$').Matches[0].Groups[1].Value +git add --all -- '*.cs' '*.csproj' +git diff --name-status $BaseSha -- '*.cs' '*.csproj' +git status --porcelain --untracked-files=all -- '*.cs' '*.csproj' +``` + + Acceptance: the artifact records the diff listing and the porcelain listing under separate headings, states that the porcelain listing is taken after the staging command and therefore overlaps the diff listing rather than complementing it, and records the union of the two listings as the working set, subtracts the union of the two Phase 0 scope-baseline sets, and confirms that every remaining path is a member of the Write Set. The test is a subset test, not an equality test, so a claimed-but-unmodified path such as TaskMaster.Test/TaskMaster.Test.csproj does not fail the gate. The artifact separately confirms zero paths under the dot-claude, dot-codex or dot-agents trees, zero paths under the config directory, zero GitHub workflow files, and no repository-root file. The pathspec restricts the gate to source and project files, because the change also writes feature-folder documents and evidence artifacts by design; that reading and its reason are stated in the artifact. + +- [x] [P5-T10] Record the clean-pass confirmation at docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p5-t10-clean-pass.2026-09-06T22-00.md, naming the commands run in P5-T1 through P5-T6 and stating that they completed in a single uninterrupted pass with no step failing against its declared expectation and no step changing files. + Acceptance: the artifact names all six commands verbatim, records each `EXIT_CODE:`, states the number of loop restarts performed and the reason for each, and confirms that the final pass required no restart. + +--- + +### Phase 6 — Manual verification, acceptance check-off and handoff + +- [x] [P6-T1] Perform the AC3 manual verification and record it at docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/other/p6-t1-ac3-manual-verification.2026-09-06T22-00.md. The written procedure is: (1) confirm the settings file StoresWrapper.json does not exist under the local AppData TaskMaster directory, recording the check without recording the absolute path of the user profile; (2) build and load the add-in and start Outlook; (3) open Settings, then Folder Settings, and record the rendered Archive Root Outlook, Archive Root File System, Junk Potential, Junk Email, User Email, Inbox and Root Folder values; (4) select an Archive Root Outlook value and click Save; (5) confirm the settings file now exists; (6) close Outlook fully and reopen it; (7) reopen Folder Settings and confirm the saved value is present; (8) confirm the session log contains no serializer error and no line reporting an empty or null settings path; (9) check the junk-folder rollout consideration recorded as risk 4 in spec.md by confirming whether the junk selections shown agree with the .NET user settings. + Acceptance: the artifact carries `Timestamp:`, the nine numbered steps with an observed result for each, an explicit `AC3-RESULT:` line reading PASS or FAIL, and a statement that this criterion is verified manually because it requires a live Outlook process. No absolute host path, user account name or machine name appears in the artifact. + +- [x] [P6-T2] Check off AC1 in the `## Acceptance Criteria` section of docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md and mirror the same check-off in the Proposed Fix / Validation Ideas section, at heading level two, of docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md. + Acceptance: exactly the AC1 checkbox is marked in both files, the criterion text is byte-identical to its pre-change text in both files, and no other checkbox changes state in this task. + +- [x] [P6-T3] Check off AC2 in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md and mirror it in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md. + Acceptance: exactly the AC2 checkbox is marked in both files, its text is unchanged, and no other checkbox changes state in this task. + +- [ ] [P6-T4] Check off AC3 in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md and mirror it in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md, only if the P6-T1 artifact records `AC3-RESULT: PASS`. + Acceptance: exactly the AC3 checkbox is marked in both files when and only when the P6-T1 artifact records PASS; when it records FAIL, this task leaves the checkbox unmarked and the plan outcome is remediation-required rather than complete. Both branches are recorded in the P6-T11 summary. + +- [x] [P6-T5] Check off AC4 in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md and mirror it in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md. + Acceptance: exactly the AC4 checkbox is marked in both files, its text is unchanged, and no other checkbox changes state in this task. + +- [x] [P6-T6] Check off AC5 in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md and mirror it in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md. + Acceptance: exactly the AC5 checkbox is marked in both files, its text is unchanged, and no other checkbox changes state in this task. + +- [x] [P6-T7] Check off AC6 in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md and mirror it in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md. + Acceptance: exactly the AC6 checkbox is marked in both files, its text is unchanged, and no other checkbox changes state in this task. + +- [x] [P6-T8] Check off AC7 in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md and mirror it in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md. + Acceptance: exactly the AC7 checkbox is marked in both files, its text is unchanged, and no other checkbox changes state in this task. + +- [x] [P6-T9] Check off AC8 in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md and mirror it in docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md. + Acceptance: exactly the AC8 checkbox is marked in both files, its text is unchanged, and no other checkbox changes state in this task. + +- [x] [P6-T10] Mirror the issue update at docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/issue-updates/issue-797.2026-09-06T22-00.md. + Acceptance: the artifact carries `Timestamp:`, the exact text intended for the issue, and a `PostedAs:` field with the value body, comment or unknown; when not posted it carries a POSTING BLOCKED header and the reason. When `PostedAs: body`, the same update is mirrored into the feature folder issue.md. + +- [x] [P6-T11] Produce the acceptance status summary at docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/evidence/qa-gates/p6-t11-ac-status.2026-09-06T22-00.md, then delete the session helper described in rule R2, then commit every source change, every evidence artifact, and the acceptance-criteria check-off edits to the specification and the issue document, so the worktree is clean. The feature folder documents and the promoted feature entry were committed to this branch during preparation, before execution began, so they are already tracked and are not new files here. + Acceptance: the summary lists AC1 through AC8 with a status of PASS or NOT MET, names for each the implementing task, the test name or manual procedure, and the evidence artifact path; it restates the final coverage figures from the P5-T7 artifact including the changed-line percentage; it names the four excluded shell-icon test classes and states that CI covers them; and it records the two declared expectation changes, namely the D6 inversion and any P3-T1 arrangement correction. After the commit, `git status --porcelain --untracked-files=all` reports at most two residual classes: this plan file, whose final task check-off is written after the commit, and any file under the repository's agent-memory tree, tracked or untracked. The artifact records the verbatim porcelain output and identifies which residual class each line belongs to. Any other path present is a gate failure. + +--- + +## Known limitations recorded rather than resolved + +1. AC3 cannot be gated automatically. The automated tests establish that the disk path is populated + and that the write occurs through the injectable seam, but they do not prove the file appears on + disk in a live VSTO host. +2. The serializer file remains over the 500-line cap after this change. D5 records this as + pre-existing and deliberately not resolved here; no task in this plan splits it. +3. The AC6 retry reintroduces a synchronous Outlook COM property read on the UI thread at dialog-open + time. The risk is bounded to one lookup per dialog open, attempted only when the address is null. + A genuinely non-blocking read is out of scope and is recorded in spec.md as a follow-up. +4. The QuickFiler recipient-resolution blocking hazard is a different caller of the same Outlook + getter and is not fixed under this issue. +5. The junk-folder rollout divergence described as risk 4 in spec.md is a known, accepted consequence + of the chosen AC5 reading and is checked during the P6-T1 manual verification. +6. Four shell-icon test classes are excluded from every local run for environmental reasons unrelated + to this change. CI covers them. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/policy-audit.2026-09-07T22-40.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/policy-audit.2026-09-07T22-40.md new file mode 100644 index 000000000..284ac83e0 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/policy-audit.2026-09-07T22-40.md @@ -0,0 +1,390 @@ +# Policy Compliance Audit — Issue #797 (Folder Settings never persist; User Email "Error Loading") + +- Component: TaskMaster VSTO add-in — store settings dialog, Outlook store wrapper, and the shared `SmartSerializable` serializer +- Date: 2026-09-07 +- Timestamp label: 2026-09-07T22-40 +- Work Mode: `full-bug` (marker read from `issue.md` line 12) +- Authoritative acceptance-criteria source: `spec.md` in this feature folder (per the acceptance-criteria-tracking skill, `full-bug` resolves to `spec.md` only). `user-story.md` is correctly absent; its absence is not a finding. +- Base branch / base commit: `origin/main` at `c431dc3297e864041d829e8d79b348960b8d8019` (recorded in `spec.md` line 6 and in `evidence/baseline/phase0-base-sha.2026-09-06T22-00.md`) +- Branch: `bug/folder-settings-never-persist-797` +- Reviewer inputs: the pre-generated branch patch `artifacts/797-source-review.patch` (82274 bytes, `git diff origin/main HEAD` restricted to `*.cs` and `*.csproj`), the feature folder documents, the committed evidence tree, and the two session Cobertura documents read directly from disk. + +## Template provenance + +The `policy-audit-template-usage` skill names the MCP tool `mcp__drm-copilot__resolve_policy_audit_template_asset` as the required template source. No MCP tool is exposed in this agent session, so the asset could not be resolved. This artifact is hand-authored preserving all twelve canonical major headings the skill enumerates, with the template instruction block omitted as the skill requires. The companion validator `mcp__drm-copilot__validate_orchestration_artifacts` is likewise unavailable and was not run; that is recorded rather than claimed as passed. + +--- + +## Rejected Scope Narrowing + +The caller supplied the branch diff in a form restricted by file type. The relevant caller text, verbatim: + +> THE DIFF, PRE-GENERATED FOR YOU: +> C:/Users/DanMoisan/repos/TaskMaster/.claude/worktrees/agent-a46162b1321fb4a50/artifacts/797-source-review.patch +> That file is `git diff origin/main HEAD` restricted to `*.cs` and `*.csproj`. It is 82274 bytes and is the complete and authoritative source footprint of this change against `main`. + +and + +> Do not run git. Do not run gh. Do not attempt to compute a diff yourself. + +Justification for recording: restricting the supplied diff to two file extensions is a subset-of-changed-files restriction, which the scope invariant requires be recorded rather than silently accepted. The audit scope remains the full branch diff against the resolved base branch. + +Disposition. The narrowing was not accepted as a scope limit, but the accompanying prohibition on running `git` removes the means by which this reviewer would independently enumerate changes outside the `*.cs` / `*.csproj` pathspec. The residual was therefore bounded by three independent, non-git means rather than left open: + +1. The executor's own scope gate at `evidence/qa-gates/p5-t9-scope.2026-09-06T22-00.md` enumerates fifteen `.cs` / `.csproj` paths from an anchored diff **and** a porcelain listing taken with `--untracked-files=all`. That listing is an exact set match with the fifteen file diffs present in the supplied patch, confirming the patch is neither truncated nor filtered beyond its stated pathspec. +2. That same gate separately confirms zero working-set paths under the `.claude`, `.codex`, `.agents` and `config` trees, zero GitHub workflow files, zero repository-root files, and zero files with a `resx`, `config`, `props` or `targets` extension. +3. The terminal porcelain listing at `evidence/qa-gates/p6-t11-ac-status.2026-09-06T22-00.md` lines 92-98 shows only five `.claude/agent-memory` Markdown residuals, all of which predate execution and none of which was committed by this change. + +The only files this change writes outside the supplied pathspec are therefore Markdown documents inside this feature folder: `spec.md` and `issue.md` (acceptance-criteria check-off only), the plan file (task check-off), and the timestamp-named evidence artifacts. None is a coverage language and none is a policy document under `.claude/rules/` or `.github/instructions/`. + +Residual limitation, stated plainly: the base-branch resolution and the head commit identity were taken from the caller and from the executor's Phase 0 artifact and could not be recomputed here, because the directive prohibits running `git`. This is the single scope fact in this audit that rests on a supplied value rather than on direct measurement. + +--- + +## Evidence Location Compliance + +The evidence-location invariant requires agent-produced evidence to live under `/evidence//`. + +- Files in the branch diff written under `artifacts/baselines/`, `artifacts/qa/`, `artifacts/evidence/` or `artifacts/coverage/`: **zero**. Verified by inspecting every one of the fifteen file paths in `artifacts/797-source-review.patch`; none is under `artifacts/`. +- All twenty-eight committed evidence artifacts for this work item are under `/evidence/` in the canonical kind subdirectories `baseline` (13), `regression-testing` (8), `qa-gates` (11), `issue-updates` (1) and `other` (1). Enumerated by directory listing. +- `spec.md` lines 585-591 states the same requirement and the delivery matches it. +- `validate_evidence_locations.py --root .` was not run, because the directive prohibits invoking the shell in this worktree. The scan was performed by direct path inspection of the diff and of the evidence tree instead, and found no violation. This substitution is recorded, not concealed. + +Observation, not a violation. The raw Cobertura documents that back the coverage figures live at `coverage/plan797-baseline/coverage.cobertura.xml` and `coverage/plan797-final/coverage.cobertura.xml`. That directory is git-ignored, so those documents are not committed and a later reviewer cannot re-verify the figures without rerunning the measurement. This reviewer read both documents directly during this audit and independently confirmed every headline figure (see section 5). Two additional points weigh in favour of leaving them uncommitted: they carry absolute host paths including an account name in every `filename` attribute, and committing them would leave large blobs reachable in history. + +--- + +## Executive Summary + +The change is compliant. Every mandatory gate in the policy compliance order was executed in the required order, produced exit code 0 on a single clean final pass, and is evidenced. Seven of the eight acceptance criteria are delivered and verified by named automated tests; the eighth (AC3) requires a live Outlook restart, was honestly recorded as blocked with all nine procedure steps marked NOT PERFORMED, and its checkbox was correctly left unmarked in both requirement files. + +The verdict is **PASS** with **zero blocking findings**. Six advisory findings are recorded in the companion code review; none of them alters an acceptance-criteria verdict and none requires remediation before merge. No remediation-inputs artifact is produced. + +Two conditions are pre-existing rather than introduced and are reported as such: the shared serializer remains over the 500-line file cap (613 lines at baseline, 658 after this change), and the two-assembly-scoped repository line coverage sits well below both the 80 percent and the 85 percent documented floors, at 53.23 percent before this change and 53.26 percent after it. + +| Section | Verdict | +|---|---| +| 1. General Unit Test Policy | PASS | +| 2. General Code Change Policy | PASS with one recorded pre-existing exception (file size) | +| 3. C# Code Change Policy | PASS | +| 4. C# Unit Test Policy | PASS | +| 5. Test Coverage Detail | PASS | +| 6. Test Execution Metrics | PASS | +| 7. Code Quality Checks | PASS | +| 10. Compliance Verdict | **PASS — 0 blocking findings** | + +--- + +## 1. General Unit Test Policy Compliance + +Reference: `.claude/rules/general-unit-test.md`. + +| Requirement | Verdict | Evidence | +|---|---|---| +| Independence — tests run in any order | PASS | Both new test classes and both modified controller test classes carry `[DoNotParallelize]` (`SmartSerializableSerializeGuardTests.cs` line 815 of the patch; `StoreWrapperControllerTests.cs` line 23; `StoreWrapperController_Tests.cs` line 14; `StoreWrapperTests.cs` line 14). Every log4net appender attachment is undone in a `finally` block through the `restore` delegate returned by `AttachRootMemoryAppender` and `AttachControllerMemoryAppender`, which also restores the previous logger level and the repository `Configured` flag. | +| Isolation — one unit per test | PASS | Each of the 25 new tests exercises a single member. The six `TrimStorePrefix_*` cases are pure-function cases over one static helper; the four `GetSmtpAddressFromStore_*` cases each pin one step of the fallback order. | +| Fast execution | PASS | 5262 tests total in the final scoped run; no test in the diff performs I/O, sleeps, or waits on a clock. The deferred timer is driven by `ManualFireTimerWrapper.FireElapsed()`, not by elapsed wall time. | +| Determinism | PASS | No `Thread.Sleep`, `Task.Delay`, `DateTime.Now`, or real wall-clock wait appears anywhere in the patch. Verified by pattern search over `artifacts/797-source-review.patch`: zero matches for `Thread\.Sleep|Task\.Delay|DateTime\.Now`. | +| Readability and maintainability | PASS | Every new test carries an Arrange comment naming the issue and the criterion it serves, and every non-trivial FluentAssertions call supplies a `because` reason string. | +| **No temporary files in tests** | PASS | Zero matches for `Path.GetTempPath` or `GetTempFileName` in the patch. Both new serializer tests write through the injected `CreateStreamWriter` seam into a `MemoryStream` (`SmartSerializableSerializeGuardTests.cs`, `harness.SetCreateStreamWriter(...)`), and the two fake paths `X:\FakeAppData\TaskMaster\StoresWrapper.json` and `X:\FakeAppData\TaskMaster\GuardProbe.json` are never opened. | +| No external dependencies; mocks used at boundaries | PASS | Every Outlook COM object is a `Mock` over the interop interface. No live Outlook process, no network, no database, no filesystem. | +| Scenario completeness (positive, negative, edge, error) | PASS | AC1 has a positive and a key-absent negative case. AC2 has both the empty-string and the null path. AC4 has the explicit-save case and the deferred-path pin. AC6 has four ordered fallback cases plus a null-root-folder safety case. AC7 has six boundary cases including null, empty and prefix-only. AC8 has both the populate path and the helper path. | +| Coverage tooling excludes test files | PASS | The Cobertura packages are the production assemblies; no test assembly appears as a measured package. | +| **Coverage Exclusion Policy — no production file excluded from measurement** | PASS | Zero occurrences of `ExcludeFromCodeCoverage` in the entire patch, verified by pattern search. The two new production files are both present in the post-change Cobertura document: `StoreWrapperController.Display.cs` appears as a measured `class` element with `line-rate="1"`, and `IJunkFolderSelectionSink.cs` legitimately emits no `class` element because an interface declaration produces no IL. No `exclude` entry matching a production source path was introduced. | +| Test file location mirrors production structure | PASS | `UtilitiesCS.Test/OutlookObjects/Store/` mirrors `UtilitiesCS/OutlookObjects/Store/`; `TaskMaster.Test/AppGlobals/` mirrors `TaskMaster/AppGlobals/`. No test file was placed in a production source tree. | +| Determinism infrastructure — no banned APIs | PASS | The deferred write is advanced by a manual-fire timer double injected through the existing `TimerFactory` seam, which is the repository's established fake-timer facility for this class. | + +Minor observation, not a finding. `UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs` sits one directory level shallower than a strict mirror of `UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs` would place it. It matches the established local convention for this class's sibling test files — `SmartSerializableLoader_Tests.cs`, `SmartSerializableNonTyped_Tests.cs` and `SmartSerializableStatic_Tests.cs` are all in the same directory — so the General Code Change Policy's "match the existing style" rule is the governing one and is satisfied. + +--- + +## 2. General Code Change Policy Compliance + +Reference: `.claude/rules/general-code-change.md` and the CLAUDE.md embedded copy. + +| Requirement | Verdict | Evidence | +|---|---|---| +| Simplicity first | PASS | Each of the four root-cause fixes is the smallest expression of its remedy: one `CopyFrom` call for AC1, one shared guard method for AC2, one new public entry point for AC4, one interface plus one cast for AC5. | +| Reusability — no copy-paste | PASS | `TryGetSerializationPath` is factored once and consumed by both `Serialize()` and `SerializeNow()`, so the two entry points cannot diverge in their diagnostic. `TrimStorePrefix` is factored once and consumed by both label assignments. | +| Extensibility — public APIs extended, not broken | PASS | `SerializeNow()` is additive. `Serialize()` keeps its signature and its deferred behaviour for every existing caller, pinned by `Serialize_WithConfiguredPath_StillRequiresTimerFireToWrite`. `IJunkFolderSelectionSink` is a new interface, deliberately not added to `IOlObjects`, so no existing implementer or test stub is forced to change. | +| Separation of concerns | PASS | `TrimStorePrefix` is a pure static helper with no dependency on controller state. `BuildUserEmailUnavailableText` reads one property and formats a string. The COM-bound lookup stays in `StoreWrapper`; the rendering stays in the controller display partial. | +| Fail fast and explicitly; no silent error swallowing | PASS | This is the substance of the change. Two silent paths were removed: the serializer's empty-path early return now logs at error level, and the controller's reflection miss, previously a `logger.Warn`, is now a `logger.Error`. | +| Logging uses the project pattern | PASS | Every new diagnostic goes through the existing static log4net `logger` field on the owning type. No `Console.WriteLine` or ad-hoc output was introduced. | +| Comment **why**, not what | PASS | Every substantive edit carries a `// why: issue #797 AC.` comment naming the defect mechanism. Examples: `AppOlObjects.StoreLoading.cs` lines 70-75, `SmartSerializable.cs` lines 487-494, `StoreWrapperController.Display.cs` lines 41-47. | +| Cohesive modules; small public surface | PASS | The AC5 seam is implemented **explicitly** (`void IJunkFolderSelectionSink.ApplyJunkFolderSelections(...)` at `AppOlObjects.JunkFolders.cs` line 54), so the public surface of `AppOlObjects` does not widen and the existing internal method keeps its accessibility. `TrimStorePrefix` is `internal`, not `public`. | +| No new external dependency | PASS | The patch adds no package reference. The `log4net` usings added to two test files resolve against a reference the test project already carries. | +| I/O isolated; domain logic testable without disk or network | PASS | All five new or changed production members are reachable through pre-existing injectable seams; no new production seam was required. | +| Treat existing tests as part of the spec | PASS | One expectation was changed, and it was declared in advance. See "Declared expectation change" below. | +| **500-line file cap** | PASS with one recorded pre-existing exception | Twelve of the thirteen C# Write Set paths are at or below 500 lines. See the file-size table below. | +| No policy document modified | PASS | Zero paths under `.claude/rules/` or `.github/instructions/` in the working set, confirmed by the P5-T9 scope gate. | +| No secrets or `.env` files created | PASS | No such file in the working set. | + +### File size + +Source: `evidence/qa-gates/p5-t8-file-sizes-postformat.2026-09-06T22-00.md`, taken after the formatter ran. The four newly created files' line counts are independently corroborated by the patch's own hunk headers (`@@ -0,0 +1,N @@`), which agree exactly: 29, 173, 252 and 352. + +| Path | Lines | Within cap | +|---|---|---| +| `TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs` | 90 | yes | +| `TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs` | 198 | yes | +| `UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs` | 658 | **no — pre-existing** | +| `UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs` | 302 | yes | +| `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs` | 388 | yes | +| `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs` | 173 | yes | +| `UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs` | 29 | yes | +| `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs` | 402 | yes | +| `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs` | 361 | yes | +| `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs` | 252 | yes | +| `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs` | 416 | yes | +| `UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs` | 352 | yes | +| `TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs` | 429 | yes | + +Test files are counted against the cap on the same footing as production files, per the policy text; all seven test paths are within it. + +**The serializer exception.** `SmartSerializable.cs` was 613 lines at the base commit and is 658 lines after the change: a delta of **+45 lines**, a 7.3 percent increase on an already over-cap file, leaving it 158 lines over the 500-line limit. The delta is independently corroborated by the patch's two hunk headers for that file (`@@ -439,11 +439,35 @@` is +24 and `@@ -453,6 +477,27 @@` is +21, summing to +45, which reconciles 613 to 658 exactly). The overage is pre-existing and is declared out of this work item by design decision D5 (`spec.md` lines 411-418), by Non-Goals item 1 (lines 190-197) and by risk 5 (lines 643-645), on the stated ground that splitting a shared reusable-type-classes file during a parallel run would create merge contention with concurrently running sibling work items. This is recorded as a pre-existing exception, not a new violation class. One wording note is carried into the code review: `spec.md` line 195 describes the addition as "a small number of lines", which understates a +45 delta. + +**Declared expectation change.** `PopulateWithCurrent_NullCurrent_SetsErrorLoadingText` in `StoreWrapperController_Tests.ButtonAndPopulate.cs` previously asserted `act.Should().Throw()`. It now asserts `act.Should().NotThrow()` plus four specific rendered label values. This was declared in advance as design decision D6 (`spec.md` lines 420-432) and is required by AC8. The reviewer confirms it is a strengthening, not a weakening: the original pinned only an exception type, while the replacement pins four exact strings. The test name always described the fixed behaviour, so the change also removes a pre-existing contradiction between the name and the assertion. + +--- + +## 3. Language-Specific Code Change Policy Compliance (C#) + +Reference: the C# Code Change Policy embedded in CLAUDE.md. + +| Requirement | Verdict | Evidence | +|---|---|---| +| Formatting via `dotnet tool run csharpier format .`, verified with `check` | PASS | `evidence/qa-gates/p5-t10-clean-pass.2026-09-06T22-00.md` steps 1 and 2, both exit 0. The check subcommand exited 0 over 1605 files. Invoked through `dotnet tool run`, so the manifest-pinned version was used. | +| `dotnet format` not used | PASS | Absent from every recorded command. | +| No hand-formatting against the formatter | PASS | The final pass rewrote zero Write Set files, proven by SHA-256 hashing all thirteen Write Set C# files immediately before and after the formatter run and comparing: `FORMAT-REWRITTEN-COUNT=0`. | +| Analyzer build with `/t:Rebuild`, `EnableNETAnalyzers` and `EnforceCodeStyleInBuild` | PASS | `p5-t10` step 3, exit 0. The command uses `/t:Rebuild`, not `/t:Build`, so `CoreCompile` was not skipped and the gate was genuinely capable of failing. | +| Nullable build with `/t:Rebuild` and `TreatWarningsAsErrors=true` | PASS | `p5-t10` step 4, exit 0. Character-for-character the CI command shape. | +| `/p:Nullable=enable` deliberately **not** added | PASS | Absent from the recorded command, as CLAUDE.md requires. | +| Per-file nullable opt-in honoured | PASS | The new production partial `StoreWrapperController.Display.cs` opens with `#nullable enable` on line 1, matching its sibling `StoreWrapperController.cs` line 1, so the relocated and new members remain inside the same nullable analysis context they were in before the split. `StoreWrapper.cs` continues to use `string?` annotations. | +| Toolchain run in the exact order, restarting on any change | PASS | One restart was performed and its cause recorded: the first `csharpier format` run rewrote files, which the loop rule requires be treated as a restart trigger. The restarted pass completed all six steps with exit code 0 and no file changes. | +| Strong contracts and explicit APIs | PASS | `IJunkFolderSelectionSink` documents the parameter order as part of the contract in XML comments, and a test pins it. | +| Null-safety by default | PASS | The AC8 fix converts four unguarded dereferences to the null-conditional form, matching the form already used in the adjacent block of the same method. | +| Prefer fixing diagnostics over suppressing them | PASS | Exactly one suppression is introduced, `#pragma warning disable CS0067` around the `PropertyChanged` event of the test-only probe type. It is narrowly scoped to a single member, carries an in-code rationale, and is unavoidable because `ISmartSerializable` derives from `INotifyPropertyChanged` and the probe never raises the event. | +| XML documentation on non-obvious public APIs | PASS | `IJunkFolderSelectionSink`, `SerializeNow`, `TryGetSerializationPath`, `RefreshUserEmailAddress`, `LastSmtpLookupError`, `TrimStorePrefix`, `BuildUserEmailUnavailableText` and both new partial class declarations all carry `` blocks. | +| Non-SDK-style compile entries added for new files | PASS | Four new files, four hand-added `` entries: two in `UtilitiesCS.csproj` (lines 1731 and 1739 of the patch) and two in `UtilitiesCS.Test.csproj` (lines 1144 and 1152). The risk that a missing entry silently omits a test file was mitigated as `spec.md` risk 3 directs — all 25 new test names appear in the run output, and the test total rose from 5237 to 5262. | +| `TaskMaster.Test.csproj` claimed but unmodified | PASS | The Write Set claim is conservative and every scope gate is written as a subset test, not an equality test (`spec.md` lines 264-269, `p5-t9` lines 82-86). An unmodified claimed file is not a finding. | + +--- + +## 4. Language-Specific Unit Test Policy Compliance (C#) + +Reference: the C# Unit Test Policy embedded in CLAUDE.md. + +| Requirement | Verdict | Evidence | +|---|---|---| +| MSTest is the framework | PASS | Every new test carries `[TestMethod]`; the two new test types carry `[TestClass]` or extend an existing `[TestClass]` partial. `Microsoft.VisualStudio.TestTools.UnitTesting` is the only test framework namespace imported. No xUnit or NUnit reference appears. | +| Moq for mocking | PASS | `Mock`, `Mock`, `Mock`, `Mock`, `Mock`, `Mock`, `Mock` and `Mock` are all used. | +| FluentAssertions for assertions | PASS | Every assertion in the 25 new tests uses `.Should()`. No bare MSTest `Assert` call was introduced. | +| MSTest attributes from the correct namespace | PASS | Confirmed by the using directives in both new test files. | +| Test with coverage via `vstest.console.exe /EnableCodeCoverage` | PASS | `p5-t10` steps 5 and 6, both exit 0, invoking vstest over two explicitly named assemblies rather than by directory discovery, with the `/InIsolation` switch CI uses. | +| No test can trigger UX or a live Outlook worker | PASS | Every Outlook object is a Moq double. `MyBox.ShowDialog` is not reachable from any new test: the AC5 tests drive `PersistJunkFolderSelections` on the UtilitiesCS side against doubles and never enter `AppOlObjects.LoadJunkPotential` or `LoadJunkCertain`, which are the only members in the touched files that raise a dialog. The one live-host criterion, AC3, was not automated. | + +--- + +## 5. Test Coverage Detail + +### Coverage authority applied + +Plan rule R8 (`plan.2026-09-06T22-00.md` lines 356-368) governs and resolves a genuine conflict between two policy documents that this reviewer confirms is real and unreconciled in the repository: CLAUDE.md, rank 1 in the policy compliance order, names an 80 percent repository-wide line floor and a 90 percent floor for new code, while `.claude/rules/general-unit-test.md` and `.claude/rules/quality-tiers.md` name 85 percent line and 75 percent branch uniformly across tiers. + +Every coverage run for this change is scoped to two test assemblies, `UtilitiesCS.Test` and `TaskMaster.Test`, which is a narrower denominator than the full-suite denominator either floor is written against. Under R8 the two binding gates are (a) no regression between the same-scope baseline and the post-change figure, and (b) the 90 percent changed-line requirement CLAUDE.md sets for new and changed code. The 80 and 85 percent absolute figures are recorded as observations against a non-comparable denominator and are not asserted as gates here. + +### Independent verification performed by this reviewer + +The executor's figures were not taken on trust. Both Cobertura documents were read directly and the root `coverage` element attributes transcribed: + +- Baseline `coverage.cobertura.xml`: `lines-covered="44426" lines-valid="83466" line-rate="0.5322646347015552" branches-covered="10877" branches-valid="24323" branch-rate="0.44718990256136165"`. +- Post-change `coverage.cobertura.xml`: `lines-covered="44489" lines-valid="83537" line-rate="0.5325664076995822" branches-covered="10928" branches-valid="24371" branch-rate="0.4484017890115301"`. + +Every figure in `evidence/qa-gates/p5-t7-coverage-delta.2026-09-06T22-00.md` reconciles exactly with these values. The internal arithmetic also reconciles: 44426/83466 = 53.2265 percent; 44489/83537 = 53.2566 percent; the `lines-valid` delta of 71 is 0.085 percent of the baseline against an R9 tolerance of 4173; and 92/101 = 91.089 percent, with 101 + 38 relocated = 139 and 92 + 38 = 130 matching the pre-exclusion aggregate. + +Additionally, this reviewer located each new or changed production member in the post-change Cobertura document and read its own `line-rate` and `branch-rate` directly, which is stronger evidence for the new-code floor than a file-level aggregate: + +| Member | Line rate | Branch rate | Criterion | +|---|---|---|---| +| `SmartSerializable.TryGetSerializationPath` | 1.00 | 1.00 | AC2 | +| `SmartSerializable.SerializeNow` | 1.00 | 1.00 | AC4 | +| `StoreWrapper.RefreshUserEmailAddress` | 1.00 | 1.00 | AC6 | +| `StoreWrapper.GetSmtpAddressFromStore` | 0.8710 | 0.9444 | AC6 | +| `StoreWrapperController.BuildUserEmailUnavailableText` | 1.00 | 1.00 | AC6 | +| `StoreWrapperController.TrimStorePrefix` | 1.00 | 1.00 | AC7 | +| `StoreWrapperController` class in `StoreWrapperController.Display.cs` | 1.00 | 0.9324 | AC6, AC7, AC8 | + +Every one of the seven clears both the 85 percent line figure and the 75 percent branch figure at member level, and six of the seven are at 100 percent line coverage. The single member below 100 percent, `GetSmtpAddressFromStore`, is at 87.10 percent line and 94.44 percent branch, both comfortably above every documented floor. + +### Coverage verdicts by language + +Languages with changed files in the branch diff: C# only. The fifteen changed paths are ten `.cs` files, two `.csproj` files and three feature-folder Markdown documents. + +- **C# coverage: PASS.** No-regression gate met (post-change 53.26 percent is not below the baseline 53.23 percent, on a comparable denominator under rule R9) and the changed-line gate met (91.09 percent over 101 executable changed lines, against CLAUDE.md's 90 percent requirement for new and changed code). Verified directly by this reviewer against both Cobertura documents and against per-member rates, as tabulated above. The canonical path `artifacts/csharp/coverage.xml` is not populated in this worktree; the substituting artifacts are the two session Cobertura documents named above, which were read in full during this audit, together with the committed delta report at `evidence/qa-gates/p5-t7-coverage-delta.2026-09-06T22-00.md`. +- **PowerShell coverage: PASS.** Zero changed `.ps1` files in the branch diff, so no Pester coverage obligation arises. The one PowerShell file used during execution, `coverage/plan797-helpers.ps1`, was created and deleted within the agent session, lived in a git-ignored directory throughout, and was never committed, which is exactly the throwaway-script exemption the General Code Change Policy grants. +- **Python coverage: PASS.** Zero changed `.py` files in the branch diff, so no Python coverage obligation arises. +- **TypeScript coverage: PASS.** Zero changed `.ts` files in the branch diff, so no TypeScript coverage obligation arises. + +### Repository-wide floors, recorded as observations + +Both the baseline and the post-change figures sit below CLAUDE.md's 80 percent repository-wide line floor and below the 85 percent line and 75 percent branch figures in `.claude/rules/general-unit-test.md`. Under the two-assembly scope this plan measures, the post-change document reads 53.26 percent line and 44.84 percent branch. This condition is pre-existing: the baseline was already at 53.23 percent line and 44.72 percent branch before any change here. This change neither created nor resolved it, and it moved both figures marginally upward. It is recorded as an observation against a non-comparable denominator, not raised as a finding. + +One further observation. The `UtilitiesCS` package-level line rate moved from 0.884099 to 0.884058, a decrease of 0.004 percentage points. That is inside the known cross-session nondeterminism band for this repository's C# coverage constants and is not treated as a regression; the binding same-scope document-level comparison moved upward. + +### The two zero-coverage rows, examined + +`evidence/qa-gates/p5-t7-coverage-delta.2026-09-06T22-00.md` reports two rows that warrant scrutiny rather than acceptance. + +1. `IJunkFolderSelectionSink.cs` is reported NOT APPLICABLE. This reviewer confirms the basis: the file declares only an interface, an interface declaration emits no IL, and consequently no `class` element for it exists in either Cobertura document. Rule R10 directs that such a file be reported as not applicable rather than as a zero. This is the correct signal for a declaration-only asset, not a coverage gap, and it does **not** constitute exclusion of a production file from measurement. +2. `AppOlObjects.JunkFolders.cs` is reported at 1 executable changed line, 0 covered. This reviewer confirms the file itself is measured (21 of 69 lines in the post-change document) and carries no class-level exclusion attribute. The single uncovered line is the explicit interface implementation's forwarding expression at line 57 of that file. It is uncovered because driving it would write to the `.NET` user settings store, which no unit test may do. The contract the forwarder carries — that the junk-certain path is supplied first — is pinned instead on the UtilitiesCS side by `PersistJunkFolderSelections_PassesJunkCertainPathFirst` against a recording double, and this reviewer verified the forwarder's argument order by code read: parameters are passed positionally in the same order, and the internal method routes `junkCertainRelativePath` to `WriteJunkCertainSetting` and `junkPotentialRelativePath` to `WriteJunkPotentialSetting`. Recorded as an advisory in the code review, not a blocking gap. + +--- + +## 6. Test Execution Metrics + +Source: `evidence/qa-gates/p5-t5-vstest.2026-09-06T22-00.md`. + +| Metric | Value | +|---|---| +| Total tests | 5262 | +| Passed | 5262 | +| Failed | 0 | +| Skipped | 0 | +| Exit code | 0 | +| Baseline total | 5237 | +| Tests added by this change | 25 | +| Fail-before tests recorded | 16 | +| Fail-before tests that pass after | 16 of 16, enumerated in full rather than sampled | +| Pre-existing failures in the Write Set | NONE | + +Red-first evidence. `evidence/regression-testing/p1-t18-fail-before.2026-09-06T22-00.md` records sixteen named tests failing before their fixes, and `p5-t5` enumerates all sixteen as passing after, in full rather than by sample. The correspondence is complete and no name is carried under a pre-existing-failure exclusion. + +Excluded test classes. Four shell-icon test classes are excluded from every local run in this plan by test-case filter: `HelperClasses.ShellUtilities_Tests`, `HelperClasses.ShellUtilitiesStatic_Tests`, `HelperClasses.SysImageListHelperTests` and `EmailIntelligence.OSBrowser_Tests`. The stated reason is that they stall vstest on this workstation, which is an environmental condition unrelated to this change and independently documented in this repository. None of the four is in the Write Set, none touches any changed file, and CI covers them. The exclusion is applied identically to the baseline and the post-change runs, so the coverage comparison is not distorted by it. + +Known intermittent failure. `UtilitiesCS.Test.Extensions.DfDeedle_COM_Tests.GetEmailDataInViewAsync_SeparatesTableSnapshotFromDataFrameTransform` is tracked as a timing flake under issue 803 and is outside this Write Set. It passed in this run as it did at baseline and is not raised here. + +--- + +## 7. Code Quality Checks + +| Check | Command | Exit code | +|---|---|---| +| Format (apply) | `dotnet tool run csharpier format .` | 0 | +| Format (verify) | `dotnet tool run csharpier check .` | 0 (1605 files, none unformatted) | +| Analyzers | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` | 0 | +| Nullable / type check | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` | 0 | +| Tests | vstest over the two named assemblies with `/InIsolation` | 0 | +| Coverage | vstest coverage collection, converted to Cobertura | 0 | + +Loop discipline. Exactly one restart was performed, its trigger recorded (the first formatter run rewrote files), and the restarted pass completed all six steps in a single uninterrupted sequence with every exit code at 0 and no step changing files. The `FORMAT-REWRITTEN-COUNT=0` SHA-256 comparison is a genuine proof that the final pass was clean rather than an assertion that it was. + +Scope gate. Fifteen source and project paths in the working set, every one a member of the Write Set, confirmed by the union of an anchored diff listing and a porcelain listing taken with `--untracked-files=all`. This reviewer independently confirmed the same fifteen paths by enumerating the file diffs in the supplied patch, and confirmed the post-image of five production files by reading them directly from the working tree and comparing them against the patch text. + +Artifact hygiene. No absolute host path, account name or machine name appears in any evidence artifact authored by this execution. The vstest results file was deliberately not committed because it carries `runUser` and `computerName` attributes, and only sanitized counts were transcribed. Two pre-existing documents in the feature folder — `issue.md` and `research/research-folder-settings-persistence.md` — contain the reporter's own mailbox address, which is maintainer-authored bug-report content that predates this change and was not introduced by it. + +--- + +## 8. Gaps and Exceptions + +1. **AC3 is not automatable and was not automated.** It requires a live Outlook process, a real user profile and a full process teardown and restart. The handoff at `evidence/other/p6-t1-ac3-manual-verification.2026-09-06T22-00.md` records `AC3-RESULT: BLOCKED-MANUAL`, marks all nine procedure steps NOT PERFORMED with individual reasons, fabricates no observation, and leaves the checkbox unmarked in both `spec.md` and `issue.md`. Plan task P6-T4 is correspondingly left unchecked, which is the plan's own conditional branch behaving as designed. This is honest non-verification, correctly recorded, and is not a defect. It is carried in the feature audit as UNVERIFIED and handed to the maintainer. +2. **Pre-existing file-size overage, deliberately not resolved.** `SmartSerializable.cs` remains 158 lines over the 500-line cap and grew by 45 lines in this change. Declared out of scope by D5 with a stated rationale. Recorded, with the delta reported as the caller directed. +3. **Repository-wide coverage below both documented floors.** Pre-existing under the two-assembly measurement scope; neither created nor resolved by this change. Recorded as an observation under R8 rather than asserted as a failed gate. +4. **The 80-versus-85 percent floor conflict between CLAUDE.md and `.claude/rules/` is unreconciled in the repository.** This is a standing documentation defect independent of this work item. R8 resolves it for this change by naming the two gates that are measurable against the scope actually used. Reconciling the two documents is a separate concern and is not raised as a finding against this change. +5. **Base and head commit identity rest on supplied values.** The directive prohibits running `git`, so the merge base could not be recomputed and the head SHA could not be confirmed current. Mitigated by three independent non-git corroborations of the patch's completeness, enumerated under "Rejected Scope Narrowing" above. +6. **The MCP policy-audit template and its validator were unavailable.** This artifact is hand-authored preserving the canonical heading structure; the validator was not run. +7. **Raw Cobertura documents are not committed.** They are git-ignored, so a later reviewer cannot re-verify the coverage figures without rerunning the measurement. This reviewer read them during this audit and confirmed every figure; the confirmation is recorded in section 5. + +--- + +## 9. Summary of Changes + +Fifteen paths: seven production C# files (five modified, two created), six test C# files (four modified, two created), and two project files. Three feature-folder Markdown documents were also written for check-off and evidence. + +| Path | Change | Purpose | +|---|---|---| +| `TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs` | modify | AC1 — the fresh-build branch adopts the loader's disk configuration | +| `TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs` | modify | AC5 — explicit implementation of the new typed sink | +| `UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs` | modify | AC2 shared path guard; AC4 `SerializeNow` explicit-save entry point | +| `UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs` | modify | AC6 — ordered SMTP fallback chain, captured failure reason, retry entry point | +| `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs` | modify | AC4 save-path switch; AC5 reflection removal; D4 partial split | +| `UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs` | create | AC5 — the typed seam | +| `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs` | create | D4 relief; AC6 retry, AC7 trim, AC8 guards | +| `UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs` | create | AC2 and AC4 tests | +| `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs` | create | AC6, AC7 and AC8 tests | +| `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs` | modify | AC5 tests and double retarget | +| `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs` | modify | AC8 — the declared D6 inversion | +| `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs` | modify | AC6 fallback-order tests | +| `TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs` | modify | AC1 positive and negative tests | +| `UtilitiesCS/UtilitiesCS.csproj` | modify | two compile entries for the new production files | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | modify | two compile entries for the new test files | + +--- + +## 10. Compliance Verdict + +**PASS. Zero blocking findings.** + +Every mandatory gate in the policy compliance order was run in the required order and passed on a single clean final pass with independently corroborated evidence. The coverage gates that R8 identifies as binding are both met and were re-verified by this reviewer against the raw Cobertura documents rather than accepted from the executor's report. No production file was excluded from coverage measurement, no coverage-exclusion attribute was introduced, no policy document was modified, no evidence artifact was written outside the canonical location, and no temporary file, sleep or wall-clock wait appears in any test. + +Six advisory findings are recorded in `code-review.2026-09-07T22-40.md`. None is blocking, none changes an acceptance-criteria verdict, and no remediation-inputs artifact is produced. + +The one outstanding item is AC3, which is handed to the maintainer for manual verification by Outlook restart with a nine-step procedure. It is unverified, not failed. + +--- + +## Appendix A: Test Inventory + +25 tests added. All 25 passed in the final run and appear in the results file. + +**AC1 — `TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs` (2)** +1. `LoadStoresAsync_WhenConfigDeserializesToNull_FreshWrapperAdoptsLoaderDiskConfiguration` +2. `LoadStoresAsync_WhenConfigKeyIsAbsent_FreshWrapperKeepsEmptyDiskPath` + +**AC2 and AC4 — `UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs` (4)** +3. `Serialize_WithEmptyDiskPath_LogsErrorAndArmsNoTimer` +4. `Serialize_WithNullDiskPath_LogsErrorAndArmsNoTimer` +5. `SerializeNow_WithConfiguredPath_WritesWithoutFiringTimer` +6. `Serialize_WithConfiguredPath_StillRequiresTimerFireToWrite` + +**AC5 — `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs` (2 new, 1 retargeted)** +7. `PersistJunkFolderSelections_PassesJunkCertainPathFirst` +8. `PersistJunkFolderSelections_WhenGlobalsAreNotTheTypedSink_LogsErrorAndDoesNotInvoke` +- retargeted: `PersistJunkFolderSelections_WhenApplyMethodIsMissing_DoesNotThrow` + +**AC6 — `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs` (5)** +9. `GetSmtpAddressFromStore_WhenPrimarySmtpAddressIsPresent_ReturnsIt` +10. `GetSmtpAddressFromStore_WhenPrimarySmtpThrows_FallsBackToAddressEntryAddress` +11. `GetSmtpAddressFromStore_WhenPrimaryAndAddressEntryFail_FallsBackToDisplayName` +12. `GetSmtpAddressFromStore_WhenEveryFallbackFails_ReturnsNullAndCapturesReason` +13. `RefreshUserEmailAddress_WhenRootFolderIsNull_ReturnsNullAndDoesNotThrow` + +**AC6, AC7 and AC8 — `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs` (12)** +14. `PopulateWithCurrent_WhenUserEmailIsNull_RetriesLookupAndRendersAddress` +15. `PopulateWithCurrent_WhenUserEmailIsAlreadyPopulated_DoesNotRetryLookup` +16. `PopulateWithCurrent_WhenRetryFails_RendersSpecificMessageWithReason` +17. `TrimStorePrefix_WithLeadingStorePrefix_RemovesIt` +18. `TrimStorePrefix_WithNoLeadingBackslash_ReturnsInputUnchanged` +19. `TrimStorePrefix_WithSingleLeadingBackslash_ReturnsInputUnchanged` +20. `TrimStorePrefix_WithEmptyString_ReturnsEmptyString` +21. `TrimStorePrefix_WithNull_ReturnsNull` +22. `TrimStorePrefix_WithOnlyTheStorePrefix_ReturnsEmptyString` +23. `PopulateWithCurrent_RendersInboxAndRootFolderWithoutStorePrefix` +24. `PopulateWithCurrent_WithNullCurrent_RendersPlaceholdersAndDoesNotThrow` +25. `GetRelativeFsPath_WithNullCurrent_ReturnsPlaceholderAndDoesNotThrow` + +**Modified — `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs`** +- `PopulateWithCurrent_NullCurrent_SetsErrorLoadingText` — assertion inverted under the declared D6 expectation change. + +--- + +## Appendix B: Toolchain Commands Reference + +```text +dotnet tool run csharpier format . +dotnet tool run csharpier check . +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true +vstest.console.exe /EnableCodeCoverage /InIsolation +``` + +Two constraints were honoured and are restated for the record: `/t:Rebuild` rather than `/t:Build`, because MSBuild's up-to-date check does not invalidate on a command-line property change and a warm `/t:Build` would skip `CoreCompile` and return exit 0 without running the gate; and `/p:Nullable=enable` deliberately omitted from the nullable step, because no project in this repository carries a `` element and forcing it conscripts every file that has never adopted the per-file pragma. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/research/research-folder-settings-persistence.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/research/research-folder-settings-persistence.md new file mode 100644 index 000000000..ec0d773f3 --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/research/research-folder-settings-persistence.md @@ -0,0 +1,604 @@ +# Research: Folder Settings never persist; User Email shows "Error Loading" (Issue #797) + +- Date: 2026-09-06 +- Branch: `bug/folder-settings-never-persist-797` +- Base commit: `c431dc3297e864041d829e8d79b348960b8d8019` +- Requirements source: `docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md` (AC1-AC8, authoritative, not renumbered or weakened here) +- Scope: research only. No source file was modified. + +All line numbers below were re-derived by reading the files in this worktree at the base commit. Where a line number or attribution in `issue.md` has moved or is incorrect, that is called out explicitly. + +--- + +## 0. Corrections to the citations in `issue.md` + +These are verified corrections, not disagreements with the acceptance criteria. Every AC stands as written. + +| Cited in `issue.md` | Verified state at `c431dc32` | Effect | +|---|---|---| +| `SmartSerializableBase.cs:167-188` is the `Deserialize(loader)` on the live StoresWrapper path | Those line numbers are correct **for that file**, but the live path does **not** enter `SmartSerializableBase`. `AppOlObjects.SmartSerializable` is an `ISmartSerializableNonTyped` (`TaskMaster\AppGlobals\AppOlObjects.cs:40-41`); `SmartSerializableNonTyped.Deserialize` (`UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializableNonTyped.cs:54-56`) calls `GetInstance()` which returns `SmartSerializable` (`:34-35`), so overload resolution binds `SmartSerializable.Deserialize(SmartSerializable loader)` at `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs:214-234`. | The defect shape is identical in both classes (config copy guarded by `if (instance is not null)`), but the **regression surface** (A3) and any edit target must be read off `SmartSerializable.cs`, not `SmartSerializableBase.cs`. | +| null return at `SmartSerializableBase.cs:335-342` | Correct for that file. The corresponding live site is `SmartSerializable.cs:388-394` (`DeserializeJson`, `if (!DiskExists(disk)) return instance;` where `instance` is `null`). | Same behaviour, different file. | +| instance-only config copy at `:176-180` | Correct for `SmartSerializableBase.cs`. Live site: `SmartSerializable.cs:222-225` (`if (instance is not null) { instance.Config.CopyFrom(loader.Config, true); }`). | Same. | +| CONTRAST overload at `:190-240`, unconditional copy at `~236` | Correct for `SmartSerializableBase.cs` (`:190-245`, copy at `:236`). Live sibling: `SmartSerializable.cs:257-310`, unconditional copy at `:302`. | Same. | +| `SmartSerializable.cs:442-448` (`Serialize`) and `:550-559` (`RequestSerialization`) | **Exactly correct.** | No change. | +| `FilePathHelper.cs:72-102` default `FilePath` of `""` | Correct: `_filePath = ""` at `UtilitiesCS\HelperClasses\FileSystem\FilePathHelper.cs:71`, property `:72-80`; the `FolderPath`/`FileName` siblings follow at `:82-102`. | No change. | +| `IntelligenceResources.resx` ~176-203 | Correct: the `StoresWrapper` data element is `UtilitiesCS\IntelligenceResources.resx:176-204`, `FileName` `StoresWrapper.json` at `:185`, `SpecialFolderName` `AppData` at `:187`. | No change. | +| `StoreWrapper.cs:179-217` (`GetSmtpAddressFromStore`), `~83` (`Init` calls it once) | Correct: method `:179-217`, throwing read `RootFolder?.Session?.CurrentUser` at `:184`, single call site `UserEmailAddress = GetSmtpAddressFromStore();` at `:83`. | No change. | +| `StoreWrapperController.cs:288-296`, `~169`, `348-357`, `391-418`, `456-474`, single `&` at `~464` | All correct. `PopulateWithCurrent` is `:279-314`; the unguarded dereferences are `:288-291`; the null-safe reads are `:294-296`. | No change. | +| `AppOlObjects.JunkFolders.cs:27-34` | The .NET user-settings write **pair** is `:27-34`; the method `ApplyJunkFolderSelections` that the reflection call targets is `:36-45`. | Minor: the reflection target is at `:36-45`, the setting writers at `:24-34`. | +| `AppAutoFileObjects.cs:217-222` (RecentFolders, `askUserOnError` overload) | Correct: `TaskMaster\AppGlobals\AppAutoFileObjects.cs:217-224`, `SloLinkedList.Static.DeserializeAsync(config, true)` at `:219-222`. | No change. | +| "the same session's `ThreadMonitor` captured the UI thread inside `_ExchangeUser.get_PrimarySmtpAddress()` at 17:35:21, so a second caller of this chain also blocks on it" | **Partly incorrect.** The captured stack at 17:35:21 is `RecipientStatic.GetRecipientAddress` -> `RecipientStatic.GetRecipientInfo` -> `MailItemHelper.InitLazyFields` -> `QfcCollectionController.GetPartiallyInitializedHelperAsync`. That is the QuickFiler recipient-resolution path in `UtilitiesCS\OutlookObjects\Recipient\RecipientStatic.cs:458`, **not** `StoreWrapper.GetSmtpAddressFromStore`. | The blocking hazard for `_ExchangeUser.get_PrimarySmtpAddress()` is real and repeatedly evidenced (17:35, 17:40 x3, 17:43, 19:06, 20:02) but it is attributable to a **different, out-of-scope** caller. Do not fix that caller under #797. | + +Runtime evidence actually confirming root causes 1 and 2 (read-only; log lives outside the repo): + +``` +2026-09-06 17:29:59,517 [VSTA_Main] WARN TaskMaster.AppOlObjects - StoresWrapper config deserialized to null; rebuilding from live stores. +2026-09-06 17:29:59,592 [VSTA_Main] ERROR UtilitiesCS.OutlookObjects.Store.StoreWrapper - Error retrieving PrimarySmtpAddress from secondary inbox. The operation failed. +``` + +The same two lines recur at 19:09:20 and 19:26:35, i.e. once per Outlook start, confirming that the file is never created and the SMTP lookup fails on every start. + +--- + +## A. Serializer bootstrap + +### A1. Every `Deserialize` overload, and whether it copies the loader's `Config`/`Disk` + +#### A1.1 `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializableBase.cs` (as asked) + +| Line | Signature | Copies loader `Config`? | File present | File absent | Deserialize error | +|---|---|---|---|---|---| +| `:126` | `Deserialize(string fileName, string folderPath)` | n/a (no loader) | delegates to `:132` | delegates | delegates | +| `:132` | `Deserialize(string, string, bool askUserOnError)` | n/a | delegates to `:247` | delegates | delegates | +| `:140` | `Deserialize(string, string, bool, JsonSerializerSettings)` | n/a | delegates to `:247` | delegates | delegates | +| `:167` | `Deserialize(SmartSerializable loader)` | **Yes, but only inside `if (instance is not null)` at `:177-180`** (`config?.CopyFrom(loader.Config, true)` at `:179`) | copies | **returns `null`, copies nothing** (`DeserializeJson` returns `null` at `:339-342`) | `DeserializeJson` logs and returns `null` at `:347-350`, so also **copies nothing** | +| `:190` | `Deserialize(SmartSerializable loader, bool askUserOnError, Func? altLoader)` | **Yes, unconditionally at `:236`** | copies | `CreateEmpty` at `:220`, then copy at `:236`, then `Serialize(instance!)` at `:241` | `CreateEmpty` at `:231`, then copy at `:236`, then write | +| `:247` | `protected Deserialize(FilePathHelper disk, bool, JsonSerializerSettings)` | Copies `disk.FilePath` and settings only, at `:291-296` | copies | `CreateEmpty` at `:274` then `:291-296` then write at `:300` | `CreateEmpty` at `:285` then `:291-296` then write | + +Related non-`Deserialize`-named members on the same class: `TryDeserialize` `:152` (wraps `:167`); `DeserializeAsync` `:305`, `:314`, `:324` (wrap `:167`, `:190`, `:190`); `DeserializeJson` `:335`, `:382`; `DeserializeObject` `:362`. + +Only `:167` (single-argument loader form) discards the loader's disk configuration, and only on the file-absent / deserialize-error paths. + +#### A1.2 `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs` (the class actually on the StoresWrapper path) + +| Line | Signature | Copies loader `Config`? | File present | File absent | Deserialize error | +|---|---|---|---|---|---| +| `:177` | `Deserialize(string, string)` | n/a | delegates to `:182` | delegates | delegates | +| `:182` | `Deserialize(string, string, bool)` | n/a | delegates to `:312` | delegates | delegates | +| `:189` | `Deserialize(string, string, bool, JsonSerializerSettings)` | n/a | delegates to `:312` | delegates | delegates | +| `:214` | `Deserialize(SmartSerializable loader)` | **Yes, guarded at `:222-225`** | copies | **returns `null` (as `instance!`), copies nothing** | `DeserializeJson` logs at `:399-402` and returns `null`; copies nothing | +| `:236` | `Deserialize(ISmartSerializable loader)` | Same guarded shape at `:244-247` | copies | returns `null` | returns `null` | +| `:257` | `Deserialize(SmartSerializable, bool, Func?)` | **Yes, unconditionally at `:302`**, plus `instance!.Serialize()` at `:306` when `writeInstance` | copies | `CreateEmpty` `:286` -> copy `:302` -> write `:306` | `CreateEmpty` `:297` -> copy `:302` -> write | +| `:312` | `protected Deserialize(FilePathHelper, bool, JsonSerializerSettings)` | Copies `disk.FilePath` only, at `:355` | copies | `CreateEmpty` `:338` -> `:355` -> write `:359` | `CreateEmpty` `:349` -> `:355` -> write | + +Related members: `TryDeserialize` `:200`; `DeserializeAsync` `:364`, `:372`, `:378`; `DeserializeJson` `:388`, `:432`; `DeserializeObject` `:410`; nested `Static` forwarders at `:569`, `:572`, `:575`, `:582`, `:588`, `:592`, `:599`. + +Live trace for #797: `TaskMaster\AppGlobals\AppOlObjects.StoreLoading.cs:41-44` -> `SmartSerializableNonTyped.cs:54-56` -> `SmartSerializable.cs:214-234` -> `SmartSerializable.cs:388-394` returns `null` because `DiskExists(disk)` is false -> the `if (instance is not null)` guard at `:222` skips the copy -> `StoreLoading.cs:51-53` logs the observed WARN -> `StoreLoading.cs:64` calls `BuildFreshStoresWrapper()` (`StoreLoading.cs:32-33`) -> `new StoresWrapper(_globals).Init()` whose `Config.Disk.FilePath` is the `FilePathHelper` default `""` (`FilePathHelper.cs:71`) -> `StoreWrapperController.SaveChanges` `:356` calls `Model.Serialize()` -> `SmartSerializable.cs:444` `if (Config.Disk.FilePath != "")` is false -> silent return. + +### A2. Minimal change for AC1, and where it belongs + +**Recommendation: put the fix in `TaskMaster\AppGlobals\AppOlObjects.StoreLoading.cs` only. Do not change `SmartSerializable.cs` or `SmartSerializableBase.cs` for AC1.** + +Rationale (evidence-based, not preference): + +1. **There is no instance to copy onto in the serializer.** On the file-absent path `DeserializeJson` returns `null` (`SmartSerializable.cs:391-394`). "Make the file-absent path adopt the loader's disk configuration" is therefore not expressible as a copy inside `Deserialize(loader)`; it would require *constructing* an instance, which changes the method's null-returning contract. +2. **That null contract is load-bearing for a second production caller.** `TaskMaster\AppGlobals\AppAutoFileObjects.FolderPredictorLoad.cs:69-85` calls `FolderPredictorDeserializer(loader)` (default `LcppnFolderPredictor.Static.DeserializeAsync(loader)`, `:42`), and its own comment at `:74-76` states: "DeserializeAsync returns null when the dedicated file is absent (fail-soft); the holder then stays null and the accessor falls back to flat." Making the overload return a constructed instance would silently disable that fallback. +3. **Blast radius.** The `ReusableTypeClasses` tree is shared with three concurrent sibling work items. `SmartSerializable.cs` is already **613 lines** and `SmartSerializableBase.cs` **545 lines** — both already over the 500-line cap in `.claude/rules/general-code-change.md`. Any edit there is a merge-conflict magnet and worsens an existing violation. `AppOlObjects.StoreLoading.cs` is **75 lines** with ample headroom and is owned solely by this issue. +4. **The seam already exists and is already unit-tested.** `BuildFreshStoresWrapper()` is `protected internal virtual` (`StoreLoading.cs:32-33`) and is overridden by `TestableAppOlObjects` in `TaskMaster.Test\AppGlobals\AppOlObjectsCoverageTests.cs` (see `:78-143`, `:186-201`). `LoadStoresAsync` already holds the `config` loader in scope at `:39` and can apply `config.Config` to the freshly built wrapper at `:64` with no new type, no new interface, and no COM. + +Concrete minimal shape (for the planner, not applied here): in `LoadStoresAsync`, after `StoresWrapper = BuildFreshStoresWrapper();` at `:64`, when the `TryGetValue` at `:39` produced a `config`, copy the loader configuration onto the fresh wrapper — `StoresWrapper.Config.CopyFrom(config.Config, true)` (`NewSmartSerializableConfig.CopyFrom` is `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\Config\NewSmartSerializableConfig.cs:197-214`, and copies `Disk`, `LocalDisk`, `NetDisk`, `ActiveDisk` and the three settings lazies). The `config not found` branch at `:57` has no loader and must remain a fresh build with an empty path; AC2's new error log then makes that case visible rather than silent. + +**Not recommended (rejected alternatives, kept brief):** +- Changing `SmartSerializable.cs:214` to construct-and-copy on the null path: breaks the LCPPN fail-soft contract (point 2), edits an over-cap shared file, and would need `askUserOnError` semantics it does not have. +- Switching the call site to the `askUserOnError` overload (`SmartSerializable.cs:257`, the `RecentFolders` design reference): this *would* fix AC1 with one call-site change, but the overload calls `AskUser` -> `MyBox.ShowDialog` (`:163-168`) during VSTO startup and `CreateEmpty` -> `new T()` (`:136`), i.e. `new StoresWrapper()` with no globals rather than `BuildFreshStoresWrapper()`'s `new StoresWrapper(_globals).Init()`. `Globals` would be null and `GetFilteredStores` (`StoresWrapper.cs:190-193`) would throw. Rejected on both counts. + +### A3. Complete regression surface — every caller of the affected overload + +Affected overload: `SmartSerializable.Deserialize(SmartSerializable loader)`, `SmartSerializable.cs:214-234`. + +**Because the A2 recommendation makes no change to this overload, the behavioural regression surface is empty.** The list is nonetheless enumerated in full so the planner can confirm that, and so a reviewer can verify that no alternative was chosen that would touch it. + +Direct in-repo callers of `SmartSerializable.cs:214`: + +| # | Call site | Kind | Would A2 change it? | +|---|---|---|---| +| 1 | `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializableNonTyped.cs:56` (`Deserialize` forwarder) | production forwarder | No | +| 2 | `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs:205` (`TryDeserialize`) | production forwarder | No | +| 3 | `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs:369` (`DeserializeAsync(config)`) | production forwarder | No | +| 4 | `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs:583` (`Static.Deserialize`) | production forwarder | No | +| 5 | `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs:590` (`Static.DeserializeAsync`) | production forwarder | No | +| 6 | `UtilitiesCS\ReusableTypeClasses\SerializableNew\Concurrent\Observable\ScoDictionaryNew.cs:147` (explicit `ISmartSerializable<>` impl) | production forwarder; no in-repo caller found | No | +| 7 | `UtilitiesCS\ReusableTypeClasses\SerializableNew\Concurrent\Observable\SloStack.cs:85` (`DeserializeAsync(config)`) | production forwarder | No | +| 8 | `UtilitiesCS\ReusableTypeClasses\SerializableNew\Concurrent\Observable\SloLinkedList.cs:71` and `:161` | production forwarders | No | + +Terminal (leaf) production entry points that actually reach it at runtime: + +| # | Entry point | Path | Would A2 change it? | +|---|---|---|---| +| A | `TaskMaster\AppGlobals\AppOlObjects.StoreLoading.cs:41-44` | via forwarder 1 | **Yes — this is the intended fix site.** The overload's own behaviour is unchanged; the caller gains a post-fresh-build config copy. | +| B | `TaskMaster\AppGlobals\AppAutoFileObjects.FolderPredictorLoad.cs:42` (`LcppnFolderPredictor.Static.DeserializeAsync(loader)`; `LcppnFolderPredictor : SmartSerializable` at `UtilitiesCS\EmailIntelligence\Bayesian\LcppnFolderPredictor.cs:23-25`) | via forwarders 5 -> 3 | No. Its documented fail-soft null contract (`FolderPredictorLoad.cs:74-76`) is preserved exactly. | + +Production entry points that use a **different** overload and are therefore out of the surface: `TaskMaster\AppGlobals\AppToDoObjects.cs:174-178` (3-argument `askUserOnError` form), `TaskMaster\AppGlobals\AppAutoFileObjects.cs:190-194` and `:219-222`, `UtilitiesCS\EmailIntelligence\ClassifierGroups\ManagerAsyncLazy.cs:293-297`, `UtilitiesCS\EmailIntelligence\SubjectMap\SubjectMapEncoder.cs:23`, `:41`, `:97`, `ToDoModel\Data Model\Project\ProgramData.cs:75`, `:83`, `:97`. + +Test callers that pin the current behaviour of the affected overload (must keep passing): `UtilitiesCS.Test\ReusableTypeClasses\SmartSerializable_Tests.cs:406`, `:422`, `:449`, `:468`, `:659`, `:686`, `:708`, `:728`, `:742-749`; `UtilitiesCS.Test\ReusableTypeClasses\SmartSerializableNonTyped_Tests.cs` (whole file); `UtilitiesCS.Test\ReusableTypeClasses\SloLinkedList_Tests.cs:291`, `:367`, `:385`; `UtilitiesCS.Test\ReusableTypeClasses\SerializableNew\Concurrent\Observable\SloStack_Tests.cs:263`, `:367-368`, `:385-387`; `TaskMaster.Test\AppGlobals\AppOlObjectsTests.cs:199-205`; `TaskMaster.Test\AppGlobals\AppOlObjectsCoverageTests.cs:41-47`, `:117-123`, `:157-164`. + +### A4. How the resource-defined disk configuration is constructed + +1. The resource entry: `UtilitiesCS\IntelligenceResources.resx:176-204`, key `StoresWrapper`, with `Config.Disk = { FileName: "StoresWrapper.json", RelativePath: "", SpecialFolderName: "AppData" }` (`:184-188`), an identical `LocalDisk` (`:189-193`), a `NetDisk` on `"Flow"` (`:194-198`), and `"ActiveDisk": 1` (`:200`). +2. The reader: `UtilitiesCS\EmailIntelligence\IntelligenceConfig.cs`. `GetSerializedConfigurations()` at `:237-246` reads `IntelligenceResources.ResourceManager` and returns every resource as a `name -> json` map. `ReadConfigurationAsync()` at `:77-163` deserializes each into a `SmartSerializableLoader` via `DeserializeLoaderAsync` (`:248-261`) and exposes the result as `IntelligenceConfig.Config` (`:62-66`), a `ConcurrentDictionary` surfaced on `IApplicationGlobals.IntelRes` (`UtilitiesCS\Interfaces\IGlobals\IApplicationGlobals.cs:17`). +3. The path materialisation: `SmartSerializableLoader.GetSettings()` (`UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializableLoader.cs:80-88`) registers `new FilePathHelperConverter(Globals.FS)` (`:86`). `FilePathHelperConverter.ReadJson` (`UtilitiesCS\NewtonsoftHelpers\FilePathHelperConverter.cs:29-42`) resolves `SpecialFolderName` through `FileSystemFolders.SpecialFolders` (`:44-65`) and returns `new FilePathHelper(fileName, folderPath)`, whose constructor sets `FilePath = Path.Combine(folderPath, fileName)` (`FilePathHelper.cs:28-34`). +4. `"AppData"` resolves to `%LocalAppData%\TaskMaster`: `TaskMaster\AppGlobals\AppFileSystemFolderPaths.cs:216-223` registers `AppData` as `[Environment.GetFolderPath(SpecialFolder.LocalApplicationData), nameof(TaskMaster)]`. +5. `DeserializeConfig` finishes with `instance.Config.ActivateMostRecent()` (`SmartSerializableLoader.cs:195`), which copies `LocalDisk` or `NetDisk` into `Disk` (`NewSmartSerializableConfig.cs:145-169`). + +**How a caller obtains the correctly-pathed loader:** exactly what `LoadStoresAsync` already does — `_globals.IntelRes.Config.TryGetValue("StoresWrapper", out var config)` at `TaskMaster\AppGlobals\AppOlObjects.StoreLoading.cs:39`. The `config` in scope at `:39-54` **already carries** `Config.Disk.FilePath == %LocalAppData%\TaskMaster\StoresWrapper.json`. Nothing new needs to be constructed for AC1; the value is present and is simply discarded on the fresh-build branch. + +--- + +## B. Serialize guard and deferred write + +### B1. The guard, and the available logger seam + +`UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs:442-448`: + +```csharp +public void Serialize() +{ + if (Config.Disk.FilePath != "") + { + RequestSerialization(Config.Disk.FilePath); + } +} +``` + +Two properties matter for AC2. First, the comparison is `!= ""` only — a **null** `FilePath` passes the guard and reaches `RequestSerialization(null)`; `FilePathHelper.FilePathHelper_PropertyChanged` can assign `_filePath = null!` at `:358` and `:366`. AC2's wording ("empty or null") therefore covers a genuinely reachable second case. Second, the sibling `SmartSerializableBase.Serialize(T instance)` at `SmartSerializableBase.cs:422-430` already uses `IsNullOrEmpty` and is the consistent shape to converge on. + +Logger seam available on the type: `SmartSerializable` declares `private static readonly log4net.ILog logger` at `:26-28`. It is **not injectable**. Three options, in ascending intrusiveness: + +- **(a) log4net `MemoryAppender` attached to `typeof(SmartSerializable).FullName`.** No production change beyond the `logger.Error(...)` call itself. The pattern is already proven in `TaskMaster.Test\AppGlobals\ApplicationGlobalsStartupTimingTests.cs:202-218` and `TaskMaster.Test\AppGlobals\AppEventsTests.Helpers.cs:228-241`. `UtilitiesCS.Test` has a direct `log4net` reference (`UtilitiesCS.Test\UtilitiesCS.Test.csproj:576-578`) so the same helper compiles there, though `UtilitiesCS.Test` does not currently use it anywhere. **Recommended** — it adds no new production surface to a shared, over-cap file. +- (b) A `protected virtual void LogSerializeError(string message)` hook overridden by the existing `SmartSerializableHarness` (`UtilitiesCS.Test\ReusableTypeClasses\SmartSerializable_Tests.cs:771-806`). Matches the file's existing protected-seam idiom (`ReadAllText`, `DiskExists`, `ShowDialog`, `CreateStreamWriter`, `TimerFactory`). +- (c) An injectable `Action` sink, the shape used by `StoreWrapperInitProbe` (`UtilitiesCS\OutlookObjects\Store\StoreWrapperInitProbe.cs`, constructed as `new StoreWrapperInitProbe(s => logger.Debug(s))` at `StoreWrapper.cs:92`). More surface than (b) for no extra benefit here. + +### B2. `RequestSerialization` timer mechanics + +`SmartSerializable.cs:550-559`: + +```csharp +protected void RequestSerialization(string filePath) +{ + if (_serializationRequested.CheckAndSetFirstCall) + { + _timer = TimerFactory(TimeSpan.FromSeconds(3)); + _timer.Elapsed += (sender, e) => SerializeThreadSafe(filePath); + _timer.AutoReset = false; + _timer.StartTimer(); + } +} +``` + +- **Timer type:** `ITimerWrapper` (`UtilitiesCS\Interfaces\ITimerWrapper.cs:6-10`) produced by `TimerFactory` (`SmartSerializable.cs:547-548`), defaulting to `new TimerWrapper(interval)`. `TimerWrapper` wraps a `System.Timers.Timer` through `SystemTimersTimerAdapter` (`UtilitiesCS\ReusableTypeClasses\TimedActions\TimerWrapper.cs:36-77`), whose `Elapsed` callback runs on a **ThreadPool** thread, not the UI thread. +- **Interval:** fixed 3 seconds, `AutoReset = false` (single shot). +- **Repeat calls coalesce; they do not reset.** The gate is `ThreadSafeSingleShotGuard.CheckAndSetFirstCall` (`UtilitiesCS\Threading\ThreadSafeSingleShotGuard.cs:24-27`, an `Interlocked.Exchange`). The first call inside a window arms the timer; every subsequent call returns immediately without creating or restarting a timer. Consequently the **`filePath` captured is the first caller's**, and the guard is only re-armed in `SerializeThreadSafe`'s `finally` at `:498` (`_serializationRequested = new ThreadSafeSingleShotGuard();`). A second `Serialize(otherPath)` inside the window silently writes to the first path. +- **Process exit:** `System.Timers.Timer` raises `Elapsed` on a ThreadPool (background) thread. Background threads are not joined at process exit, so a write still pending when Outlook tears down the AppDomain is **lost with no log entry**. This is exactly the AC4 loss window. + +### B3. Candidate hosts for an AC4 flush + +| Candidate | Location | Verdict | +|---|---|---| +| `ThisAddIn_Shutdown` | `TaskMaster\ThisAddIn.cs:287-291`, wired at `:302` | **Unusable.** The body carries the stock VSTO comment: "Note: Outlook no longer raises this event." Verified present at the base commit. A flush placed here would never run. | +| `ThisAddIn.Designer.OnShutdown` | `TaskMaster\ThisAddIn.Designer.cs:162-165` | Designer-generated; must not be hand-edited, and it is downstream of the same unraised-event problem. | +| `AppOlObjects.FolderTreeService` disposal | `TaskMaster\AppGlobals\AppOlObjects.FolderTreeService.cs:374` (`Dispose()`), `:240`, `:246`, `:407` | Exists, but is a folder-tree-service lifetime hook, not an application-shutdown hook, and there is no evidence it runs at Outlook teardown. Out of scope per the footprint constraint. | +| **Synchronous write on explicit Save** | `UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs:356` (`Model.Serialize();` inside `SaveChanges`) | **Recommended.** | + +**Is a synchronous write on explicit Save feasible without changing deferred behaviour for other callers?** Yes. `SerializeThreadSafe(string filePath)` is already `public` on `SmartSerializable` (`:474-501`); it takes the write lock, writes through the injectable `CreateStreamWriter`, and re-arms the single-shot guard in its `finally`. Calling it directly from `SaveChanges` in place of (or in addition to) `Model.Serialize()` writes the file inline on the UI thread and leaves `Serialize()`/`RequestSerialization` untouched for every other caller. Two facts to respect: + +1. `SerializeThreadSafe` calls `_parent.ThrowIfNull(...)` at `:476-478`. `StoresWrapper` sets `base._parent = this` in both constructors (`UtilitiesCS\OutlookObjects\Store\StoresWrapper.cs:28`, `:33`), so the guard is satisfied on both the deserialized and fresh-built models. +2. AC2's empty/null-path error must be evaluated **before** any synchronous write, otherwise the fix would substitute one silent failure (`Serialize` no-op) for another (`File.CreateText("")` throwing inside `SerializeThreadSafe`'s own catch at `:490-493`). The cleanest shape is a new small guarded method rather than a bare `SerializeThreadSafe` call from the controller. + +`SmartSerializable.Serialize()` is **not virtual**, so a Moq mock of `StoresWrapper` cannot intercept it. AC4 tests must use the `CreateStreamWriter` seam (see F1) rather than `Mock.Verify`. + +--- + +## C. Junk-folder double persistence (AC5) + +### C1. The reflection lookup, and whether the target exists + +`UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs:391-418`: + +```csharp +internal void PersistJunkFolderSelections() +{ + var olObjects = Globals?.Ol; + if (olObjects is null) { return; } + + var applyMethod = olObjects + .GetType() + .GetMethod( + "ApplyJunkFolderSelections", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + [typeof(string), typeof(string)], + null + ); + + if (applyMethod is null) + { + logger.Warn( + "Unable to persist junk-folder selections because the Outlook globals implementation does not expose ApplyJunkFolderSelections." + ); + return; + } + + applyMethod.Invoke(olObjects, [JunkEmail?.RelativePath, JunkPotential?.RelativePath]); +} +``` + +Resolved method: `TaskMaster.AppOlObjects.ApplyJunkFolderSelections(string junkCertainRelativePath, string junkPotentialRelativePath)`, declared at `TaskMaster\AppGlobals\AppOlObjects.JunkFolders.cs:36-45`. It is `internal void`, instance, two `string` parameters — an exact match for the `BindingFlags` and parameter-type array above. + +**The method does exist with a matching signature, and the reflection lookup succeeds in production.** The `applyMethod is null` warn branch at `:409-415` is reached only by test doubles that do not declare the method (`NoApplyOlObjects` in `UtilitiesCS.Test\OutlookObjects\Store\StoreWrapperControllerTests.cs:214`). The issue text's "silently returns with only a warning when the method is not found" describes a real but production-unreachable branch; the substantive AC5 defect is the double persistence itself plus the untyped, rename-fragile binding. + +Argument-order note: the invocation passes `JunkEmail?.RelativePath` first and `JunkPotential?.RelativePath` second, matching `(junkCertainRelativePath, junkPotentialRelativePath)`. `JunkEmail` on the controller mirrors `StoreWrapper.JunkCertain` (`StoreWrapperController.cs:290`, `:351`), so the order is semantically correct today — but it is enforced by nothing except positional agreement, which is precisely the fragility AC5 targets. + +### C2. The two mechanisms, and how they diverge + +**Mechanism 1 — per-store JSON.** `SaveChanges` (`StoreWrapperController.cs:348-357`) assigns `Current.JunkCertain = JunkEmail` (`:351`) and `Current.JunkPotential = JunkPotential` (`:352`), then `Model.Serialize()` (`:356`). These live on `StoreWrapper` (`UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs:227-229`) as `FolderMinimalWrapper`s whose `RelativePath` is computed relative to **the selected store's** root: `SelectFolder` builds `new FolderMinimalWrapper(folder, Current.RootFolder!)` at `StoreWrapperController.cs:429`, and `FolderMinimalWrapper.ToRelativePath()` strips `OlRoot.FolderPath` (`UtilitiesCS\OutlookObjects\Folder\FolderMinimalWrapper.cs:84`). + +**Mechanism 2 — .NET user settings.** `ApplyJunkFolderSelections` (`AppOlObjects.JunkFolders.cs:36-45`) writes `Properties.Settings.Default.OlJunkCertain` and `.JunkPotential` (writers at `:27-34`), calls `Properties.Settings.Default.Save()` (`:43`), then `RefreshJunkFolderSelections()` (`:44`), which nulls `_junkCertain`/`_junkPotential` and re-reads them (`:47-53`). The read side, `LoadJunkCertain` (`:106-140`) and `LoadJunkPotential` (`:55-89`), resolves the stored relative path against `new OutlookFolderNode(Root)` (`:69`, `:120`) where `Root` is the **default store's** root folder (`TaskMaster\AppGlobals\AppOlObjects.cs:206-214`, `App.Session.DefaultStore.GetRootFolder()`). + +Concrete divergence paths, all currently live: + +1. **Different roots.** Mechanism 1 stores a path relative to the *selected* store's root; mechanism 2 resolves it against the *default* store's root. Selecting junk folders while a non-default store is chosen in the dialog writes a settings value that `LoadJunkCertain` will fail to resolve (or, worse, resolve to a same-named folder in the wrong store), while the JSON side records the correct value. +2. **Store scope.** `Properties.Settings` holds exactly one global pair, but the JSON holds one pair *per store*. Saving on store B unconditionally overwrites the settings written for store A. `PersistJunkFolderSelections` is called on **every** `SaveChanges`, including saves that changed only the archive root or the exclude-store checkbox (`AnyChanges`, `:236-243`). +3. **Asymmetric failure today.** Because of root cause 1, mechanism 1 writes nothing at all while mechanism 2 writes successfully. The two stores of truth are therefore already divergent on every machine that has never had `StoresWrapper.json`. +4. **Null propagation.** `applyMethod.Invoke(..., [JunkEmail?.RelativePath, JunkPotential?.RelativePath])` passes `null` when either wrapper or its `RelativePath` is null; `WriteJunkCertainSetting(null)` stores `null`, and `LoadJunkCertain` then early-returns `null` at `:109-112`. The JSON side, by contrast, retains the previous `FolderMinimalWrapper`. + +### C3. The typed seam that replaces the reflection call + +**Project reference direction (verified):** `TaskMaster\TaskMaster.csproj:520` declares ``. `UtilitiesCS\UtilitiesCS.csproj` declares exactly one `ProjectReference`, to `..\SVGControl\SVGControl.csproj` (`:1118-1121`). The dependency is one-way, `TaskMaster -> UtilitiesCS`, and **must stay that way**. (`UtilitiesCS\Interfaces\IGlobals\IApplicationGlobals.cs:2` carries `using TaskMaster;`, which resolves because `UtilitiesCS\Interfaces\IGlobals\IAppEvents.cs:6` declares `namespace TaskMaster` inside `UtilitiesCS` itself — it is not a reference to the `TaskMaster` project.) + +**Recommended seam: a new narrow interface in `UtilitiesCS`, implemented by `TaskMaster.AppOlObjects`.** + +- Interface: `IJunkFolderSelectionSink` (name for the planner to confirm), one member `void ApplyJunkFolderSelections(string junkCertainRelativePath, string junkPotentialRelativePath)`. +- Project and file: `UtilitiesCS\Interfaces\IGlobals\IJunkFolderSelectionSink.cs`, alongside the existing `IStoreDisableService.cs` and `IStoreRehookService.cs` in the same folder — a directly analogous precedent (a `UtilitiesCS`-declared service interface implemented in `TaskMaster` and consumed from `UtilitiesCS`). +- Implementer: `TaskMaster.AppOlObjects`, via the junk partial `TaskMaster\AppGlobals\AppOlObjects.JunkFolders.cs`. The existing method at `:36-45` must be promoted from `internal` to `public` to satisfy the implicit interface implementation (C# requires public implicit implementations), or declared explicitly as `void IJunkFolderSelectionSink.ApplyJunkFolderSelections(...)` to keep the surface closed. **Explicit implementation is preferred** so the public surface of `AppOlObjects` does not widen. +- Consumption: `StoreWrapperController.PersistJunkFolderSelections` becomes `if (Globals?.Ol is IJunkFolderSelectionSink sink) { sink.ApplyJunkFolderSelections(...); } else { logger.Warn(...); }` — a compile-checked call with no `System.Reflection` dependency (the `using System.Reflection;` at `StoreWrapperController.cs:6` can then be removed if no other use remains). +- **Direction respected:** the interface lives in `UtilitiesCS`, the implementation in `TaskMaster`. `UtilitiesCS` gains no reference to `TaskMaster`. This is identical in shape to `IStoreDisableService` (`UtilitiesCS\Interfaces\IGlobals\IStoreDisableService.cs:54-104`). + +**Why not add the member to `IOlObjects`** (`UtilitiesCS\Interfaces\IGlobals\IOlObjects.cs:11-38`): every concrete implementer would have to be updated, including the test stub `OlObjectsStubBase : IOlObjects` at `UtilitiesCS.Test\OutlookObjects\Store\StoreWrapperControllerTests.cs:139-194` and the `IApplicationGlobals` stubs in `QuickFiler.Test` (`EfcHomeControllerTests.cs:202`, `EfcHomeControllerMetricsTests.cs:462`, `EfcHomeControllerLifecycleTests.cs:388`). A separate opt-in interface keeps the change local and preserves the existing `NoApplyOlObjects` negative test (`StoreWrapperControllerTests.cs:214`) unchanged in intent. + +AC5 also permits removing the double-persistence path entirely. That is the larger behavioural change (it would strand `LoadJunkCertain`/`LoadJunkPotential`, which are the only consumers of the settings values and are read by `IOlObjects.JunkCertain`/`JunkPotential`, `IOlObjects.cs:34-35`). The typed seam plus a loud failure is the lower-risk reading of AC5 and is what this research recommends. + +--- + +## D. SMTP lookup (AC6) + +### D1. Full call chain to the placeholder + +1. `UtilitiesCS\OutlookObjects\Store\StoresWrapper.cs:49` — `Stores = filteredStores.Select(store => new StoreWrapper(store).Init()).ToList();` (fresh build), or `:151` / `:157` inside `AddOrRestoreStore` (rewire path; `Restore` also calls `Init()` at `StoreWrapper.cs:120`). +2. `UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs:82-86` — `UserEmailAddress = GetSmtpAddressFromStore();`, once per `Init`, inside the `CurrentStoreContext.Begin(DisplayName)` scope opened at `:62`. +3. `UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs:179-217` — `GetSmtpAddressFromStore()`. The chain is `RootFolder?.Session?.CurrentUser` (`:184`) -> `currentUser?.AddressEntry` (`:190`) -> `addressEntry?.GetExchangeUser()` (`:196`) -> `exchangeUser?.PrimarySmtpAddress` (`:202`). A `COMException` anywhere is caught at `:209`, logged at `:211-214`, and converted to `return null;` at `:215`. The observed failure threw at `:184`, matching the log's `StoreWrapper.cs:line 184`. +4. `UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs:173-174` — `[JsonIgnore] public string? UserEmailAddress { get; internal set; }`. `JsonIgnore` means it is not persisted, so a successful lookup is not cached across restarts. +5. `UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs:296` — the placeholder render: + +```csharp +Viewer.UserEmail.Text = Current?.UserEmailAddress ?? "Error Loading"; +``` + +**Exact literal:** `"Error Loading"`. It occurs at exactly three sites, all in `UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs`: `:294` (Inbox), `:295` (Root Folder), `:296` (User Email). There are no other occurrences of the literal in any `.cs` file in the repository. + +### D2. Alternative sources for the mailbox SMTP address already reachable from the types in scope + +| # | Source | Location | Notes | +|---|---|---|---| +| 1 | `StoreWrapper.DisplayName` | `UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs:153`, assigned at `:38` from `InnerStore.DisplayName` | Already read before the failing chain, at no extra COM cost. In the reported environment it *is* the SMTP address (`dmoisan@realgoodfoods.com`, per the `[store-filter]` log line). Requires an `@`-containing sanity test before use. | +| 2 | `IOlObjects.UserEmailAddress` | interface `UtilitiesCS\Interfaces\IGlobals\IOlObjects.cs:17`; implementation `TaskMaster\AppGlobals\AppOlObjects.cs:347-357` (cached in `_userEmailAddress` at `:343`) | Reachable from the controller as `Globals?.Ol?.UserEmailAddress`. This is the **application-level** address (default store), so it is a correct fallback only for the default store; for a secondary store it is a different mailbox. | +| 3 | `AppOlObjects.ResolveCurrentUserEmailAddress` | `TaskMaster\AppGlobals\AppOlObjects.cs:359-382` | Backs source 2. Notably it already does the UI-thread marshalling (`UiThread.UiSyncContext.Send`, `:364-369`) and catches `COMException` (`:377-381`). | +| 4 | `AppOlObjects.TryGetSmtpAddress(AddressEntry)` | `TaskMaster\AppGlobals\AppOlObjects.cs:384-412` | **The existing in-repo precedent for AC6's fallback ordering**: try `GetExchangeUser()?.PrimarySmtpAddress` inside its own `try/catch (COMException)` (`:391-399`), then fall back to `addressEntry.Address` when it contains `"@"` inside a second `try/catch` (`:401-409`), then `null`. `StoreWrapper.GetSmtpAddressFromStore` has a single outer catch and no `Address` fallback — the direct gap AC6 names. Note this helper lives in `TaskMaster`, not `UtilitiesCS`, so `StoreWrapper` cannot call it; the shape should be replicated inside `GetSmtpAddressFromStore`. | +| 5 | `StoreWrapper.GlobalAddressBook` | `UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs:176-177`, populated only by `RestoreGlobalAddresses(Application)` at `:141-147` | **Dead.** A repository-wide search finds no caller of `RestoreGlobalAddresses` outside its own declaration. Not a usable fallback without new wiring. | +| 6 | Existing consumers that already tolerate a null per-store address | `TaskVisualization\AutoCreateProject.cs:136-137` (`Stores.FirstOrDefault(x => !x.UserEmailAddress.IsNullOrEmpty())?.UserEmailAddress`) | Shows an established cross-store "first non-empty" pattern that could be reused as a last-resort fallback in the dialog. | + +Not available: `Outlook.Account` / `Session.Accounts` is **not read anywhere in the repository** (searched across all `.cs`). Using it would be new COM surface, and `StoreWrapper` holds no `Application` reference (only `InnerStore`, `:165`), so it is not reachable from the types in scope without a new parameter. Recorded as unverified/unavailable rather than recommended. + +Recommended fallback order for AC6, all inside `GetSmtpAddressFromStore` except the last: (1) `PrimarySmtpAddress`; (2) `AddressEntry.Address` when it contains `@` (mirroring `TryGetSmtpAddress`); (3) `DisplayName` when it contains `@`; and at the controller, (4) a specific unavailability message carrying the caught exception's `Message`, replacing `"Error Loading"`. + +### D3. Where a retry-on-dialog-open is wired + +`StoreWrapperController.Launch()` (`UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs:117-137`) assigns `Viewer.DisplayName.DataSource = readiness.DisplayNames;` at `:134`. `StoreWrapperViewer`'s constructor wires `DisplayName.SelectedValueChanged += DisplayName_SelectedValueChanged;` (`UtilitiesCS\OutlookObjects\Store\StoreWrapperViewer.cs:14`), so setting the data source raises `DisplayName_SelectedValueChanged` (`StoreWrapperController.cs:153-171`), which sets `Current` at `:169` and calls `PopulateWithCurrent()` at `:170` — **before** `Viewer.ShowDialog()` at `:136`. + +Therefore **`PopulateWithCurrent()` (`:279-314`) is the single method that runs both when the dialog opens and on every store re-selection.** It is the correct retry site. + +**Thread:** yes, the UI thread. `Launch` is `[ExcludeFromCodeCoverage]` and reached from the ribbon click (`RibbonViewer.FolderSettings_Click -> RibbonController.FolderStoresSettings -> StoreWrapperController.Launch`, confirmed by the stack at log line 2682). `PopulateWithCurrent` itself opens with an `if (Viewer.InvokeRequired) { Viewer.Invoke(() => PopulateWithCurrent()); return; }` marshal at `:281-285`, so its body always executes on the UI (STA) thread. + +### D4. The blocking hazard, and whether a reusable seam exists + +**Hazard, stated precisely.** `_ExchangeUser.get_PrimarySmtpAddress()` is an Outlook interop call that can block the STA for many seconds. The evidence in `debug_2026-09-06.log` (lines 2887-2892, 7814, 8005, 8291, 9108, 13039, 14842) shows the UI thread parked inside it repeatedly — but via `RecipientStatic.GetRecipientAddress` (`UtilitiesCS\OutlookObjects\Recipient\RecipientStatic.cs:458`) in the QuickFiler mail-load path, **not** via `StoreWrapper.GetSmtpAddressFromStore`. Adding a retry at `PopulateWithCurrent` therefore reintroduces a blocking call on the UI thread at dialog-open time, on a chain independently demonstrated to be capable of long blocks. + +**Existing seams, evaluated:** + +- `UtilitiesCS\Threading\TimeOutTask.cs` — the only timeout primitive, `public static class TimeOutTask` at `:13` with `RunWithTimeout` overloads at `:21`, `:97`, `:165`, `:250`, `:324`, `:402`, `:480`, `:551`, `:633`, `:715` (plus private recursive companions). Every overload executes the work via `Task.Run(...)` on a **ThreadPool (MTA)** thread (for example `:63`). **Not suitable for Outlook COM.** An Outlook interop object is STA-apartment-bound; calling it from an MTA thread marshals the call back to the STA, so the STA still blocks and the caller's timeout merely abandons the wait while the worker remains blocked. Verified property of the code, not speculation about Outlook: `AppOlObjects.ResolveCurrentUserEmailAddress` documents precisely this constraint at `TaskMaster\AppGlobals\AppOlObjects.cs:361-369` ("Outlook COM objects must be accessed from the STA thread on which they were created... marshal synchronously to the UI thread to avoid COMException 0xEF640201"). +- `UtilitiesCS\Threading\UiThread.cs` — `UiSyncContext` (`:113-126`), `UiThreadId` (`:128-133`), `Dispatcher` (`:153-171`). These marshal work **onto** the UI thread; they do not move COM work off it. +- `UtilitiesCS\Threading\ThreadMonitor.cs` plus `CurrentStoreContext` (`UtilitiesCS\Threading\CurrentStoreContext.cs`, already wrapped around this exact chain at `StoreWrapper.cs:62-87`) — observational attribution only. + +**Conclusion for D4:** no existing seam in `UtilitiesCS` makes an Outlook COM property read genuinely non-blocking. The honest options for AC6 are (a) do the retry synchronously on the UI thread and accept the same latency the current startup path already incurs, bounding the risk by attempting the retry at most once per dialog open and only when `UserEmailAddress` is null; or (b) treat a non-blocking SMTP read as a separate, larger piece of work and file it. Option (a) is what AC6 as written requires; option (b) should be recorded as a potential follow-up, not folded in. + +--- + +## E. Remaining acceptance criteria + +### E1. AC7 — the leading `\\` store prefix + +The two producing reads are `UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs:294-295`: + +```csharp +Viewer.Inbox.Text = Current?.Inbox?.FolderPath ?? "Error Loading"; +Viewer.RootFolder.Text = Current?.RootFolder?.FolderPath ?? "Error Loading"; +``` + +`Current.Inbox` and `Current.RootFolder` are `Outlook.Folder?` (`UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs:167-171`) assigned from live COM at `StoreWrapper.cs:65` (`GetRootFolder()`) and `:74-76` (`GetDefaultFolder(olFolderInbox)`). `MAPIFolder.FolderPath` is Outlook's native `\\\` form; there is no transformation between the COM read and the label. + +**No dedicated trim helper exists in `UtilitiesCS`.** The three closest existing behaviours are: + +- `UtilitiesCS\OutlookObjects\Folder\FolderNavigator.cs:16-19` — `if (FolderPath.StartsWith(@"\\")) { FolderPath = FolderPath.Substring(2); }`, inside a navigation method, not reusable as a helper. +- `UtilitiesCS\EmailIntelligence\EmailParsingSorting\EmailFilerConfig.cs:258` — `folderPath.TrimStart('\\')` as the out-of-ancestor fallback of `GetStem`. +- `UtilitiesCS\OutlookObjects\Folder\ArchiveStemContract.cs` — `IsFullOutlookPath` (`:41-56`) and `TryMakeArchiveRelative` (`:106-145`). These are **root-relative** operations, not store-prefix trims; `TryMakeArchiveRelative` returns false for a path that is not under the supplied root and never passes the input through (`:129-141`), so it cannot be used as a plain display trim. + +Recommendation: add a small pure private static helper in the `StoreWrapperController` display partial (see F/G) rather than extending `ArchiveStemContract`, which is a shared filing-boundary contract with its own test suite (`UtilitiesCS.Test\OutlookObjects\Folder\ArchiveStemContractTests.cs`) and is consumed by the breadcrumb/EFC work items. Keeping the trim local respects the "do not broaden the shared trees" constraint. + +Note the existing assertion `StoreWrapperControllerTests`/`StoreWrapperViewerTests` do not pin the `\\` form for Inbox or Root Folder, so AC7 adds coverage rather than changing an existing expectation. + +### E2. AC8 — null `Current` + +**Assignment that can produce null:** `UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs:168-169` + +```csharp +var displayName = Viewer.DisplayName.SelectedValue?.ToString(); +Current = Model.Stores!.Find(store => store.DisplayName == displayName); +``` + +`List.Find` returns `null` when no element matches (for example when `SelectedValue` is null or when a store's `DisplayName` read failed and left it null at `StoreWrapper.cs:38`). `Current` is declared `public StoreWrapper Current { get; internal set; } = null!;` at `:89`, so the compiler does not flag the assignment. + +**Unguarded dereference:** `:288-291`, the first four statements of `PopulateWithCurrent` after the `InvokeRequired` marshal: + +```csharp +ArchiveOutlook = Current.ArchiveRoot; +ArchiveFS = Current.ArchiveFsRoot; +JunkEmail = Current.JunkCertain; +JunkPotential = Current.JunkPotential; +``` + +These use `Current.` (no `?.`), while the very next block at `:294-296` uses `Current?.` — an internal inconsistency inside one method. A null `Current` therefore throws `NullReferenceException` at `:288` before any placeholder can be rendered. `GetRelativeFsPath` at `:459-460` has the same unguarded `Current.ArchiveFsRoot` dereference and is called from `:298`. + +**Placeholder text that should render instead:** the existing literals already in the method — `"Error Loading"` for Inbox / Root Folder / User Email (`:294-296`), `"Please select an archive"` for Archive Outlook and Archive FS (`:297`, `:466`, `:473`), `"Please select a folder"` for Junk Email and Junk Potential (`:311-312`). Under AC6 the User Email literal becomes the new specific unavailability message; the other five are unchanged. + +**Existing test that must be updated (call this out explicitly in the plan).** `UtilitiesCS.Test\OutlookObjects\Store\StoreWrapperController_Tests.ButtonAndPopulate.cs:123-135`, `PopulateWithCurrent_NullCurrent_SetsErrorLoadingText`, currently asserts `act.Should().Throw();` — the test **name** describes the AC8 behaviour but the **assertion** codifies the bug. Per the General Code Change Policy (existing tests are part of the spec), this deliberate inversion must be named in the change description. + +### E3. The single-ampersand in `GetRelativeFsPath` + +`UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs:464`: + +```csharp +if (specialFolder.IsNullOrEmpty() & relativePath.IsNullOrEmpty()) +``` + +**What the short-circuit was for:** nothing operative. Both operands call the extension `UtilitiesCS\Extensions\StringExtensions.cs:15`, `public static bool IsNullOrEmpty(this string? str) => string.IsNullOrEmpty(str);`. An extension method on a null receiver does not throw — the receiver is passed as an ordinary argument — and `string.IsNullOrEmpty` accepts null. Neither operand has a side effect. + +**Can the non-short-circuit form throw?** No. Verified: `IsNullOrEmpty` is null-tolerant, both operands are pure, and the tuple is produced by `FsConverter(...)` at `:463` whose default implementation `FilePathHelperConverter.GetSerializablePath` (`UtilitiesCS\NewtonsoftHelpers\FilePathHelperConverter.cs:166-195`) always returns two non-null strings (`name` is `"Not Found"` when nothing matches, `:178`). + +So `&` versus `&&` is **behaviourally inert here** — it is a readability/consistency defect, not a latent crash. Fixing it is safe and costless; the change description should not claim it repairs a fault. (Secondary observation, recorded but out of scope: because `GetSerializablePath` never returns an empty `name`, the `:464` condition is effectively unreachable in production, so `:466` is dead. That is a separate finding, not part of AC1-AC8.) + +--- + +## F. Testability and test layout + +### F1. Injectable file-system and path seams (no temporary files permitted) + +The repository prohibits creating temporary files in tests (`.claude/rules/general-unit-test.md`, "External Dependencies"). **Adequate seams already exist**; no new seam is required for the serializer work. + +| Seam | Type | Location | Use | +|---|---|---|---| +| `ReadAllText` | `protected Func` | `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs:43-48` (base sibling `SmartSerializableBase.cs:25-30`) | supply JSON in memory | +| `DiskExists` | `protected Func` | `SmartSerializable.cs:50-55` (base `SmartSerializableBase.cs:32-37`) | simulate file present/absent | +| `CreateStreamWriter` | `protected Func` | `SmartSerializable.cs:467-472` (base `SmartSerializableBase.cs:444-449`) | capture a write into a `MemoryStream` instead of disk | +| `ShowDialog` | `protected Func` | `SmartSerializable.cs:57-64` (base `SmartSerializableBase.cs:39-46`) | suppress the `askUserOnError` dialog | +| `TimerFactory` | `protected Func` | `SmartSerializable.cs:547-548` (base `SmartSerializableBase.cs:529-530`) | fire the 3-second deferred write deterministically | +| `FilePathHelper.Exists()` | `public virtual bool` | `UtilitiesCS\HelperClasses\FileSystem\FilePathHelper.cs:151-166` | mockable via Moq | +| `IFileSystemFolderPaths.SpecialFolders` | interface | `UtilitiesCS\Interfaces\IGlobals\IFileSystemFolderPaths.cs` | supply a fake `AppData` root for path construction | + +All five `protected` seams are already exposed to tests by the established harness `SmartSerializableHarness : SmartSerializable` at `UtilitiesCS.Test\ReusableTypeClasses\SmartSerializable_Tests.cs:771-806` (setters at `:792-806`), with a matching harness in `SmartSerializableBase_Tests.cs`. The deterministic timer double is `UtilitiesCS.Test\TestHelpers\ManualFireTimerWrapper.cs:19` (`ManualFireTimerWrapper : ITimerWrapper`), used with a `FireElapsed()` call at `SmartSerializable_Tests.cs:596-613`. `StopPrivateTimer` helpers exist at `SmartSerializable_Tests.cs:759` and `SmartSerializableBase_Tests.cs:583` to avoid leaking real timers. + +For AC4 specifically: `SerializeThreadSafe` writes through `CreateStreamWriter` (`SmartSerializable.cs:484`), so a synchronous-flush test can assert the write occurred by injecting a `MemoryStream`-backed writer and checking a signal — the exact pattern already used at `SmartSerializable_Tests.cs:598-613`. No disk write, no temp file. + +For AC2 the logger is not injectable; see B1 option (a), the `MemoryAppender` pattern proven at `TaskMaster.Test\AppGlobals\ApplicationGlobalsStartupTimingTests.cs:202-218`. + +For AC1 the fresh-build path needs no filesystem at all: `TestableAppOlObjects` already injects a stub `ISmartSerializableNonTyped` and a canned fresh wrapper, and counts `BuildFreshStoresWrapperInvocationCount` (`TaskMaster.Test\AppGlobals\AppOlObjectsCoverageTests.cs:78-143`). + +**No new seam is required.** The one genuine gap is the AC2 logger, and the `MemoryAppender` route closes it without adding production surface. + +### F2. Correct test project and directory per production type, and existing coverage + +| Production type | File | Test project + directory | Existing test files | +|---|---|---|---| +| `SmartSerializable` | `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs` | `UtilitiesCS.Test\ReusableTypeClasses\` | `SmartSerializable_Tests.cs` (896 lines) | +| `SmartSerializableBase` | `...\SmartSerializableBase.cs` | `UtilitiesCS.Test\ReusableTypeClasses\` | `SmartSerializableBase_Tests.cs` (726 lines) | +| `SmartSerializableNonTyped` | `...\SmartSerializableNonTyped.cs` | `UtilitiesCS.Test\ReusableTypeClasses\` | `SmartSerializableNonTyped_Tests.cs` (149) | +| `SmartSerializableLoader` | `...\SmartSerializableLoader.cs` | `UtilitiesCS.Test\ReusableTypeClasses\` | `SmartSerializableLoader_Tests.cs` (180) | +| `NewSmartSerializableConfig` | `...\Config\NewSmartSerializableConfig.cs` | `UtilitiesCS.Test\ReusableTypeClasses\` | `NewSmartSerializableConfig_Tests.cs` (395) | +| `StoreWrapperController` | `UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs` | `UtilitiesCS.Test\OutlookObjects\Store\` | `StoreWrapperController_Tests.cs` (182), `.ButtonAndPopulate.cs` (396), `.ExcludeStore.cs` (164), `.Launch.cs` (480), `StoreWrapperControllerTests.cs` (216) | +| `StoreWrapper` | `UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs` | `UtilitiesCS.Test\OutlookObjects\Store\` | `StoreWrapperTests.cs` (285) — already covers `GetSmtpAddressFromStore` null and `COMException` paths at `:73-118` | +| `StoresWrapper` | `UtilitiesCS\OutlookObjects\Store\StoresWrapper.cs` | `UtilitiesCS.Test\OutlookObjects\Store\` | `StoresWrapperTests.cs` (431), `.StoreIdExclusion.cs` (222), `StoresWrapperDisableTests.cs` (369), `StoresWrapperRehookTests.cs` (94) | +| `StoreWrapperViewer` | `UtilitiesCS\OutlookObjects\Store\StoreWrapperViewer.cs` | `UtilitiesCS.Test\OutlookObjects\Store\` | `StoreWrapperViewerTests.cs` (167) | +| `AppOlObjects` (StoreLoading partial) | `TaskMaster\AppGlobals\AppOlObjects.StoreLoading.cs` | `TaskMaster.Test\AppGlobals\` | `AppOlObjectsCoverageTests.cs` (347), `AppOlObjectsTests.cs` (438) | +| `AppOlObjects` (JunkFolders partial) | `TaskMaster\AppGlobals\AppOlObjects.JunkFolders.cs` | `TaskMaster.Test\AppGlobals\` | `AppOlObjectsTests.cs` (`LoadJunkCertain` tests around `:140-178`) | +| `FilePathHelper` | `UtilitiesCS\HelperClasses\FileSystem\FilePathHelper.cs` | `UtilitiesCS.Test\HelperClasses\` | present in the project; not modified by this fix | + +**File-size constraint — a hard planning input.** The 500-line cap in `.claude/rules/general-code-change.md` is already exceeded by two files in scope and nearly exceeded by two more: + +| File | Lines | Consequence | +|---|---|---| +| `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs` | **613** | already over cap; AC2 adds lines. Either accept a documented pre-existing overage (no new violation *class*), or split the `#region Serialization` (`:440-561`) into a `partial` sibling. A split is a shared-tree refactor and conflicts with the "no broad refactor" constraint — recommend **accepting the pre-existing overage and adding the minimum lines**, and recording it. | +| `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializableBase.cs` | **545** | already over cap. **Do not edit** under this issue. | +| `UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs` | **478** | 22 lines of headroom. AC5, AC6, AC7 and AC8 all land here and will exceed 500. A **partial split is required**: move `PopulateWithCurrent`, `BindExcludeStoreCheckbox`, `GetRelativeFsPath` and the new display helpers into `UtilitiesCS\OutlookObjects\Store\StoreWrapperController.Display.cs`. The `AppOlObjects.*.cs` partials are the in-repo precedent. | +| `TaskMaster\AppGlobals\AppOlObjects.cs` | **493** | 7 lines of headroom. **Do not add to it**; the AC5 explicit interface implementation belongs in `AppOlObjects.JunkFolders.cs` (186 lines). | +| `TaskMaster\AppGlobals\AppOlObjects.StoreLoading.cs` | **75** | ample headroom for AC1. | +| `UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs` | **233** | ample headroom for AC6. | +| `UtilitiesCS.Test\OutlookObjects\Store\StoreWrapperController_Tests.Launch.cs` | **480** | near cap; new controller tests should go to a new partial file, not here. | +| `UtilitiesCS.Test\ReusableTypeClasses\SmartSerializable_Tests.cs` | **896** | already over cap; new serializer tests should go to a new file. | + +### F3. Non-SDK-style projects — every `.csproj` needing a hand-added `Compile Include` + +Verified: **every** project in the solution is non-SDK-style (``). No `Sdk=` attribute appears in any `.csproj`. A new `.cs` file is **not** picked up by a wildcard; it must be added by hand. + +| New file would live in | `.csproj` that needs the `Compile Include` entry | Existing entries to insert beside | +|---|---|---| +| `UtilitiesCS\OutlookObjects\Store\` (e.g. `StoreWrapperController.Display.cs`) | `UtilitiesCS\UtilitiesCS.csproj` | store-folder entries near `:920-1035`; interface entries at `:1024-1035` | +| `UtilitiesCS\Interfaces\IGlobals\` (e.g. `IJunkFolderSelectionSink.cs`) | `UtilitiesCS\UtilitiesCS.csproj` | `:1029-1030` (`IStoreDisableService.cs`, `IStoreRehookService.cs`) | +| `TaskMaster\AppGlobals\` (only if a new partial is added; not currently needed) | `TaskMaster\TaskMaster.csproj` | `:417-422` (`AppOlObjects.*.cs` block) | +| `UtilitiesCS.Test\OutlookObjects\Store\` (new controller/wrapper test partials) | `UtilitiesCS.Test\UtilitiesCS.Test.csproj` | `:373-396` and `:526-529` | +| `UtilitiesCS.Test\ReusableTypeClasses\` (new serializer tests) | `UtilitiesCS.Test\UtilitiesCS.Test.csproj` | `:466-470` | +| `TaskMaster.Test\AppGlobals\` (new AC1 tests, if not appended to existing files) | `TaskMaster.Test\TaskMaster.Test.csproj` | `:289-309` | + +Note also that `TaskMaster\TaskMaster.csproj` needs **no** edit if AC1 is implemented entirely inside the already-registered `AppOlObjects.StoreLoading.cs` (`:421`) and the AC5 implementation inside the already-registered `AppOlObjects.JunkFolders.cs` (`:420`). + +### F4. Mocking approach and mockability of the types in scope + +Required stack (CLAUDE.md, `CUT1`/`CUT2`): **MSTest** (`[TestClass]`/`[TestMethod]`), **Moq**, **FluentAssertions**. + +| Type | Mockable as-is? | Evidence | +|---|---|---| +| `IApplicationGlobals`, `IOlObjects`, `IStoreWrapperViewer`, `ISmartSerializableNonTyped` | Yes — interfaces, mocked throughout | `StoreWrapperController_Tests.ButtonAndPopulate.cs:213-226`; `AppOlObjectsCoverageTests.cs:37-47` | +| `StoreWrapper` | Constructible without COM: `new StoreWrapper(null)` | `StoreWrapperController_Tests.ButtonAndPopulate.cs:33`, `:50`, `:84`, `:141`, `:186` | +| `StoresWrapper` | `Mock` works (public parameterless ctor, non-sealed); `Init()` and `RewireAfterDeserializeAsync()` are `virtual` (`StoresWrapper.cs:37`, `:68`) | `StoreWrapperController_Tests.ButtonAndPopulate.cs:31` | +| `SmartSerializable.Serialize()` / `SerializeThreadSafe` | **Not virtual** (`SmartSerializable.cs:442`, `:474`) — Moq cannot intercept. Use the `CreateStreamWriter` seam instead. | `SmartSerializable_Tests.cs:598-613` | +| `StoreWrapperController` | Concrete, but every method under test is `internal`/`public` and `SelectFolder` is `internal virtual` (`:420`); `InternalsVisibleTo` is in effect for `UtilitiesCS.Test` | `StoreWrapperController_Tests.*` throughout | +| `StoreWrapperViewer` | Real WinForms viewer is constructible in tests without creating a window handle; `InvokeRequired` then returns false | `StoreWrapperController_Tests.ButtonAndPopulate.cs:170-177` (with the documented rationale) | +| `AppOlObjects` | Subclassable — `BuildFreshStoresWrapper` and `AwaitStoreRewireAsync` are `protected internal virtual` (`AppOlObjects.StoreLoading.cs:27`, `:32`) | `TestableAppOlObjects` in `TaskMaster.Test\AppGlobals\AppOlObjectsCoverageTests.cs` | +| `Outlook.Folder`, `Outlook.NameSpace`, `Outlook.ExchangeUser` | Mockable as COM interfaces with Moq | `UtilitiesCS.Test\OutlookObjects\Store\StoreWrapperTests.cs:120-134` (`CreateRootFolderWithPrimarySmtpAddress`) | + +Two recorded hazards for the test author, both already documented in-repo: `Mock` over `Task`-bearing interfaces can throw `TypeInitializationException` in this test binary because `System.Threading.Tasks.Extensions 4.2.0.1` is absent from the test output (`StoreWrapperController_Tests.ButtonAndPopulate.cs:170-175`); and `FolderMinimalWrapper`/`FilePathHelper` comparisons in `PairwiseEquals` (`StoreWrapperController.cs:266-277`) are reference-equality, which the mirroring test at `:167-204` depends on. + +**No new mocking seam is required** for AC1, AC3, AC5, AC6, AC7 or AC8. AC2 needs the logger route from B1; AC4 needs the `CreateStreamWriter` route from F1. + +--- + +## Numeric Derivation Evidence + +The acceptance criteria AC1-AC8 contain no numeric assertions. This section supports the two enumerations stated above (A1 and A3) so a reviewer can verify them independently. + +### Claim N1 — the number of members named exactly `Deserialize` declared on `SmartSerializableBase` + +- **Complete Family:** all method declarations whose identifier is exactly `Deserialize` (any arity, any generic arity, any accessibility) declared directly in `class SmartSerializableBase`. +- **Exhaustive Search Scope:** the entire file `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializableBase.cs`, lines 1-545 (whole class body; the class is not `partial` — no `partial` modifier appears at `:19`, and no other file declares `SmartSerializableBase`). +- **Inclusion Rules:** identifier exactly `Deserialize`; declaration site (not a call site); any accessibility including `protected`. +- **Exclusion Rules:** `TryDeserialize`, `DeserializeAsync`, `DeserializeJson`, `DeserializeObject`; commented-out code; members of nested types. +- **Primary Search Strategy:** full sequential read of the file (lines 1-545) with manual identification of each declaration. +- **Primary Member Set:** `{ :126 Deserialize(string,string), :132 Deserialize(string,string,bool), :140 Deserialize(string,string,bool,JsonSerializerSettings), :167 Deserialize(SmartSerializable), :190 Deserialize(SmartSerializable,bool,Func?), :247 protected Deserialize(FilePathHelper,bool,JsonSerializerSettings) }` +- **Primary Count:** 6 +- **Cross-check Search Strategy or Query Expression:** ripgrep over the same file with `^\s*(public|protected|private|internal)[^;=]*\bDeserialize\w*\s*(<[^>]*>)?\s*\(`, which matches every accessibility-modified declaration line whose identifier begins with `Deserialize` at a word boundary (thereby also surfacing the `Async`/`Json`/`Object` variants for exclusion, and excluding `TryDeserialize` because no word boundary precedes `Deserialize` there). +- **Cross-check Member Set:** raw matches `{ :126, :132, :140, :167, :190, :247, :305, :314, :324, :335, :362, :382 }`; after applying the exclusion rules (removing `:305`, `:314`, `:324` `DeserializeAsync`; `:335`, `:382` `DeserializeJson`; `:362` `DeserializeObject`) the set is `{ :126, :132, :140, :167, :190, :247 }`. +- **Cross-check Count:** 6 +- **Member-set Comparison:** the normalized primary set `{126,132,140,167,190,247}` and the normalized cross-check set `{126,132,140,167,190,247}` are identical. No member appears in one and not the other. Count agreement: 6 = 6. + +### Claim N2 — the number of members named exactly `Deserialize` declared on `SmartSerializable` (excluding the nested `Static` class) + +- **Complete Family:** all method declarations whose identifier is exactly `Deserialize` declared directly in `class SmartSerializable`, excluding the nested `public static class Static`. +- **Exhaustive Search Scope:** the entire file `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs`, lines 1-613. The type is not `partial` (`:23`) and no other file declares it. +- **Inclusion Rules / Exclusion Rules:** as N1, plus explicit exclusion of the seven forwarders inside `Static` (`:569`, `:572`, `:575`, `:582`, `:588`, `:592`, `:599`, region delimited by `:565` and `:609`). +- **Primary Search Strategy:** full sequential read of lines 1-613. +- **Primary Member Set:** `{ :177 Deserialize(string,string), :182 Deserialize(string,string,bool), :189 Deserialize(string,string,bool,JsonSerializerSettings), :214 Deserialize(SmartSerializable), :236 Deserialize(ISmartSerializable), :257 Deserialize(SmartSerializable,bool,Func?), :312 protected Deserialize(FilePathHelper,bool,JsonSerializerSettings) }` +- **Primary Count:** 7 +- **Cross-check Search Strategy or Query Expression:** ripgrep over the same file with the same declaration-line regex as N1, then partition by the `#region Static` boundary at `:563-611`. +- **Cross-check Member Set:** raw matches `{ :177, :182, :189, :214, :236, :257, :312, :364, :372, :378, :388, :410, :432, :569, :572, :575, :582, :588, :592, :599 }`; removing the `Async`/`Json`/`Object` variants (`:364, :372, :378, :388, :410, :432`) and the seven `Static` forwarders (`:569, :572, :575, :582, :588, :592, :599`) leaves `{ :177, :182, :189, :214, :236, :257, :312 }`. +- **Cross-check Count:** 7 +- **Member-set Comparison:** the normalized primary set `{177,182,189,214,236,257,312}` and the normalized cross-check set `{177,182,189,214,236,257,312}` are identical. Count agreement: 7 = 7. Note the primary read additionally established the semantic property that only `:214` and `:236` skip the loader-config copy on the null path — a property the regex alone cannot establish, which is why the regex is used only as a completeness cross-check. + +### Claim N3 — the number of occurrences of the literal `"Error Loading"` in production source + +- **Complete Family:** every occurrence of the exact string literal `Error Loading` in any `.cs` file in the repository. +- **Exhaustive Search Scope:** all `.cs` files under the worktree root. +- **Inclusion Rules:** exact case-sensitive substring `Error Loading` inside a C# source file. **Exclusion Rules:** Markdown, XML, coverage artefacts, log files. +- **Primary Search Strategy:** ripgrep for the alternation `Error Loading|Please select an archive|Please select a folder` restricted to `--type cs`, then filtering to the first alternative. +- **Primary Member Set:** `{ StoreWrapperController.cs:294, StoreWrapperController.cs:295, StoreWrapperController.cs:296 }` +- **Primary Count:** 3 +- **Cross-check Search Strategy or Query Expression:** independent full sequential read of `UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs` (lines 1-478) plus inspection of the two placeholder-asserting test files `StoreWrapperViewerTests.cs` and `StoreWrapperController_Tests.ButtonAndPopulate.cs`, which reference the other two placeholder literals but never `Error Loading`. +- **Cross-check Member Set:** `{ StoreWrapperController.cs:294, :295, :296 }`; zero occurrences in any test file (`StoreWrapperViewerTests.cs:77-80` and `StoreWrapperController_Tests.ButtonAndPopulate.cs:89`, `:102` reference only the archive/folder placeholders). +- **Cross-check Count:** 3 +- **Member-set Comparison:** the two normalized member sets are identical. Count agreement: 3 = 3. Consequence: replacing the User Email placeholder under AC6 touches exactly one line (`:296`) and breaks no existing assertion. + +--- + +## G. Write set + +### G1. Consolidated create/modify list + +**Production — modify** + +- `TaskMaster\AppGlobals\AppOlObjects.StoreLoading.cs` — AC1: apply the resolved loader configuration to the freshly built wrapper. +- `TaskMaster\AppGlobals\AppOlObjects.JunkFolders.cs` — AC5: explicit implementation of the new typed sink interface. +- `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs` — AC2 (error log on empty/null `FilePath`) and AC4 (guarded synchronous flush entry point). Already 613 lines; add the minimum. +- `UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs` — AC6: fallback chain inside `GetSmtpAddressFromStore`, plus a retry-capable entry point and a captured failure reason. +- `UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs` — AC4 (flush on Save), AC5 (typed seam call), AC8 (null-`Current` guard). Content must be moved out to the new display partial to stay under 500 lines. + +**Production — create** + +- `UtilitiesCS\Interfaces\IGlobals\IJunkFolderSelectionSink.cs` — AC5 typed seam. +- `UtilitiesCS\OutlookObjects\Store\StoreWrapperController.Display.cs` — AC6/AC7/AC8 display logic relocated from `StoreWrapperController.cs`, plus the `\\`-trim helper. + +**Tests — modify** + +- `UtilitiesCS.Test\OutlookObjects\Store\StoreWrapperController_Tests.ButtonAndPopulate.cs` — AC8: invert `PopulateWithCurrent_NullCurrent_SetsErrorLoadingText` (`:123-135`) from asserting the throw to asserting the placeholders. +- `UtilitiesCS.Test\OutlookObjects\Store\StoreWrapperControllerTests.cs` — AC5: retarget the reflection-era `NoApplyOlObjects` / `RecordingOlObjects` doubles (`:196-214`) to the typed sink. +- `UtilitiesCS.Test\OutlookObjects\Store\StoreWrapperTests.cs` — AC6: SMTP fallback ordering cases. +- `TaskMaster.Test\AppGlobals\AppOlObjectsCoverageTests.cs` — AC1: assert the fresh wrapper adopts the loader's `Config.Disk.FilePath`. + +**Tests — create** + +- `UtilitiesCS.Test\ReusableTypeClasses\SmartSerializableSerializeGuardTests.cs` — AC2 and AC4 (`SmartSerializable_Tests.cs` is already 896 lines). +- `UtilitiesCS.Test\OutlookObjects\Store\StoreWrapperController_Tests.Display.cs` — AC6/AC7/AC8 (`.Launch.cs` is already 480 lines). + +**Non-SDK-style `Compile Include` carriers — modify** + +- `UtilitiesCS\UtilitiesCS.csproj` — entries for `Interfaces\IGlobals\IJunkFolderSelectionSink.cs` and `OutlookObjects\Store\StoreWrapperController.Display.cs`. +- `UtilitiesCS.Test\UtilitiesCS.Test.csproj` — entries for `ReusableTypeClasses\SmartSerializableSerializeGuardTests.cs` and `OutlookObjects\Store\StoreWrapperController_Tests.Display.cs`. +- `TaskMaster.Test\TaskMaster.Test.csproj` — entry only if a new test file is created there rather than appending to `AppOlObjectsCoverageTests.cs`. +- `TaskMaster\TaskMaster.csproj` — **no entry needed**, because AC1 and AC5 land in files already registered at `:420-421`. + +**Feature documentation — modify** + +- `docs\features\active\2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797\issue.md` — AC check-off only. + +### G2. Files whose extension a downstream extractor may drop + +One file in the write set carries an extension in the affected set (`.resx`, `.config`, `.props`, `.targets`) — and it is **read-only** for this change, listed here solely because the research references it: + +- `UtilitiesCS\IntelligenceResources.resx` — spelled out in words: UtilitiesCS backslash IntelligenceResources dot **r-e-s-x**. It is the source of the `StoresWrapper.json` / `AppData` disk configuration described in A4. **It is not in the write set.** No modification to it is proposed, because the resource already carries the correct path; the defect is that the path is discarded downstream. + +Three `.csproj` files **are** in the write set (`UtilitiesCS\UtilitiesCS.csproj`, `UtilitiesCS.Test\UtilitiesCS.Test.csproj`, and conditionally `TaskMaster.Test\TaskMaster.Test.csproj`). Spelled out in words, each ends in dot **c-s-p-r-o-j**. A downstream extractor that strips the extension would produce paths a human must repair by appending `.csproj`. + +No `.config`, `.props` or `.targets` file is created or modified by this change. + +### G3. Solution file and repository-root build property files + +The fix does **not** require touching the solution file or any repository-root build property file. Stated in plain prose without backticks: the Visual Studio solution file named TaskMaster dot sln at the repository root is not modified, because every project that receives a new source file already exists in the solution and only its own project file changes. There is no Directory dot Build dot props file and no Directory dot Build dot targets file anywhere in this repository, so no such file is created, modified, or otherwise involved. No file at the repository root is written by this change. + +### Constraints confirmed + +- No edit is proposed under the dot claude tree, the dot codex tree, or the dot agents tree. +- No edit is proposed to either published JSON file under the config directory. +- No edit is proposed to any GitHub workflow file. +- The footprint stays inside: the serializer under the reusable type classes tree (one file, `SmartSerializable.cs`, minimum lines); the Outlook store wrapper and its controller; and the store-loading and junk-folder partials in the TaskMaster AppGlobals, plus one new narrow interface file in the already-established `UtilitiesCS\Interfaces\IGlobals` folder. No broad refactor of the reusable type classes tree is proposed; in particular `SmartSerializableBase.cs` (545 lines, over cap) is not edited at all. + +--- + +## Testing implications (strategy only, no test code) + +Per `.claude/rules/general-unit-test.md` and CLAUDE.md `CUT1`/`CUT2`: MSTest, Moq, FluentAssertions; Arrange-Act-Assert; no temporary files; no `Thread.Sleep`/`Task.Delay`/wall-clock waits. + +- **AC1** — extend the existing `TestableAppOlObjects` harness: given a `SmartSerializableLoader` whose `Config.Disk.FilePath` is a fake `AppData` path and a `Deserialize` stub returning null, assert `sut.StoresWrapper.Config.Disk.FilePath` equals the loader's path after `LoadStoresAsync()`. Negative case: config key absent -> fresh build, path remains empty, AC2's error is logged on the subsequent save. No filesystem access. +- **AC2** — `MemoryAppender` attached to `typeof(SmartSerializable).FullName`; call `Serialize()` with `Config.Disk.FilePath` set to `""` and, separately, to `null`; assert one `Error`-level event each and assert no timer was armed. Detach in a `finally`. +- **AC3** — manual verification only (restart of Outlook); record in the manual-verification evidence, not as an automated test. +- **AC4** — inject `ManualFireTimerWrapper` via `TimerFactory` and a `MemoryStream`-backed `CreateStreamWriter`; assert the explicit-save path writes **without** firing the timer, and that the pre-existing deferred path still requires a timer fire. Both assertions in the same file establish that other callers' behaviour is unchanged. +- **AC5** — a test double implementing the new sink interface records the two arguments and their order; a second double implementing only `IOlObjects` asserts the loud-failure branch. `PersistJunkFolderSelections_WhenApplyMethodIsMissing_DoesNotThrow` (`StoreWrapperControllerTests.cs:124-137`) is retargeted, not deleted. +- **AC6** — table-driven over the `Mock` chain already built by `CreateRootFolderWithPrimarySmtpAddress` (`StoreWrapperTests.cs:120-134`): PrimarySmtpAddress present; PrimarySmtpAddress throws and `Address` contains `@`; both fail and `DisplayName` contains `@`; all fail -> specific message containing the exception reason. Plus a controller test asserting the retry runs on a second `PopulateWithCurrent` when `UserEmailAddress` is null and does **not** run when it is already populated. +- **AC7** — pure-function tests over the new trim helper (leading `\\` present, absent, single `\`, empty, null), plus one `PopulateWithCurrent` test asserting the rendered `Inbox`/`RootFolder` label text. +- **AC8** — the inverted existing test plus a `GetRelativeFsPath` null-`Current` test, both asserting placeholders rather than a throw. + +Coverage: every changed member is reachable through an existing seam, so the `>= 90%` new-code target and the no-regression-on-changed-lines rule are attainable without an `ExcludeFromCodeCoverage` attribute anywhere in this change. diff --git a/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md new file mode 100644 index 000000000..8e10d4a1a --- /dev/null +++ b/docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/spec.md @@ -0,0 +1,666 @@ +# Bug Specification: Folder Settings never persist; User Email shows "Error Loading" (Issue #797) + +- Issue: #797 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/797 +- Branch: bug/folder-settings-never-persist-797 +- Base commit: c431dc3297e864041d829e8d79b348960b8d8019 +- Work Mode: full-bug +- Date: 2026-09-06 +- Requirements source: issue.md in this feature folder (AC1-AC8, settled with the maintainer on 2026-09-06) +- Research source: research/research-folder-settings-persistence.md in this feature folder + +> **Authoritative acceptance-criteria source.** Work Mode is full-bug, so this file is the single +> authoritative acceptance-criteria source per the acceptance-criteria-tracking skill. No +> user-story.md is produced for this work item, and no second checkbox list exists anywhere in this +> feature folder other than the issue.md copy from which the criteria below were transcribed verbatim. + +> **Formatting is deliberate — do not "fix" it.** A downstream scheduler harvests backtick-delimited +> path tokens from this document to derive the change footprint for a parallel run against three +> concurrent sibling work items. Every file this change creates or modifies is therefore backticked +> exactly once, inside the `## Write Set` section, and nowhere else. All other file and line +> citations in this document are written as plain prose without backticks, on purpose. Adding +> backticks to a citation elsewhere would inject a false write claim and needlessly serialize the +> run; removing a backtick inside the Write Set would drop a real file from the footprint. +> +> One unavoidable exception: the `## Acceptance Criteria` block below is reproduced verbatim from +> issue.md, and the maintainer-authored text contains inline code spans. Those spans are quoted +> runtime values and C# member names (a runtime file name under the user's local AppData directory, +> a percent-prefixed environment path, a generic method signature, and an escaped backslash pair). +> None of them is a repository-relative path and none is a write claim. They are preserved because +> verbatim reproduction of the criteria takes precedence. + +--- + +## Summary + +Values chosen in Settings -> Folder Settings (Archive Root Outlook, Archive Root File System, Junk +Potential, Junk Email) survive only the current Outlook session and are lost on restart. The settings +file StoresWrapper.json has never been created on the reporting machine, and the save path silently +does nothing when no file path is configured, so the defect emits no diagnostic signal. In the same +dialog, User Email renders the generic placeholder "Error Loading" because the Exchange SMTP lookup +throws a COM exception that is caught, converted to null, and never retried. + +Two independent root causes produce the reported symptoms. They are kept separately traceable through +this document: root cause 1 is a bootstrap gap between the loader and the serializer; root cause 2 is +an unretried COM failure in the SMTP lookup. AC1 through AC5 derive from root cause 1; AC6 derives +from root cause 2; AC7 and AC8 are adjacent defects in the same rendering method. + +Severity is High. The dialog cannot persist any per-store setting on a machine where the file has +never been created, which is every fresh install, and the archive root and junk folder settings it +manages feed the filing and junk-mail workflows. + +--- + +## Root Cause Analysis + +### Root cause 1 — bootstrap gap between the loader and the serializer + +When the settings file is absent the deserializer returns null and the caller builds a fresh wrapper +that never adopts the loader's disk configuration, so the wrapper carries an empty file path and the +serializer's guard silently returns without writing and without logging. + +Verified trace at the base commit (line numbers re-derived by the research against this worktree): + +1. TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs lines 39-44 resolves the loader from + IntelRes.Config by the key "StoresWrapper" and calls the non-typed deserialize forwarder. +2. The live path binds SmartSerializable<T>.Deserialize<U>(loader) in + UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs lines 214-234, not the + SmartSerializableBase overload cited in issue.md. The defect shape is identical in both classes, + but the edit target and regression surface must be read off SmartSerializable.cs. +3. DeserializeJson at SmartSerializable.cs lines 388-394 returns null because DiskExists is false. +4. The loader-configuration copy at SmartSerializable.cs lines 222-225 is guarded by + `if (instance is not null)`, so on the null path the loader's disk configuration is discarded. +5. AppOlObjects.StoreLoading.cs line 64 calls BuildFreshStoresWrapper, which constructs a wrapper + whose Config.Disk.FilePath is the FilePathHelper default empty string + (UtilitiesCS/HelperClasses/FileSystem/FilePathHelper.cs line 71). +6. StoreWrapperController.SaveChanges line 356 calls Model.Serialize(). The guard at + SmartSerializable.cs line 444 is `Config.Disk.FilePath != ""`, so with an empty path the method + returns without writing and without logging. +7. Because the file is never written, every subsequent Outlook start takes the same null path. + In-session persistence works only because the values live in the in-memory store wrapper. + +Runtime confirmation (read-only; the log lives outside the repository). The pair of lines below +recurs once per Outlook start at 17:29:59, 19:09:20 and 19:26:35 on 2026-09-06: + +```text +[VSTA_Main] WARN TaskMaster.AppOlObjects - StoresWrapper config deserialized to null; rebuilding from live stores. +[VSTA_Main] ERROR UtilitiesCS.OutlookObjects.Store.StoreWrapper - Error retrieving PrimarySmtpAddress from secondary inbox. The operation failed. +``` + +Two properties of the loader are established by the research and are load-bearing for the fix. First, +the loader already in scope at AppOlObjects.StoreLoading.cs line 39 already carries the correct, +resource-derived path; nothing new must be constructed. Second, the sibling three-argument overload +(SmartSerializable.cs lines 257-310) copies the loader configuration unconditionally, which is why +RecentFolders, which uses that overload, has a file on disk while StoresWrapper does not. + +Downstream consequences of root cause 1 that carry their own acceptance criteria: + +- The empty-path guard is silent (AC2). It also compares only against the empty string, so a null + FilePath passes the guard and reaches the write path; FilePathHelper can assign a null + \_filePath in its property-changed handler, so the null case is genuinely reachable. +- The write is deferred by a three-second single-shot timer (AC4). The timer callback runs on a + ThreadPool background thread, which is not joined at process exit, so a pending write is lost with + no log entry when Outlook tears down the AppDomain. +- Junk folder selections are persisted twice, by a per-store JSON path and by .NET user settings, and + the two mechanisms resolve relative paths against different roots and have different scope (AC5). + Because of root cause 1 the JSON side currently writes nothing at all while the settings side + writes successfully, so the two stores of truth are already divergent on every affected machine. + +Evidence-strength calibration on AC5: the research verified that the reflection target +ApplyJunkFolderSelections does exist with an exactly matching signature at +TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs lines 36-45, so the reflection lookup succeeds in +production and the warn-and-return branch is reached only by test doubles. The issue text's +"silently returns with only a warning when the method is not found" describes a real but +production-unreachable branch. The substantive AC5 defect is the double persistence itself plus the +untyped, rename-fragile binding. + +### Root cause 2 — unretried COM failure in the Exchange SMTP lookup + +The Exchange SMTP lookup throws a COMException that is caught, converted to null, and rendered as a +generic placeholder, and the lookup is never retried. + +Verified trace: + +1. UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs line 83 assigns UserEmailAddress from + GetSmtpAddressFromStore, once per Init. +2. GetSmtpAddressFromStore (StoreWrapper.cs lines 179-217) walks RootFolder -> Session -> CurrentUser + (line 184) -> AddressEntry (line 190) -> GetExchangeUser (line 196) -> PrimarySmtpAddress + (line 202). A COMException anywhere in that chain is caught by a single outer catch at line 209, + logged, and converted to `return null` at line 215. The observed failure threw at line 184, + matching the log's reported line number. +3. UserEmailAddress carries JsonIgnore (StoreWrapper.cs lines 173-174), so a successful lookup is not + cached across restarts. +4. StoreWrapperController.cs line 296 renders the null as the literal "Error Loading". That literal + occurs at exactly three sites, all in StoreWrapperController.cs (lines 294, 295 and 296), and + nowhere else in any C# file in the repository; no existing test asserts it. + +The Outlook-side cause of the COM failure is not determinable from the log, and this specification +does not claim to identify it. The fix addresses the absence of a fallback and the absence of a +retry, not the underlying Outlook condition. + +Correction to one attribution in issue.md, recorded because it changes scope. issue.md states that +the same session's ThreadMonitor captured the UI thread inside the Exchange primary-SMTP getter, and +infers that a second caller of this chain blocks on it. The research verified the captured stacks and +found they belong to RecipientStatic.GetRecipientAddress in the QuickFiler recipient-resolution path, +not to StoreWrapper.GetSmtpAddressFromStore. The blocking hazard for the Exchange primary-SMTP getter +is real and repeatedly evidenced across multiple timestamps in the same log, but it is attributable +to a different, out-of-scope caller. That caller is not fixed under this issue. + +### Adjacent defects in the same rendering method + +- AC7: the leading double-backslash store prefix on the Inbox and Root Folder labels is Outlook's + native MAPIFolder.FolderPath read directly at StoreWrapperController.cs lines 294-295. There is no + transformation between the COM read and the label. This is cosmetic, not a fault. +- AC8: StoreWrapperController.cs line 169 assigns Current from List.Find, which returns null when no + store matches. PopulateWithCurrent then dereferences Current without a null-conditional operator at + lines 288-291, while the very next block at lines 294-296 uses the null-conditional form — an + inconsistency inside a single method. A null Current therefore throws NullReferenceException before + any placeholder can render. GetRelativeFsPath at lines 459-460 has the same unguarded dereference + and is called from line 298. + +--- + +## Scope and Non-Goals + +### In scope + +The footprint stays inside: the serializer under the reusable type classes tree; the Outlook store +wrapper and its controller; the store-loading and junk-folder partials in the TaskMaster application +globals; one new interface file in the already-established UtilitiesCS interfaces folder; and the +corresponding tests and their non-SDK-style project compile entries. + +One additional in-scope cleanup, not covered by any acceptance criterion: the single-ampersand +operator at StoreWrapperController.cs line 464 is changed to the short-circuit form. The research +verified that both operands are pure and null-tolerant, so this is behaviourally inert. It is a +readability and consistency fix only, and the change description must not claim it repairs a fault. +It lands in a file already in the Write Set because GetRelativeFsPath moves to the new display +partial. + +### Explicit scope constraints + +- Nothing under the dot-claude, dot-codex or dot-agents trees is edited. +- Neither published JSON file under the config directory is edited. +- No GitHub workflow file is edited. + +### Non-Goals + +> The file names in this section are deliberately written without backticks. They are out-of-scope +> paths, and backticking them would register them as write claims with the footprint extractor. + +1. **Pre-existing 500-line cap violation in the serializer is not resolved.** + UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs is already 613 lines + against the 500-line cap in the general code change policy, before any change here. This change + adds a small number of lines to it and does not split it. Rationale: splitting a shared + reusable-type-classes file during a parallel run would create merge contention with concurrently + running sibling work items, and the split is a separable concern that should be raised on its own + rather than folded into a bug fix. Stated plainly: this change does not resolve that pre-existing + violation, and it does not introduce a new violation class. +2. **The shared deserialize overload is not changed.** Its null return on the file-absent path is a + load-bearing fail-soft contract, documented in-code and relied upon by a second production caller + in TaskMaster/AppGlobals/AppAutoFileObjects.FolderPredictorLoad.cs. See D1. +3. **SmartSerializableBase.cs is not edited at all.** It is 545 lines, already over the cap, and the + live StoresWrapper path does not enter it. +4. **The IOlObjects interface is not extended.** See D2. +5. **No VSTO add-in lifecycle file is modified.** See D3. +6. **No resource file is modified.** See D7. +7. **The QuickFiler recipient-resolution blocking hazard is not fixed** under this issue. It is a + different caller of the same Outlook getter and is out of scope. +8. **A genuinely non-blocking Outlook COM property read is not delivered.** The research verified + that no existing seam in UtilitiesCS provides one: the repository's only timeout primitive + dispatches work to ThreadPool (MTA) threads, and an Outlook interop object is STA-apartment-bound, + so the call marshals back to the STA and the STA still blocks while the caller merely abandons the + wait. AC6 is therefore satisfied with a synchronous retry on the UI thread, attempted at most once + per dialog open and only when the address is null. A non-blocking read is recorded here as a + potential follow-up work item, not folded in. +9. **The dead-branch observation at StoreWrapperController.cs line 466 is not addressed.** The + research recorded, as a secondary observation, that the path-name producer never returns an empty + name, making that branch effectively unreachable. That is a separate finding outside AC1-AC8. +10. **AC3 is not automated.** It is manual verification by Outlook restart; see the Verification + section. + +--- + +## Write Set + +Every file this change creates or modifies appears below as a concrete repository-relative path +inside backticks. This is the only section of this document containing backticked paths, except for +the verbatim acceptance-criteria block noted in the header blockquote. + +### Production — modify + +- `TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs` +- `TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs` +- `UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs` +- `UtilitiesCS/OutlookObjects/Store/StoreWrapper.cs` +- `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs` + +### Production — create + +- `UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs` +- `UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs` + +### Tests — modify + +- `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs` +- `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperControllerTests.cs` +- `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperTests.cs` +- `TaskMaster.Test/AppGlobals/AppOlObjectsCoverageTests.cs` + +### Tests — create + +- `UtilitiesCS.Test/ReusableTypeClasses/SmartSerializableSerializeGuardTests.cs` +- `UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.Display.cs` + +### Project compile-entry carriers — modify + +Every project in this solution is non-SDK-style, verified by the research: each project file opens +with a ToolsVersion attribute and the 2003 MSBuild namespace and closes with an import of the C# +targets, and no project file carries an Sdk attribute. A newly created C# file is therefore not +picked up by a wildcard and must be registered by a hand-added compile entry. + +- `UtilitiesCS/UtilitiesCS.csproj` +- `UtilitiesCS.Test/UtilitiesCS.Test.csproj` +- `TaskMaster.Test/TaskMaster.Test.csproj` + +The third entry is retained as a write claim to keep the parallel run schedule-safe. The research +records it as conditional: it is required only if the AC1 tests are placed in a new file under the +TaskMaster test project's AppGlobals directory rather than appended to the already-registered +AppOlObjectsCoverageTests.cs. Claiming it unconditionally is the conservative choice; if the +implementation appends to the existing file, this project file may end the change unmodified. + +### Requirements document — modify + +- `docs/features/active/2026-09-06-folder-settings-never-persist-and-user-email-error-loading-797/issue.md` + +This file is modified for acceptance-criteria check-off only, mirroring the check-offs made in this +specification. No criterion text is altered. This specification itself and the timestamp-named +research and evidence artifacts in this feature folder are excluded from the Write Set by convention. + +### Files not written, stated in plain prose + +The TaskMaster production project file, TaskMaster.csproj, needs no compile-entry change, because the +two files that receive the AC1 and AC5 production edits — AppOlObjects.StoreLoading.cs and +AppOlObjects.JunkFolders.cs — are already registered in it. The Visual Studio solution file, +TaskMaster.sln at the repository root, is not modified, because every project that receives a new +source file already exists in the solution and only its own project file changes. No repository-root +build property file is modified. Directory.Build.props and Directory.Build.targets both exist at the +repository root as of the base commit; the props file sets a single System.Reactive packages.config +suppression property recorded under issue #730. Neither file participates in this change and neither +is written. No file at the repository root is written by this change. + +### Extension note + +No file with an extension of resx, config, props or targets is created or modified by this change. +This is consistent with D7: the resource entry that defines the settings file name and its AppData +special folder already carries the correct values, so IntelligenceResources.resx — spelled out, the +UtilitiesCS resource file ending in dot r-e-s-x — is read-only for this work item and is deliberately +absent from the Write Set. The research reached the same conclusion independently, so there is no +disagreement to report. + +Three project files are in the Write Set. Spelled out in words in case a downstream extractor drops +the extension, each ends in dot c-s-p-r-o-j: the UtilitiesCS project file, the UtilitiesCS test +project file, and the TaskMaster test project file, at the paths backticked above. + +--- + +## Design and Approach + +The decisions below were settled by the orchestrator against verified code and are recorded as the +chosen approach. They are not reopened by this specification. + +### D1 — AC1 is fixed at the call site, not in the shared serializer + +AC1 is fixed in TaskMaster/AppGlobals/AppOlObjects.StoreLoading.cs alone. The fresh-build branch +discards the loader that is already in scope at that call site; the fix applies the loader's +configuration to the freshly built wrapper there, after BuildFreshStoresWrapper returns. + +The shared deserialize overload is not changed, for two reasons. + +1. Its null return is a load-bearing fail-soft contract. A second production caller, the folder + predictor load path in TaskMaster/AppGlobals/AppAutoFileObjects.FolderPredictorLoad.cs, documents + in its own in-code comment that a null return when the dedicated file is absent is intentional and + that the accessor then falls back to a flat model. Returning a constructed instance would silently + disable that fallback. +2. The shared reusable-type-classes tree is touched concurrently by sibling work items in this run. + +A supporting mechanical fact: on the file-absent path there is no instance to copy onto, because the +JSON reader returns null. "Adopt the loader's disk configuration" is therefore not expressible as a +copy inside that overload without constructing an instance and changing the method's contract. + +The seam required already exists and is already exercised: BuildFreshStoresWrapper is protected +internal virtual and is overridden by the TestableAppOlObjects harness in the TaskMaster test +project. LoadStoresAsync already holds the loader in scope. + +The branch where the configuration key is not found has no loader and must remain a fresh build with +an empty path. AC2's new error log makes that case visible rather than silent. + +Rejected alternative, recorded so it is not retried: switching the call site to the three-argument +askUserOnError overload would fix AC1 with one call-site change, but that overload can raise a modal +dialog during VSTO startup and constructs the wrapper through a parameterless CreateEmpty rather than +through BuildFreshStoresWrapper, leaving Globals null so that the store-filter call would throw. + +### D2 — AC5 uses a new dedicated interface, not an extension of IOlObjects + +A new interface is created at UtilitiesCS/Interfaces/IGlobals/IJunkFolderSelectionSink.cs with a +single member accepting the junk-certain and junk-potential relative paths. It is implemented by the +existing TaskMaster globals partial TaskMaster/AppGlobals/AppOlObjects.JunkFolders.cs, where the +matching method already lives. Explicit interface implementation is preferred so the public surface +of the globals type does not widen; the research verified that an implicit implementation would +require promoting the existing internal method to public. + +The controller replaces its reflection lookup with a typed cast to that interface and logs an error, +not a warning, when the cast fails. The System.Reflection using directive in the controller may then +be removed if nothing else in the file uses it. + +The member is deliberately not added to the existing IOlObjects interface, because that would force +edits to test stubs owned by a concurrently running sibling work item. The research enumerated the +affected stubs: an IOlObjects stub in the UtilitiesCS store controller tests and three +IApplicationGlobals stubs in the QuickFiler test project. + +Project reference direction is one-way, from TaskMaster to UtilitiesCS, and is preserved: the +interface is declared in UtilitiesCS and implemented in TaskMaster, so UtilitiesCS gains no reference +to TaskMaster. This is the same shape as the existing store disable and store rehook service +interfaces already in that folder. + +AC5 also permits removing the double-persistence path entirely. That is the larger behavioural +change — the settings values are read back by the globals junk-folder accessors — so the typed seam +plus a loud failure is the chosen reading. The divergence itself is mitigated because the JSON +mechanism will begin writing once AC1 lands, and the loud failure removes the silent path. + +### D3 — AC4 is a synchronous flush on the explicit Save path + +AC4 is satisfied by a synchronous flush on the explicit Save path, not by a new add-in shutdown +handler. The deferred three-second behaviour for all other callers remains unchanged. The VSTO +add-in lifecycle file is deliberately not modified. + +Supporting facts from the research: the shutdown handler in the add-in lifecycle file carries the +stock VSTO comment stating that Outlook no longer raises the event, verified present at the base +commit, so a flush placed there would never run; the designer-generated shutdown override must not be +hand-edited and is downstream of the same unraised event. + +The thread-safe write method is already public on the serializer, takes the write lock, writes +through the injectable stream-writer seam, and re-arms the single-shot guard in its finally block. +Two facts must be respected by the implementation. First, that method requires a non-null parent +reference; the stores wrapper sets it in both constructors, so the guard is satisfied on both the +deserialized and fresh-built models. Second, AC2's empty-or-null-path error must be evaluated before +any synchronous write, so that the fix does not substitute one silent failure for another. The +implementation therefore introduces a small guarded entry point rather than calling the thread-safe +write method bare from the controller. + +Recorded mechanics of the deferred path, which must not regress: the timer is a single-shot +three-second timer produced by an injectable factory; repeat calls inside the window coalesce rather +than reset, so the captured file path is the first caller's; the guard is only re-armed in the write +method's finally block. + +### D4 — the controller is split into a display partial + +UtilitiesCS/OutlookObjects/Store/StoreWrapperController.cs is 478 lines against a 500-line repository +cap, and four acceptance criteria land in it. It is split into a new partial at +UtilitiesCS/OutlookObjects/Store/StoreWrapperController.Display.cs carrying the display and rendering +members — PopulateWithCurrent, the exclude-store checkbox binding, GetRelativeFsPath, and the new +double-backslash trim helper. The class declaration gains the partial keyword. The application +globals partials are the in-repo precedent for this layout. + +The AC7 trim helper is a small pure private static method on the display partial rather than an +extension of the shared archive-stem contract type, which is a filing-boundary contract with its own +test suite consumed by other work items. The research verified that no existing helper in UtilitiesCS +performs a plain store-prefix trim: the closest behaviours are a navigation-local substring, a +trim-start inside a stem calculation, and root-relative operations that return false rather than +passing the input through. + +### D5 — the serializer's pre-existing size violation is accepted, not fixed + +UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs is already 613 lines, over +the same 500-line cap, before any change here. This change adds a small amount to it and does not +split it. Rationale: splitting a shared reusable-type-classes file during a parallel run would create +merge contention with concurrently running sibling work items, and the split is a separable concern +that should be raised on its own rather than folded into a bug fix. This change does not resolve that +pre-existing violation. The corresponding Non-Goals entry records the same statement. + +### D6 — one existing test expectation is deliberately inverted + +An existing test named PopulateWithCurrent_NullCurrent_SetsErrorLoadingText, in +UtilitiesCS.Test/OutlookObjects/Store/StoreWrapperController_Tests.ButtonAndPopulate.cs at lines +123-135, currently asserts that a null current store throws a NullReferenceException. The assertion +codifies the bug while the test name describes the fixed behaviour, so the test contradicts its own +name. AC8 requires that behaviour to change, so that test must be inverted to assert that the +placeholder text renders. + +This is called out here explicitly as a deliberate, declared test-expectation change. The General +Code Change Policy treats existing tests as part of the spec, so a reviewer must not mistake this +inversion for a weakened test. The inverted test asserts a stricter outcome than the original: it +requires a specific rendered value rather than merely an exception type. + +A second test-double retarget is required and is not an expectation weakening: the reflection-era +doubles in the store controller tests are retargeted to the typed sink. The negative test that +asserts the missing-implementation path does not throw is retargeted, not deleted, so the loud-failure +branch introduced by D2 retains coverage. + +### D7 — no resource file change is required + +The resource entry that defines the settings file name and its AppData special folder already carries +the correct values: the file name StoresWrapper.json, an AppData special folder that resolves to the +TaskMaster subdirectory of the local application data folder, and a matching local-disk entry. The +defect is that the value is discarded at runtime, not that it is wrong. No resource file is modified. + +--- + +## Acceptance Criteria + +The eight criteria below are reproduced verbatim from issue.md, where they were settled with the +maintainer on 2026-09-06. They are the same criteria, not additional ones. They are not renumbered, +reordered, dropped, merged, split or reworded. Check-off must be mirrored in issue.md; this file is +the authoritative source under full-bug work mode. + +- [x] AC1: When `StoresWrapper.json` is absent, the fresh-build path adopts the resource-defined disk configuration so `Config.Disk.FilePath` resolves to `%LocalAppData%\TaskMaster\StoresWrapper.json`, and the first Save creates the file. +- [x] AC2: `SmartSerializable.Serialize()` logs an error (not a silent return) when invoked with an empty or null `Config.Disk.FilePath`. +- [ ] AC3: A value saved in Folder Settings is present after an Outlook restart (manual verification). +- [x] AC4: An explicit Save is not lost if Outlook closes within the 3-second deferred-write window (flush on save or on shutdown). +- [x] AC5: The junk-folder double-persistence path is either removed or made to fail loudly; the reflection lookup is replaced by a typed seam. +- [x] AC6: User Email shows the SMTP address; on lookup failure it shows a specific message including the reason, falls back to an alternative source (the account SMTP address or the store display name when it is an SMTP address), and the lookup is retried when the dialog opens. +- [x] AC7: Inbox and Root Folder are displayed without the leading `\\` (cosmetic). +- [x] AC8: A null `Current` store selection renders the placeholder text instead of throwing. + +### Verification detail per criterion + +This subsection adds detail only. The checkbox text above is the authoritative wording. + +- **AC1 detail.** Satisfied at the store-loading partial per D1. "Adopts the resource-defined disk + configuration" means the freshly built wrapper's configuration is copied from the loader already + resolved from the intelligence-resources configuration dictionary, which the research verified + already carries the correct materialised path. The key-absent branch is out of AC1's scope and + remains a fresh build with an empty path, made visible by AC2. +- **AC2 detail.** Both the empty-string case and the null case must log at error level. The research + established that the null case is reachable because the existing guard compares only against the + empty string and the path helper can assign null. The sibling base-class method already uses a + null-or-empty check and is the consistent shape to converge on. +- **AC3 detail.** Manual verification by Outlook restart. Automated coverage is not achievable + without a live Outlook process and is not attempted. +- **AC4 detail.** "Not lost" is demonstrated by the explicit Save path writing without the deferred + timer firing. The deferred path for all other callers must still require a timer fire; both + assertions live in the same test file so that the unchanged-behaviour claim is directly evidenced. +- **AC5 detail.** "Fail loudly" is an error-level log on the failed cast, per D2. "Typed seam" is the + new interface, per D2. Argument order must be pinned by a test, because today the order is enforced + by nothing except positional agreement between the call site and the method signature. +- **AC6 detail.** Fallback order: the Exchange primary SMTP address; then the address entry's address + when it contains an at-sign; then the store display name when it contains an at-sign; and finally, + at the controller, a specific unavailability message carrying the caught exception's message in + place of the generic placeholder. The first two steps mirror an existing in-repo helper in the + application globals that already implements exactly this ordering with per-step exception handling. + The retry site is PopulateWithCurrent, which the research verified is the single method that runs + both when the dialog opens and on every store re-selection, and which already marshals to the UI + thread at its top. The retry is attempted at most once per dialog open and only when the address is + null. The blocking-latency limitation is recorded in Non-Goals item 8 and in Risks. +- **AC7 detail.** A pure trim helper on the display partial, plus the two label assignments. The + research verified that no existing test pins the untrimmed form, so this adds coverage rather than + changing an expectation. +- **AC8 detail.** The four unguarded dereferences at the top of PopulateWithCurrent and the one in + GetRelativeFsPath are guarded so the existing placeholder literals render. Under AC6 the user-email + literal becomes the new specific unavailability message; the remaining placeholder literals for + Inbox, Root Folder, the two archive fields and the two junk fields are unchanged. The deliberate + test inversion is D6. + +--- + +## Verification and Test Strategy + +### Test policy + +C# tests use MSTest, with Moq for mocking and FluentAssertions for assertions, per the C# Unit Test +Policy. Tests follow Arrange-Act-Assert with descriptive names. The repository prohibits creating +temporary files in tests, so the serializer tests drive the existing injectable seams the research +identified rather than writing to disk: the read-all-text seam, the disk-exists seam, the +stream-writer seam, the dialog seam, and the timer factory. All five are already exposed to tests by +an established harness in the UtilitiesCS test project, and a manual-fire timer double already +exists. No new production seam is required for AC1, AC3, AC5, AC6, AC7 or AC8. + +The one genuine gap is AC2. The research found that the serializer's logger is a private static +log4net logger and is not injectable. The recommended assertion route is the in-memory appender +pattern already used elsewhere in the test suite, in the TaskMaster test project's startup-timing and +app-events helper files. That route adds no production surface to an already over-cap shared file, and +the UtilitiesCS test project already has a direct log4net reference, so the same helper compiles +there. The appender must be detached in a finally block so tests remain independent. + +Banned in tests: Thread.Sleep, Task.Delay, real wall-clock waits, and temporary files. + +### Criterion-to-evidence map + +| AC | Test location | What is asserted | Evidence | +|---|---|---|---| +| AC1 | TaskMaster test project, AppGlobals coverage tests (or a new file there) | Given a loader whose disk path is a fake AppData path and a deserialize stub returning null, the freshly built wrapper's disk path equals the loader's path after the load completes. Negative case: configuration key absent, fresh build, path remains empty. No filesystem access. | vstest results, coverage report | +| AC2 | New serializer guard test file in the UtilitiesCS test project | In-memory log4net appender attached to the serializer's logger; Serialize with an empty path and, separately, with a null path each produce exactly one error-level event; no timer is armed in either case. | vstest results, coverage report | +| AC3 | Manual only | Fresh profile with no settings file; save an archive root; restart Outlook; reopen the dialog and confirm the value is present, the file exists under the local AppData TaskMaster directory, and the log contains no serializer error. | Manual verification note recorded under this feature folder's evidence directory, in the canonical other subdirectory. The evidence conventions define exactly the kinds baseline, regression-testing, qa-gates, issue-updates, other and remediation-baseline; there is no manual kind, and manual verification notes belong under other. | +| AC4 | New serializer guard test file in the UtilitiesCS test project | With a manual-fire timer injected and a memory-stream-backed writer, the explicit-save path writes without the timer firing; the pre-existing deferred path still requires a timer fire. Both in one file, so the unchanged-behaviour claim is evidenced directly. | vstest results, coverage report | +| AC5 | Store controller tests in the UtilitiesCS test project | A double implementing the new sink records both arguments and their order; a second double implementing only the globals interface drives the failed-cast branch and asserts an error-level log and no throw. The existing missing-implementation test is retargeted, not deleted. | vstest results, coverage report | +| AC6 | Store wrapper tests plus the new controller display test partial, both in the UtilitiesCS test project | Table-driven over the mocked Outlook folder chain already built by an existing helper: primary SMTP present; primary SMTP throws and the address entry address contains an at-sign; both fail and the display name contains an at-sign; all fail, producing a specific message containing the exception reason. Controller tests assert the retry runs on a second populate when the address is null and does not run when it is already populated. | vstest results, coverage report | +| AC7 | New controller display test partial | Pure-function cases over the trim helper: leading double backslash present, absent, single backslash, empty, null. Plus one populate test asserting the rendered Inbox and Root Folder label text. | vstest results, coverage report | +| AC8 | Existing button-and-populate test partial (inverted, per D6) plus the new display test partial | A null current store renders the placeholder text and does not throw; the relative-path helper likewise returns a placeholder rather than throwing. | vstest results, coverage report | + +### Toolchain + +Run in this exact order, and restart from the first step if any step fails or auto-fixes files: + +1. CSharpier format: + +```text +dotnet tool run csharpier format . +``` + +verified read-only with: + +```text +dotnet tool run csharpier check . +``` + +2. Analyzer rebuild: + +```text +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +``` + +3. Nullable rebuild: + +```text +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true +``` + +4. Test with coverage: + +```text +vstest.console.exe /EnableCodeCoverage +``` + +Two toolchain constraints are mandatory. Always use the Rebuild target, never Build: MSBuild's +up-to-date check does not invalidate on a command-line property change, so a warm Build returns exit +zero with the compile target skipped and the gate cannot fail. Do not add the solution-wide nullable +enable property to step 3: it is deliberately absent from CI, no project in this repository carries a +nullable element, and forcing it conscripts every file that has never adopted the per-file pragma. + +### Coverage expectations + +Every changed member is reachable through an existing seam, so the new-code coverage target and the +no-regression-on-changed-lines rule are attainable without adding any coverage-exclusion attribute in +this change. No coverage exclusion is introduced. + +### Evidence location + +All evidence artifacts produced for this work item — build and QA gate output, regression results, +coverage reports, and the AC3 manual verification note — are written under this feature folder's +evidence directory, in a subdirectory named for the evidence kind, per the +evidence-and-timestamp-conventions skill. No evidence is written to a repository-level artifacts +directory. + +--- + +## Risks and Regression Surface + +### Regression surface of the AC1 fix + +Because D1 makes no change to the shared deserialize overload, the behavioural regression surface of +that overload is empty. The research nonetheless enumerated it in full so a reviewer can confirm +that no alternative was taken. A set of in-repo forwarders reaches the overload, and the research +identified the terminal production entry points that actually exercise it at runtime as the +store-loading path — the intended fix site, which gains a post-fresh-build configuration copy at the +caller without any change to the overload's own behaviour — and the folder predictor load path, whose +documented fail-soft null contract is preserved exactly. Production entry points that use a different +overload are outside the surface entirely. The forwarder and entry-point tables in the research are +the enumeration of record; this specification does not restate their totals, because the research +supplies formal dual-derivation evidence only for its serializer-member and placeholder-literal +counts. + +The research also listed the existing test callers that pin the current behaviour of that overload, +across the serializer, non-typed serializer, linked-list and stack test files in the UtilitiesCS test +project and the two application-globals test files in the TaskMaster test project. All of them must +continue to pass unchanged. Any failure among them indicates the shared overload was modified +contrary to D1. + +### Risks + +1. **UI-thread latency from the AC6 retry.** Adding a retry at dialog-open time reintroduces a + synchronous Outlook COM property read on the UI thread, on a chain independently demonstrated + capable of long blocks. Mitigation: retry at most once per dialog open and only when the address + is null, which bounds the added latency to the same single lookup the startup path already + performs. The residual risk is accepted because AC6 as written requires the retry. A non-blocking + read is recorded as a follow-up, per Non-Goals item 8. +2. **Controller partial split.** Moving the display members to a new partial is a mechanical + relocation, but the controller already has several existing test files whose fixtures reach the + relocated members. Mitigation: relocate without behavioural edit + first, then apply the AC5, AC6, AC7 and AC8 changes, so a failure is attributable to one or the + other. Both the class declaration and the new partial must carry the partial keyword, and the new + file needs a hand-added compile entry or it will silently not compile into the assembly. +3. **Missing compile entries.** Both new production files and both new test files require hand-added + compile entries because every project is non-SDK-style. A missing entry produces a test file that + silently does not exist rather than a build error, so the analyzer rebuild alone will not catch it. + Mitigation: confirm the new test method names appear in the vstest run output. +4. **Junk-folder divergence during rollout.** Once AC1 lands, the per-store JSON mechanism begins + writing for the first time on affected machines, while the .NET user settings already hold values + written by the second mechanism. The two may disagree on a machine where junk folders were last + selected under a non-default store, because one resolves relative paths against the selected + store's root and the other against the default store's root. AC5 as scoped makes the failure loud + rather than eliminating the second mechanism, so a first-run disagreement is possible. This is a + known, accepted consequence of the chosen AC5 reading and should be checked during AC3 manual + verification. +5. **Serializer file size.** The serializer file remains over the 500-line cap after this change. A + reviewer applying the cap mechanically will flag it. The Non-Goals section and D5 record that this + is pre-existing and deliberately not resolved here. +6. **Test-double fragility recorded by the research.** Two hazards apply to the test author: mocking + task-bearing interfaces can throw a type-initialisation exception in this test binary because a + task-extensions assembly is absent from the test output, a condition already documented in-repo; + and an existing controller test depends on reference equality in the pairwise comparison helper. + Neither is introduced by this change, but both can surface as apparent regressions. +7. **AC3 cannot be gated automatically.** Persistence across an Outlook restart is verified manually. + The automated tests establish that the path is populated and that the write occurs through the + seam, but they do not prove the file appears on disk in a live VSTO host. + +### Unverified or explicitly bounded claims + +- The Outlook-side cause of the COM failure in the SMTP chain is not determinable from the available + log and is not claimed. +- The Outlook account collection is not read anywhere in the repository and the store wrapper holds + no application reference, so an account-based SMTP fallback is recorded as unavailable rather than + recommended. +- One alternative address source found on the store wrapper is populated only by a method with no + caller anywhere in the repository, so it is not a usable fallback without new wiring and is not + used. +- Whether the TaskMaster test project file ends the change modified depends on where the AC1 tests + are placed; the Write Set claims it conservatively. diff --git a/docs/features/potential/promoted/2026-09-06-folder-settings-never-persist-and-user-email-error-loading.md b/docs/features/potential/promoted/2026-09-06-folder-settings-never-persist-and-user-email-error-loading.md new file mode 100644 index 000000000..533d08912 --- /dev/null +++ b/docs/features/potential/promoted/2026-09-06-folder-settings-never-persist-and-user-email-error-loading.md @@ -0,0 +1,112 @@ +# folder-settings-never-persist-and-user-email-error-loading (Issue #797) + +- Date captured: 2026-09-06 +- Author: Dan Moisan +- Status: Promoted -> docs/features/active/folder-settings-never-persist-and-user-email-error-loading/ (Issue #797) + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +- Issue: #797 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/797 +- Last Updated: 2026-09-07 +## Summary + +Values chosen in Settings -> Folder Settings (Archive Root Outlook, Archive Root File System, Junk Potential, Junk Email) survive only for the current Outlook session and are lost on restart, because the settings file `StoresWrapper.json` has never been created and the save path silently does nothing when no file path is configured. In the same dialog, User Email renders "Error Loading" because the Exchange SMTP lookup throws a COM exception that is caught, logged, and rendered as a generic placeholder with no fallback and no retry. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 +- Runtime: .NET Framework 4.8 VSTO Outlook add-in, debug build loaded from `TaskMaster\bin\Debug`, HEAD `c431dc32` (2026-09-06) +- Command/flags used: Outlook ribbon -> Settings -> Folder Settings (`RibbonController.FolderStoresSettings`, `StoreWrapperController.Launch`) +- Data source or fixture: live Exchange mailbox `dmoisan@realgoodfoods.com`; one included store, one Google Workspace store excluded by the GWSO filter + +## Steps to Reproduce + +1. Confirm `%LocalAppData%\TaskMaster\StoresWrapper.json` does not exist (it has never existed on this machine; every other TaskMaster JSON file is present in that folder). +2. Start Outlook, open Settings -> Folder Settings. Observe: Archive Root Outlook and File System show "Please select an archive"; Junk Potential and Junk Email show "Please select a folder"; User Email shows "Error Loading"; Inbox shows `\\dmoisan@realgoodfoods.com\Inbox`; Root Folder shows `\\dmoisan@realgoodfoods.com`. +3. Select a value for Archive Root Outlook and click Save. Reopen the dialog in the same session: the value is retained. +4. Close Outlook, reopen it, open Folder Settings again: the value is gone and the placeholder is back. +5. Confirm `StoresWrapper.json` still does not exist. + +## Expected Behavior + +- A saved Folder Settings value is written to `%LocalAppData%\TaskMaster\StoresWrapper.json` and restored on the next Outlook start. +- A save that cannot be written is reported as an error in the log, never silently dropped. +- User Email shows the mailbox SMTP address. When the Exchange user lookup fails, the dialog shows a specific unavailability message with the reason, falls back to another source for the address, and retries the lookup when the dialog is opened rather than only at startup. +- Cosmetic: Inbox and Root Folder display without the leading `\\` store prefix. + +## Actual Behavior + +- The file is never created; every Outlook start rebuilds an empty stores wrapper and the placeholders return. +- No error is logged when Save runs with no file path. +- User Email shows "Error Loading" every time. +- Inbox and Root Folder show Outlook's native `\\\` form. + +## Logs / Screenshots + +- [x] Attached minimal logs or screenshot +- Snippet (`TaskMaster\bin\Debug\logs\debug_2026-09-06.log`): + +``` +2026-09-06 17:29:59,517 [VSTA_Main] WARN TaskMaster.AppOlObjects - StoresWrapper config deserialized to null; rebuilding from live stores. +2026-09-06 17:29:59,560 [VSTA_Main] DEBUG UtilitiesCS.OutlookObjects.Store.StoresWrapper - [store-filter] displayName=dmoisan@realgoodfoods.com exchangeStoreTypeMs=0.0 filePathMs=0.0 included=true rule=Included +2026-09-06 17:29:59,592 [VSTA_Main] ERROR UtilitiesCS.OutlookObjects.Store.StoreWrapper - Error retrieving PrimarySmtpAddress from secondary inbox. The operation failed. + at UtilitiesCS.OutlookObjects.Store.StoreWrapper.GetSmtpAddressFromStore() in ...\UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs:line 184 +``` + +- Filesystem evidence (2026-09-06): `%LocalAppData%\TaskMaster` contains `ManagerFolder.json`, `9999999RecentsFile.json`, `UsedIDList.json`, etc. A recursive search of the user profile finds no `StoresWrapper.json` anywhere. + +## Impact / Severity + +- [ ] Blocker +- [x] High +- [ ] Medium +- [ ] Low + +The Folder Settings dialog cannot persist any per-store setting on a machine where the file has never been created, which is every fresh install. The archive root and junk folder settings it manages feed filing and junk-mail workflows. The silent no-op on save means the defect produces no diagnostic signal. + +## Suspected Cause / Notes + +Root cause 1 (verified by code read and by the log line above): a bootstrap gap between the loader and the serializer. + +- `TaskMaster\AppGlobals\AppOlObjects.StoreLoading.cs:35-65` (`LoadStoresAsync`) deserializes via `SmartSerializable.Deserialize(config)`. +- `UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializableBase.cs:167-188` (`Deserialize(loader)`) calls `DeserializeJson(loader.Config.Disk, ...)`, which returns null when the file does not exist (`:335-342`). The loader's disk configuration is copied onto the instance only inside `if (instance is not null)` (`:176-180`), so it is discarded on the null path. +- The loader then calls `BuildFreshStoresWrapper()` (`:64`) = `new StoresWrapper(_globals).Init()`. The fresh instance's `Config.Disk.FilePath` is the `FilePathHelper` default `""` (`UtilitiesCS\HelperClasses\FileSystem\FilePathHelper.cs:72-102`); nothing assigns the resource-defined path (`UtilitiesCS\IntelligenceResources.resx:176-203`, `FileName: StoresWrapper.json`, `SpecialFolderName: AppData`). +- `StoreWrapperController.SaveChanges` (`UtilitiesCS\OutlookObjects\Store\StoreWrapperController.cs:348-357`) calls `Model.Serialize()`. `SmartSerializable.Serialize()` (`UtilitiesCS\ReusableTypeClasses\NewSmartSerializable\SmartSerializable.cs:442-448`) is `if (Config.Disk.FilePath != "") RequestSerialization(...)`, so with an empty path it returns without writing and without logging. +- Because the file is never written, every subsequent start takes the same null path. In-session persistence works only because the values live in the in-memory `StoreWrapper` (`SaveChanges` lines 350-353). +- Contrast: the overload `Deserialize(loader, askUserOnError, altLoader)` at `SmartSerializableBase.cs:190-240` copies the loader config onto the instance regardless (`:236`) and writes the instance when `writeInstance` is set. `RecentFolders` uses the `askUserOnError: true` variant (`TaskMaster\AppGlobals\AppAutoFileObjects.cs:217-222`) and its file exists. + +Root cause 2 (verified by the log): `StoreWrapper.GetSmtpAddressFromStore` (`UtilitiesCS\OutlookObjects\Store\StoreWrapper.cs:179-217`) threw `COMException` "The operation failed." at line 184 (`RootFolder?.Session?.CurrentUser`), caught and converted to null. `StoreWrapperController.PopulateWithCurrent` (`StoreWrapperController.cs:294-296`) renders null as "Error Loading". The lookup runs once in `StoreWrapper.Init` (`:83`) and is never retried. The Outlook-side cause of the COM failure is not determinable from the log. The same session's `ThreadMonitor` captured the UI thread inside `_ExchangeUser.get_PrimarySmtpAddress()` at 17:35:21, so a second caller of this chain also blocks on it. + +Not a defect: the `\\` prefix on Inbox and Root Folder is Outlook's native `MAPIFolder.FolderPath` read directly at `StoreWrapperController.cs:294-295`. Trimming is a cosmetic acceptance criterion only. + +Related latent defects in the same files, to be fixed in the same change: + +- `SmartSerializable.RequestSerialization` (`SmartSerializable.cs:550-559`) defers the write by a 3-second single-shot timer. Closing Outlook within that window loses the save. A shutdown flush or synchronous write on explicit Save is needed. +- `StoreWrapperController.PersistJunkFolderSelections` (`StoreWrapperController.cs:391-418`) reaches `AppOlObjects.ApplyJunkFolderSelections` by reflection and silently returns with only a warning when the method is not found; the junk folders are therefore persisted twice (per-store JSON and `.NET` user settings at `TaskMaster\AppGlobals\AppOlObjects.JunkFolders.cs:27-34`) by two mechanisms that can diverge. +- `StoreWrapperController.cs:169` can assign a null `Current`; `PopulateWithCurrent` dereferences it unguarded at `:288-291` before the null-safe reads at `:294-296`, so an unmatched store selection throws instead of showing the placeholder. +- `StoreWrapperController.GetRelativeFsPath` (`:456-474`) uses `&` rather than `&&` at `:464`. + +## Proposed Fix / Validation Ideas + +Acceptance criteria settled with the maintainer on 2026-09-06: + +- [ ] AC1: When `StoresWrapper.json` is absent, the fresh-build path adopts the resource-defined disk configuration so `Config.Disk.FilePath` resolves to `%LocalAppData%\TaskMaster\StoresWrapper.json`, and the first Save creates the file. +- [ ] AC2: `SmartSerializable.Serialize()` logs an error (not a silent return) when invoked with an empty or null `Config.Disk.FilePath`. +- [ ] AC3: A value saved in Folder Settings is present after an Outlook restart (manual verification). +- [ ] AC4: An explicit Save is not lost if Outlook closes within the 3-second deferred-write window (flush on save or on shutdown). +- [ ] AC5: The junk-folder double-persistence path is either removed or made to fail loudly; the reflection lookup is replaced by a typed seam. +- [ ] AC6: User Email shows the SMTP address; on lookup failure it shows a specific message including the reason, falls back to an alternative source (the account SMTP address or the store display name when it is an SMTP address), and the lookup is retried when the dialog opens. +- [ ] AC7: Inbox and Root Folder are displayed without the leading `\\` (cosmetic). +- [ ] AC8: A null `Current` store selection renders the placeholder text instead of throwing. + +Validation: + +- [ ] Unit coverage areas: `SmartSerializableBase.Deserialize` null-file path copies loader config onto the fallback instance; `SmartSerializable.Serialize()` with empty path logs an error (Moq on the logger seam or an injectable log sink); `StoreWrapperController.PopulateWithCurrent` null-`Current` path; SMTP fallback ordering; `\\` trim helper. +- [ ] Integration scenario to retest: fresh profile with no `StoresWrapper.json`, save archive root, restart, reopen dialog. +- [ ] Manual verification notes: confirm the file is created on first Save and the log contains no serializer error; confirm User Email populates or shows the specific message. + +## Next Step + +- [ ] Promote to GitHub issue (bug-report template) +- [ ] Move to active fix folder / branch