From 3a8394631839b7b45ed7dbd0768b4abdadb2fb09 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:21:30 -0500 Subject: [PATCH 01/18] Add context-specific audit receipt registry --- .../IApplicationMutationAuditReceiptRegistry.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/ProjectTemplate.Infrastructure/Data/Auditing/IApplicationMutationAuditReceiptRegistry.cs diff --git a/src/ProjectTemplate.Infrastructure/Data/Auditing/IApplicationMutationAuditReceiptRegistry.cs b/src/ProjectTemplate.Infrastructure/Data/Auditing/IApplicationMutationAuditReceiptRegistry.cs new file mode 100644 index 0000000..3519e01 --- /dev/null +++ b/src/ProjectTemplate.Infrastructure/Data/Auditing/IApplicationMutationAuditReceiptRegistry.cs @@ -0,0 +1,14 @@ +namespace ProjectTemplate.Infrastructure.Data.Auditing; + +/// +/// Resolves the most recently completed mutation audit receipt for a specific application database context. +/// +public interface IApplicationMutationAuditReceiptRegistry +{ + /// + /// Gets the most recently completed receipt produced by . + /// + /// The application database context whose receipt should be resolved. + /// The context-specific receipt, or when the context has not completed an audited save. + ApplicationMutationAuditReceipt? GetLastCompletedReceipt(ApplicationDbContext dbContext); +} From ab8dd6db6953a5931dc31a87ff0da19d8678faa7 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:24:36 -0500 Subject: [PATCH 02/18] Add temporary issue 374 refactor workflow --- .github/workflows/issue-374-refactor.yml | 205 +++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 .github/workflows/issue-374-refactor.yml diff --git a/.github/workflows/issue-374-refactor.yml b/.github/workflows/issue-374-refactor.yml new file mode 100644 index 0000000..323c81e --- /dev/null +++ b/.github/workflows/issue-374-refactor.yml @@ -0,0 +1,205 @@ +name: Apply Issue 374 Refactor + +on: + push: + branches: + - agent/issue-374-context-audit-state-active + +permissions: + contents: write + +jobs: + refactor: + runs-on: ubuntu-latest + steps: + - name: Checkout branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: agent/issue-374-context-audit-state-active + fetch-depth: 0 + + - name: Apply context-isolation refactor + shell: python + run: | + from pathlib import Path + + pipeline_path = Path("src/ProjectTemplate.Infrastructure/Data/ApplicationSaveChangesPipeline.cs") + text = pipeline_path.read_text(encoding="utf-8-sig") + + def replace_once(old: str, new: str) -> None: + global text + if text.count(old) != 1: + raise SystemExit(f"Expected exactly one match, found {text.count(old)} for:\n{old}") + text = text.replace(old, new, 1) + + replace_once( + "using Microsoft.EntityFrameworkCore;\n", + "using System.Runtime.CompilerServices;\nusing Microsoft.EntityFrameworkCore;\n") + replace_once( + " IApplicationSaveChangesPipeline,\n IApplicationMutationAuditReceiptAccessor\n", + " IApplicationSaveChangesPipeline,\n IApplicationMutationAuditReceiptAccessor,\n IApplicationMutationAuditReceiptRegistry\n") + replace_once( + " private readonly IApplicationAuditStore _auditStore;\n private List _pendingAuditEntries = [];\n private readonly List _activeAuditRecords = [];\n private ApplicationAuditContext? _activeAuditContext;\n private string? _activeMutationBatchId;\n private int _activeAuditRecordCount;\n", + " private readonly IApplicationAuditStore _auditStore;\n private readonly ConditionalWeakTable _contextStates = new();\n private readonly object _lastCompletedReceiptLock = new();\n private ApplicationMutationAuditReceipt? _lastCompletedReceipt;\n") + replace_once( + " public ApplicationMutationAuditReceipt? LastCompletedReceipt { get; private set; }\n", + " public ApplicationMutationAuditReceipt? LastCompletedReceipt\n {\n get\n {\n lock (_lastCompletedReceiptLock)\n {\n return _lastCompletedReceipt;\n }\n }\n }\n\n /// \n public ApplicationMutationAuditReceipt? GetLastCompletedReceipt(ApplicationDbContext dbContext)\n {\n ArgumentNullException.ThrowIfNull(dbContext);\n return _contextStates.TryGetValue(dbContext, out ApplicationSaveChangesState? state)\n ? state.LastCompletedReceipt\n : null;\n }\n") + replace_once( + " ArgumentNullException.ThrowIfNull(dbContext);\n ResetActiveAuditState();\n\n IReadOnlyList entries = GetSavePipelineEntries(dbContext);", + " ArgumentNullException.ThrowIfNull(dbContext);\n ApplicationSaveChangesState state = GetContextState(dbContext);\n ResetActiveAuditState(state);\n\n IReadOnlyList entries = GetSavePipelineEntries(dbContext);") + replace_once( + " _pendingAuditEntries = OnBeforeSaveChanges(dbContext, entries);", + " state.PendingAuditEntries = OnBeforeSaveChanges(dbContext, entries, state);") + replace_once( + " cancellationToken.ThrowIfCancellationRequested();\n ResetActiveAuditState();\n\n IReadOnlyList entries = GetSavePipelineEntries(dbContext);", + " cancellationToken.ThrowIfCancellationRequested();\n ApplicationSaveChangesState state = GetContextState(dbContext);\n ResetActiveAuditState(state);\n\n IReadOnlyList entries = GetSavePipelineEntries(dbContext);") + replace_once( + " _pendingAuditEntries = await OnBeforeSaveChangesAsync(\n dbContext,\n entries,\n cancellationToken)", + " state.PendingAuditEntries = await OnBeforeSaveChangesAsync(\n dbContext,\n entries,\n state,\n cancellationToken)") + replace_once( + " ArgumentNullException.ThrowIfNull(dbContext);\n bool appendedAdditionalAuditRecords = false;\n\n foreach (AuditEntry auditEntry in _pendingAuditEntries)", + " ArgumentNullException.ThrowIfNull(dbContext);\n ApplicationSaveChangesState state = GetContextState(dbContext);\n bool appendedAdditionalAuditRecords = false;\n\n foreach (AuditEntry auditEntry in state.PendingAuditEntries)") + replace_once( + " AppendAuditRecord(dbContext, auditEntry.ToAuditRecord());", + " AppendAuditRecord(dbContext, auditEntry.ToAuditRecord(), state);") + replace_once( + " CompleteMutationReceipt();\n _pendingAuditEntries = [];", + " CompleteMutationReceipt(state);\n state.PendingAuditEntries = [];") + replace_once( + " cancellationToken.ThrowIfCancellationRequested();\n bool appendedAdditionalAuditRecords = false;\n\n foreach (AuditEntry auditEntry in _pendingAuditEntries)", + " cancellationToken.ThrowIfCancellationRequested();\n ApplicationSaveChangesState state = GetContextState(dbContext);\n bool appendedAdditionalAuditRecords = false;\n\n foreach (AuditEntry auditEntry in state.PendingAuditEntries)") + replace_once( + " await AppendAuditRecordAsync(dbContext, auditEntry.ToAuditRecord(), cancellationToken)", + " await AppendAuditRecordAsync(dbContext, auditEntry.ToAuditRecord(), state, cancellationToken)") + replace_once( + " CompleteMutationReceipt();\n _pendingAuditEntries = [];", + " CompleteMutationReceipt(state);\n state.PendingAuditEntries = [];") + replace_once( + " private void ResetActiveAuditState()\n {\n _pendingAuditEntries = [];\n _activeAuditRecords.Clear();\n _activeAuditContext = null;\n _activeMutationBatchId = null;\n _activeAuditRecordCount = 0;\n }\n", + " private ApplicationSaveChangesState GetContextState(ApplicationDbContext dbContext)\n {\n return _contextStates.GetValue(dbContext, static _ => new ApplicationSaveChangesState());\n }\n\n private static void ResetActiveAuditState(ApplicationSaveChangesState state)\n {\n state.PendingAuditEntries = [];\n state.ActiveAuditRecords.Clear();\n state.ActiveAuditContext = null;\n state.ActiveMutationBatchId = null;\n state.ActiveAuditRecordCount = 0;\n }\n") + replace_once( + " private List OnBeforeSaveChanges(\n ApplicationDbContext dbContext,\n IEnumerable entries)\n {\n List auditEntries = CreateAuditEntries(entries);", + " private List OnBeforeSaveChanges(\n ApplicationDbContext dbContext,\n IEnumerable entries,\n ApplicationSaveChangesState state)\n {\n List auditEntries = CreateAuditEntries(entries, state);") + replace_once( + " AppendAuditRecord(dbContext, auditEntry.ToAuditRecord());", + " AppendAuditRecord(dbContext, auditEntry.ToAuditRecord(), state);") + replace_once( + " private async ValueTask> OnBeforeSaveChangesAsync(\n ApplicationDbContext dbContext,\n IEnumerable entries,\n CancellationToken cancellationToken)\n {\n List auditEntries = CreateAuditEntries(entries);", + " private async ValueTask> OnBeforeSaveChangesAsync(\n ApplicationDbContext dbContext,\n IEnumerable entries,\n ApplicationSaveChangesState state,\n CancellationToken cancellationToken)\n {\n List auditEntries = CreateAuditEntries(entries, state);") + replace_once( + " await AppendAuditRecordAsync(dbContext, auditEntry.ToAuditRecord(), cancellationToken)", + " await AppendAuditRecordAsync(dbContext, auditEntry.ToAuditRecord(), state, cancellationToken)") + replace_once( + " private void AppendAuditRecord(ApplicationDbContext dbContext, AuditRecord auditRecord)\n {\n _auditStore.Append(dbContext, auditRecord);\n _activeAuditRecords.Add(auditRecord);\n }", + " private void AppendAuditRecord(\n ApplicationDbContext dbContext,\n AuditRecord auditRecord,\n ApplicationSaveChangesState state)\n {\n _auditStore.Append(dbContext, auditRecord);\n state.ActiveAuditRecords.Add(auditRecord);\n }") + replace_once( + " private async ValueTask AppendAuditRecordAsync(\n ApplicationDbContext dbContext,\n AuditRecord auditRecord,\n CancellationToken cancellationToken)\n {\n await _auditStore.AppendAsync(dbContext, auditRecord, cancellationToken).ConfigureAwait(false);\n _activeAuditRecords.Add(auditRecord);\n }", + " private async ValueTask AppendAuditRecordAsync(\n ApplicationDbContext dbContext,\n AuditRecord auditRecord,\n ApplicationSaveChangesState state,\n CancellationToken cancellationToken)\n {\n await _auditStore.AppendAsync(dbContext, auditRecord, cancellationToken).ConfigureAwait(false);\n state.ActiveAuditRecords.Add(auditRecord);\n }") + replace_once( + " private List CreateAuditEntries(IEnumerable entries)", + " private List CreateAuditEntries(\n IEnumerable entries,\n ApplicationSaveChangesState state)") + replace_once( + " _activeAuditContext = auditContext;\n _activeMutationBatchId = mutationBatchId;\n _activeAuditRecordCount = auditEntries.Count;", + " state.ActiveAuditContext = auditContext;\n state.ActiveMutationBatchId = mutationBatchId;\n state.ActiveAuditRecordCount = auditEntries.Count;") + replace_once( + " private void CompleteMutationReceipt()\n {\n if (_activeAuditContext is null ||\n string.IsNullOrWhiteSpace(_activeMutationBatchId) ||\n _activeAuditRecordCount == 0)", + " private void CompleteMutationReceipt(ApplicationSaveChangesState state)\n {\n if (state.ActiveAuditContext is null ||\n string.IsNullOrWhiteSpace(state.ActiveMutationBatchId) ||\n state.ActiveAuditRecordCount == 0)") + replace_once( + " if (_activeAuditRecords.Count != _activeAuditRecordCount)", + " if (state.ActiveAuditRecords.Count != state.ActiveAuditRecordCount)") + replace_once( + " ApplicationMutationManifest manifest = _manifestBuilder.Build(_activeAuditRecords);", + " ApplicationMutationManifest manifest = _manifestBuilder.Build(state.ActiveAuditRecords);") + replace_once( + " LastCompletedReceipt = new ApplicationMutationAuditReceipt(\n _activeMutationBatchId,\n _activeAuditRecordCount,", + " ApplicationMutationAuditReceipt receipt = new(\n state.ActiveMutationBatchId,\n state.ActiveAuditRecordCount,") + replace_once( + " _activeAuditContext.OperationExecutionId,\n _activeAuditContext.ExecutionAttemptId,\n _activeAuditContext.DecisionAuditRecordId,\n _activeAuditContext.CorrelationId,\n _activeAuditContext.TraceId);\n\n _activeAuditContext = null;\n _activeMutationBatchId = null;\n _activeAuditRecordCount = 0;\n _activeAuditRecords.Clear();", + " state.ActiveAuditContext.OperationExecutionId,\n state.ActiveAuditContext.ExecutionAttemptId,\n state.ActiveAuditContext.DecisionAuditRecordId,\n state.ActiveAuditContext.CorrelationId,\n state.ActiveAuditContext.TraceId);\n\n state.LastCompletedReceipt = receipt;\n lock (_lastCompletedReceiptLock)\n {\n _lastCompletedReceipt = receipt;\n }\n\n state.ActiveAuditContext = null;\n state.ActiveMutationBatchId = null;\n state.ActiveAuditRecordCount = 0;\n state.ActiveAuditRecords.Clear();") + replace_once( + " private static IApplicationAuditStore ResolveAuditStore(", + " private sealed class ApplicationSaveChangesState\n {\n public List PendingAuditEntries { get; set; } = [];\n\n public List ActiveAuditRecords { get; } = [];\n\n public ApplicationAuditContext? ActiveAuditContext { get; set; }\n\n public string? ActiveMutationBatchId { get; set; }\n\n public int ActiveAuditRecordCount { get; set; }\n\n public ApplicationMutationAuditReceipt? LastCompletedReceipt { get; set; }\n }\n\n private static IApplicationAuditStore ResolveAuditStore(") + + pipeline_path.write_text(text, encoding="utf-8") + + accessor_path = Path("src/ProjectTemplate.Infrastructure/Data/Auditing/IApplicationMutationAuditReceiptAccessor.cs") + accessor = accessor_path.read_text(encoding="utf-8-sig") + accessor = accessor.replace( + "/// Exposes the most recently completed mutation audit receipt for the current scoped save pipeline.", + "/// Exposes the most recently completed mutation audit receipt across the current scoped save pipeline.") + accessor = accessor.replace( + "public interface IApplicationMutationAuditReceiptAccessor\n{", + "/// \n/// When one scope owns multiple instances, this compatibility accessor\n/// represents the most recently completed receipt across those contexts. Use\n/// when context-specific receipt identity is required.\n/// \npublic interface IApplicationMutationAuditReceiptAccessor\n{") + accessor_path.write_text(accessor, encoding="utf-8") + + registration_path = Path("src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs") + registration = registration_path.read_text(encoding="utf-8-sig") + old = """ services.TryAddScoped(serviceProvider => + serviceProvider.GetRequiredService()); + services.TryAddScoped(); +""" + new = """ services.TryAddScoped(serviceProvider => + serviceProvider.GetRequiredService()); + services.TryAddScoped(serviceProvider => + serviceProvider.GetRequiredService()); + services.TryAddScoped(); +""" + if registration.count(old) != 1: + raise SystemExit("Registration replacement failed") + registration_path.write_text(registration.replace(old, new, 1), encoding="utf-8") + + transaction_path = Path("src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationAuditedTransaction.cs") + transaction = transaction_path.read_text(encoding="utf-8-sig") + transaction = transaction.replace( + " private readonly IApplicationMutationAuditReceiptAccessor _receiptAccessor;\n", + " private readonly IApplicationMutationAuditReceiptAccessor _receiptAccessor;\n private readonly IApplicationMutationAuditReceiptRegistry? _receiptRegistry;\n", + 1) + old = """ public ApplicationAuditedTransaction( + ApplicationDbContext dbContext, + IApplicationMutationAuditReceiptAccessor receiptAccessor) +""" + new = """ public ApplicationAuditedTransaction( + ApplicationDbContext dbContext, + IApplicationMutationAuditReceiptAccessor receiptAccessor, + IApplicationMutationAuditReceiptRegistry? receiptRegistry = null) +""" + if transaction.count(old) != 1: + raise SystemExit("Transaction constructor signature replacement failed") + transaction = transaction.replace(old, new, 1) + transaction = transaction.replace( + " _dbContext = dbContext;\n _receiptAccessor = receiptAccessor;\n", + " _dbContext = dbContext;\n _receiptAccessor = receiptAccessor;\n _receiptRegistry = receiptRegistry;\n", + 1) + transaction = transaction.replace( + "ApplicationMutationAuditReceipt? previousReceipt = _receiptAccessor.LastCompletedReceipt;", + "ApplicationMutationAuditReceipt? previousReceipt = GetLastCompletedReceipt();") + transaction = transaction.replace( + " ApplicationMutationAuditReceipt? currentReceipt = _receiptAccessor.LastCompletedReceipt;\n return ReferenceEquals(previousReceipt, currentReceipt) ? null : currentReceipt;", + " ApplicationMutationAuditReceipt? currentReceipt = GetLastCompletedReceipt();\n return ReferenceEquals(previousReceipt, currentReceipt) ? null : currentReceipt;", + 1) + marker = """ private IDbContextTransaction BeginTransaction(IsolationLevel? isolationLevel) +""" + helper = """ private ApplicationMutationAuditReceipt? GetLastCompletedReceipt() + { + return _receiptRegistry?.GetLastCompletedReceipt(_dbContext) + ?? _receiptAccessor.LastCompletedReceipt; + } + +""" + if transaction.count(marker) != 1: + raise SystemExit("Transaction helper insertion failed") + transaction = transaction.replace(marker, helper + marker, 1) + transaction_path.write_text(transaction, encoding="utf-8") + + - name: Commit refactor + shell: bash + run: | + if git diff --quiet; then + echo "No changes remain." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/ProjectTemplate.Infrastructure/Data + git commit -m "Isolate audit save state per DbContext" + git push From 4e1f37722ea7b446cc453ffd319503d71ee0f9f1 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:25:01 -0500 Subject: [PATCH 03/18] Trigger issue 374 refactor workflow --- .github/issue-374-trigger.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/issue-374-trigger.txt diff --git a/.github/issue-374-trigger.txt b/.github/issue-374-trigger.txt new file mode 100644 index 0000000..6e7c8db --- /dev/null +++ b/.github/issue-374-trigger.txt @@ -0,0 +1 @@ +Temporary trigger for the issue 374 branch refactor workflow. From 23c66c9cc7cfab0623a3fc9da13155c1f751bbf9 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:26:28 -0500 Subject: [PATCH 04/18] Add context-isolated SaveChanges pipeline coordinator --- ...tIsolatedApplicationSaveChangesPipeline.cs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/ProjectTemplate.Infrastructure/Data/ContextIsolatedApplicationSaveChangesPipeline.cs diff --git a/src/ProjectTemplate.Infrastructure/Data/ContextIsolatedApplicationSaveChangesPipeline.cs b/src/ProjectTemplate.Infrastructure/Data/ContextIsolatedApplicationSaveChangesPipeline.cs new file mode 100644 index 0000000..6b67e57 --- /dev/null +++ b/src/ProjectTemplate.Infrastructure/Data/ContextIsolatedApplicationSaveChangesPipeline.cs @@ -0,0 +1,94 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Options; +using ProjectTemplate.Infrastructure.Data.Auditing; +using ProjectTemplate.Infrastructure.Data.Options; + +namespace ProjectTemplate.Infrastructure.Data; + +/// +/// Routes save lifecycle callbacks to one mutable pipeline instance per application database context. +/// +internal sealed class ContextIsolatedApplicationSaveChangesPipeline : + IApplicationSaveChangesPipeline, + IApplicationMutationAuditReceiptRegistry +{ + private readonly ConditionalWeakTable _pipelines = new(); + private readonly ICurrentActorAccessor _currentActorAccessor; + private readonly IOptions _dataAccessOptions; + private readonly IApplicationAuditStore? _auditStore; + private readonly IApplicationAuditContextAccessor? _auditContextAccessor; + private readonly IApplicationAuditValuePolicy? _auditValuePolicy; + private readonly IApplicationMutationManifestBuilder _manifestBuilder; + private readonly IApplicationMutationManifestHasher _manifestHasher; + + public ContextIsolatedApplicationSaveChangesPipeline( + ICurrentActorAccessor currentActorAccessor, + IOptions dataAccessOptions, + IApplicationMutationManifestBuilder manifestBuilder, + IApplicationMutationManifestHasher manifestHasher, + IApplicationAuditStore? auditStore = null, + IApplicationAuditContextAccessor? auditContextAccessor = null, + IApplicationAuditValuePolicy? auditValuePolicy = null) + { + ArgumentNullException.ThrowIfNull(currentActorAccessor); + ArgumentNullException.ThrowIfNull(dataAccessOptions); + ArgumentNullException.ThrowIfNull(manifestBuilder); + ArgumentNullException.ThrowIfNull(manifestHasher); + + _currentActorAccessor = currentActorAccessor; + _dataAccessOptions = dataAccessOptions; + _auditStore = auditStore; + _auditContextAccessor = auditContextAccessor; + _auditValuePolicy = auditValuePolicy; + _manifestBuilder = manifestBuilder; + _manifestHasher = manifestHasher; + } + + public bool ApplyBeforeSaveChanges(ApplicationDbContext dbContext) + { + ArgumentNullException.ThrowIfNull(dbContext); + return GetPipeline(dbContext).ApplyBeforeSaveChanges(dbContext); + } + + public ValueTask ApplyBeforeSaveChangesAsync( + ApplicationDbContext dbContext, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(dbContext); + return GetPipeline(dbContext).ApplyBeforeSaveChangesAsync(dbContext, cancellationToken); + } + + public bool ApplyAfterSaveChanges(ApplicationDbContext dbContext) + { + ArgumentNullException.ThrowIfNull(dbContext); + return GetPipeline(dbContext).ApplyAfterSaveChanges(dbContext); + } + + public ValueTask ApplyAfterSaveChangesAsync( + ApplicationDbContext dbContext, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(dbContext); + return GetPipeline(dbContext).ApplyAfterSaveChangesAsync(dbContext, cancellationToken); + } + + public ApplicationMutationAuditReceipt? GetLastCompletedReceipt(ApplicationDbContext dbContext) + { + ArgumentNullException.ThrowIfNull(dbContext); + return _pipelines.TryGetValue(dbContext, out ApplicationSaveChangesPipeline? pipeline) + ? pipeline.LastCompletedReceipt + : null; + } + + private ApplicationSaveChangesPipeline GetPipeline(ApplicationDbContext dbContext) + { + return _pipelines.GetValue(dbContext, _ => new ApplicationSaveChangesPipeline( + _currentActorAccessor, + _dataAccessOptions, + _auditStore, + _auditContextAccessor, + _auditValuePolicy, + _manifestBuilder, + _manifestHasher)); + } +} From 5248095b33c906c7b3d2af003dd14d060f1d22b7 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:26:38 -0500 Subject: [PATCH 05/18] Add context-bound mutation receipt accessor --- ...ionDbContextMutationAuditReceiptAccessor.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationDbContextMutationAuditReceiptAccessor.cs diff --git a/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationDbContextMutationAuditReceiptAccessor.cs b/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationDbContextMutationAuditReceiptAccessor.cs new file mode 100644 index 0000000..f3f717c --- /dev/null +++ b/src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationDbContextMutationAuditReceiptAccessor.cs @@ -0,0 +1,18 @@ +namespace ProjectTemplate.Infrastructure.Data.Auditing; + +/// +/// Exposes the most recently completed mutation receipt for one application database context. +/// +internal sealed class ApplicationDbContextMutationAuditReceiptAccessor( + ApplicationDbContext dbContext, + IApplicationMutationAuditReceiptRegistry receiptRegistry) + : IApplicationMutationAuditReceiptAccessor +{ + private readonly ApplicationDbContext _dbContext = + dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + private readonly IApplicationMutationAuditReceiptRegistry _receiptRegistry = + receiptRegistry ?? throw new ArgumentNullException(nameof(receiptRegistry)); + + public ApplicationMutationAuditReceipt? LastCompletedReceipt => + _receiptRegistry.GetLastCompletedReceipt(_dbContext); +} From f62de5e638cf02bf0b871b2b70fd71c2c56e86eb Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:27:01 -0500 Subject: [PATCH 06/18] Register context-isolated audit pipeline services --- ...InfrastructureDataAccessServiceExtensions.cs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs b/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs index 72e3479..caad343 100644 --- a/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs +++ b/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs @@ -54,19 +54,12 @@ public static IServiceCollection AddApplicationInfrastructureDataAccess( services.TryAddScoped(); } - services.TryAddScoped(serviceProvider => - new ApplicationSaveChangesPipeline( - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService>(), - serviceProvider.GetService(), - serviceProvider.GetService(), - serviceProvider.GetService(), - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService())); + services.TryAddScoped(); services.TryAddScoped(serviceProvider => - serviceProvider.GetRequiredService()); - services.TryAddScoped(serviceProvider => - serviceProvider.GetRequiredService()); + serviceProvider.GetRequiredService()); + services.TryAddScoped(serviceProvider => + serviceProvider.GetRequiredService()); + services.TryAddScoped(); services.TryAddScoped(); services.AddDbContext(options => ConfigureProvider( From c8a0de8cc96a248726d937f412e1479199e76551 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:28:05 -0500 Subject: [PATCH 07/18] Update data access registration coverage for context isolation --- ...InfrastructureDataAccessServiceExtensionsTests.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/ProjectTemplate.Web.Tests/InfrastructureDataAccessServiceExtensionsTests.cs b/tests/ProjectTemplate.Web.Tests/InfrastructureDataAccessServiceExtensionsTests.cs index 5eefee5..d5dd05c 100644 --- a/tests/ProjectTemplate.Web.Tests/InfrastructureDataAccessServiceExtensionsTests.cs +++ b/tests/ProjectTemplate.Web.Tests/InfrastructureDataAccessServiceExtensionsTests.cs @@ -45,12 +45,18 @@ public void AddApplicationInfrastructureDataAccess_NonWebContainer_ResolvesDbCon IApplicationSaveChangesPipeline saveChangesPipeline = scope.ServiceProvider .GetRequiredService(); + IApplicationMutationAuditReceiptRegistry receiptRegistry = scope.ServiceProvider + .GetRequiredService(); + ApplicationSaveChangesInterceptor saveChangesInterceptor = scope.ServiceProvider .GetRequiredService(); using ApplicationDbContext context = scope.ServiceProvider .GetRequiredService(); + IApplicationMutationAuditReceiptAccessor receiptAccessor = scope.ServiceProvider + .GetRequiredService(); + IDbContextFactory dbContextFactory = scope.ServiceProvider .GetRequiredService>(); @@ -58,7 +64,9 @@ public void AddApplicationInfrastructureDataAccess_NonWebContainer_ResolvesDbCon Assert.Equal(SystemCurrentActorAccessor.ActorName, actorAccessor.CurrentActor); Assert.IsType(auditStore); - Assert.IsType(saveChangesPipeline); + Assert.NotNull(saveChangesPipeline); + Assert.Same(saveChangesPipeline, receiptRegistry); + Assert.Null(receiptAccessor.LastCompletedReceipt); Assert.NotNull(saveChangesInterceptor); Assert.Equal("Microsoft.EntityFrameworkCore.Sqlite", context.Database.ProviderName); Assert.Equal("Microsoft.EntityFrameworkCore.Sqlite", factoryContext.Database.ProviderName); @@ -102,6 +110,8 @@ public void AddApplicationInfrastructureDataAccess_DisabledProvider_SkipsDbConte Assert.Null(serviceProvider.GetService()); Assert.Null(serviceProvider.GetService()); Assert.Null(serviceProvider.GetService()); + Assert.Null(serviceProvider.GetService()); + Assert.Null(serviceProvider.GetService()); Assert.Null(serviceProvider.GetService()); Assert.Null(serviceProvider.GetService()); Assert.Null(serviceProvider.GetService>()); From 9e57b010c710f53a1a27fdbfcb970f6a287e8294 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:28:36 -0500 Subject: [PATCH 08/18] Add DbContext audit state isolation regression tests --- ...aveChangesPipelineContextIsolationTests.cs | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/ProjectTemplate.Web.Tests/ApplicationSaveChangesPipelineContextIsolationTests.cs diff --git a/tests/ProjectTemplate.Web.Tests/ApplicationSaveChangesPipelineContextIsolationTests.cs b/tests/ProjectTemplate.Web.Tests/ApplicationSaveChangesPipelineContextIsolationTests.cs new file mode 100644 index 0000000..13586d6 --- /dev/null +++ b/tests/ProjectTemplate.Web.Tests/ApplicationSaveChangesPipelineContextIsolationTests.cs @@ -0,0 +1,158 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ProjectTemplate.Infrastructure.Data; +using ProjectTemplate.Infrastructure.Data.Auditing; +using ProjectTemplate.Infrastructure.Data.Entities; +using ProjectTemplate.Infrastructure.Data.Extensions; + +namespace ProjectTemplate.Web.Tests; + +public sealed class ApplicationSaveChangesPipelineContextIsolationTests +{ + [Fact] + public async Task SaveChangesAsync_TwoFactoryContextsInOneScope_KeepAuditStateAndReceiptsIsolated() + { + string databasePath = Path.Combine( + Path.GetTempPath(), + $"ncat-audit-context-isolation-{Guid.NewGuid():N}.db"); + + try + { + IConfiguration configuration = CreateConfiguration(databasePath); + ServiceCollection services = new(); + services.AddLogging(); + services.AddScoped(); + services.AddScoped(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddApplicationInfrastructureDataAccess(configuration); + + await using ServiceProvider serviceProvider = services.BuildServiceProvider( + new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + + IDbContextFactory factory = scope.ServiceProvider + .GetRequiredService>(); + IApplicationMutationAuditReceiptRegistry receiptRegistry = scope.ServiceProvider + .GetRequiredService(); + BlockingApplicationAuditStore auditStore = scope.ServiceProvider + .GetRequiredService(); + + await using ApplicationDbContext firstContext = await factory.CreateDbContextAsync( + TestContext.Current.CancellationToken); + await using ApplicationDbContext secondContext = await factory.CreateDbContextAsync( + TestContext.Current.CancellationToken); + + _ = await firstContext.Database.EnsureDeletedAsync(TestContext.Current.CancellationToken); + _ = await firstContext.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + + firstContext.ExternalLoginAccounts.Add(CreateAccount("first-context-user")); + secondContext.ExternalLoginAccounts.Add(CreateAccount("second-context-user")); + + Task firstSave = firstContext.SaveChangesAsync(TestContext.Current.CancellationToken); + await auditStore.WaitUntilFirstAppendIsBlockedAsync(TestContext.Current.CancellationToken); + + int secondResult = await secondContext.SaveChangesAsync(TestContext.Current.CancellationToken); + auditStore.ReleaseFirstAppend(); + int firstResult = await firstSave; + + AuditRecord firstAuditRecord = Assert.Single( + firstContext.ChangeTracker.Entries()).Entity; + AuditRecord secondAuditRecord = Assert.Single( + secondContext.ChangeTracker.Entries()).Entity; + ApplicationMutationAuditReceipt firstReceipt = Assert.IsType( + receiptRegistry.GetLastCompletedReceipt(firstContext)); + ApplicationMutationAuditReceipt secondReceipt = Assert.IsType( + receiptRegistry.GetLastCompletedReceipt(secondContext)); + + Assert.Equal(2, firstResult); + Assert.Equal(2, secondResult); + Assert.NotEqual(firstAuditRecord.MutationBatchId, secondAuditRecord.MutationBatchId); + Assert.Equal(firstAuditRecord.MutationBatchId, firstReceipt.MutationBatchId); + Assert.Equal(secondAuditRecord.MutationBatchId, secondReceipt.MutationBatchId); + Assert.Contains("first-context-user", firstAuditRecord.CurrentValues, StringComparison.Ordinal); + Assert.DoesNotContain("second-context-user", firstAuditRecord.CurrentValues, StringComparison.Ordinal); + Assert.Contains("second-context-user", secondAuditRecord.CurrentValues, StringComparison.Ordinal); + Assert.DoesNotContain("first-context-user", secondAuditRecord.CurrentValues, StringComparison.Ordinal); + } + finally + { + File.Delete(databasePath); + } + } + + private static IConfiguration CreateConfiguration(string databasePath) + { + return new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:ApplicationDatabase"] = $"Data Source={databasePath}", + ["ProjectTemplate:DataAccess:Provider"] = "Sqlite", + ["ProjectTemplate:DataAccess:ConnectionStringName"] = "ApplicationDatabase", + ["ProjectTemplate:DataAccess:Auditing:Enabled"] = "true", + ["ProjectTemplate:DataAccess:Auditing:StorageMode"] = "Local" + }) + .Build(); + } + + private static ExternalLoginAccount CreateAccount(string providerUserId) + { + return new ExternalLoginAccount + { + LocalUserId = Guid.NewGuid(), + ProviderName = "GitHub", + ProviderUserId = providerUserId, + DisplayName = providerUserId, + Email = $"{providerUserId}@example.com", + CreatedOnUtc = new DateTime(2026, 7, 19, 12, 0, 0, DateTimeKind.Utc) + }; + } + + private sealed class BlockingApplicationAuditStore : IApplicationAuditStore + { + private readonly TaskCompletionSource _firstAppendBlocked = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseFirstAppend = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private int _appendCount; + + public void Append(ApplicationDbContext dbContext, AuditRecord auditRecord) + { + ArgumentNullException.ThrowIfNull(dbContext); + ArgumentNullException.ThrowIfNull(auditRecord); + dbContext.AuditRecords.Add(auditRecord); + } + + public async ValueTask AppendAsync( + ApplicationDbContext dbContext, + AuditRecord auditRecord, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(dbContext); + ArgumentNullException.ThrowIfNull(auditRecord); + + if (Interlocked.Increment(ref _appendCount) == 1) + { + _firstAppendBlocked.SetResult(); + await _releaseFirstAppend.Task.WaitAsync(cancellationToken); + } + + dbContext.AuditRecords.Add(auditRecord); + } + + public Task WaitUntilFirstAppendIsBlockedAsync(CancellationToken cancellationToken) + { + return _firstAppendBlocked.Task.WaitAsync(cancellationToken); + } + + public void ReleaseFirstAppend() + { + _releaseFirstAppend.TrySetResult(); + } + } +} From 71b54e13fa8e1a2c3b516ec2df115c11d5fcccd7 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:28:59 -0500 Subject: [PATCH 09/18] Document DbContext audit state isolation --- .../dbcontext-audit-state-isolation.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/articles/dbcontext-audit-state-isolation.md diff --git a/docs/articles/dbcontext-audit-state-isolation.md b/docs/articles/dbcontext-audit-state-isolation.md new file mode 100644 index 0000000..ac77af1 --- /dev/null +++ b/docs/articles/dbcontext-audit-state-isolation.md @@ -0,0 +1,42 @@ +# DbContext Audit State Isolation + +NCAT isolates mutable SaveChanges audit state by `ApplicationDbContext` instance, even when one dependency-injection scope creates several contexts through `IDbContextFactory`. + +Each context receives a dedicated internal `ApplicationSaveChangesPipeline` state machine for: + +- pending audit entries that contain temporary values; +- retained audit records used to build the canonical mutation manifest; +- active audit context and mutation-batch identity; +- audit record counts; and +- the most recently completed mutation receipt. + +The scoped coordinator uses weak context keys, so disposing a factory-created context does not leave that context permanently retained by the scope. + +## Receipt access + +`IApplicationMutationAuditReceiptAccessor` is context-bound when resolved from dependency injection. It reports the receipt for the scoped `ApplicationDbContext` used by `IApplicationAuditedTransaction`. + +Factory-created contexts can be queried explicitly through `IApplicationMutationAuditReceiptRegistry`: + +```csharp +await using ApplicationDbContext first = await factory.CreateDbContextAsync(cancellationToken); +await using ApplicationDbContext second = await factory.CreateDbContextAsync(cancellationToken); + +await first.SaveChangesAsync(cancellationToken); +await second.SaveChangesAsync(cancellationToken); + +ApplicationMutationAuditReceipt? firstReceipt = + receiptRegistry.GetLastCompletedReceipt(first); +ApplicationMutationAuditReceipt? secondReceipt = + receiptRegistry.GetLastCompletedReceipt(second); +``` + +The registry returns `null` until the specified context completes an audited save. A receipt produced by another context in the same scope is never returned for the requested context. + +## Concurrency boundary + +Different `ApplicationDbContext` instances in one scope may save independently without sharing pending audit state or mutation receipts. + +EF Core does not support concurrent operations on the same `DbContext` instance. This isolation feature does not change that rule. Await each operation on a context before starting another operation on that same context. + +The configured `IApplicationAuditStore`, audit context accessor, actor accessor, value policy, manifest builder, and manifest hasher remain shared according to their registered lifetimes. Custom scoped implementations that maintain mutable state must therefore provide their own concurrency safety when multiple contexts use them simultaneously. From 68fe4eb1716cf8f1ee5542fdb86d02b09986dbf6 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:29:16 -0500 Subject: [PATCH 10/18] Add DbContext audit isolation documentation to navigation --- docs/articles/toc.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index 0711a4a..5b5c87d 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -76,6 +76,8 @@ href: ef-core-save-pipeline.md - name: Audit Accountability Integration href: audit-accountability-integration.md + - name: DbContext Audit State Isolation + href: dbcontext-audit-state-isolation.md - name: Durable Audit-Completion Outbox href: audit-completion-outbox.md - name: Audit Reconciliation and Recovery From b3ae0fbd57f32e679e8fdfd9a8db5207409d9751 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:29:28 -0500 Subject: [PATCH 11/18] Remove temporary issue 374 refactor workflow --- .github/workflows/issue-374-refactor.yml | 205 ----------------------- 1 file changed, 205 deletions(-) delete mode 100644 .github/workflows/issue-374-refactor.yml diff --git a/.github/workflows/issue-374-refactor.yml b/.github/workflows/issue-374-refactor.yml deleted file mode 100644 index 323c81e..0000000 --- a/.github/workflows/issue-374-refactor.yml +++ /dev/null @@ -1,205 +0,0 @@ -name: Apply Issue 374 Refactor - -on: - push: - branches: - - agent/issue-374-context-audit-state-active - -permissions: - contents: write - -jobs: - refactor: - runs-on: ubuntu-latest - steps: - - name: Checkout branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: agent/issue-374-context-audit-state-active - fetch-depth: 0 - - - name: Apply context-isolation refactor - shell: python - run: | - from pathlib import Path - - pipeline_path = Path("src/ProjectTemplate.Infrastructure/Data/ApplicationSaveChangesPipeline.cs") - text = pipeline_path.read_text(encoding="utf-8-sig") - - def replace_once(old: str, new: str) -> None: - global text - if text.count(old) != 1: - raise SystemExit(f"Expected exactly one match, found {text.count(old)} for:\n{old}") - text = text.replace(old, new, 1) - - replace_once( - "using Microsoft.EntityFrameworkCore;\n", - "using System.Runtime.CompilerServices;\nusing Microsoft.EntityFrameworkCore;\n") - replace_once( - " IApplicationSaveChangesPipeline,\n IApplicationMutationAuditReceiptAccessor\n", - " IApplicationSaveChangesPipeline,\n IApplicationMutationAuditReceiptAccessor,\n IApplicationMutationAuditReceiptRegistry\n") - replace_once( - " private readonly IApplicationAuditStore _auditStore;\n private List _pendingAuditEntries = [];\n private readonly List _activeAuditRecords = [];\n private ApplicationAuditContext? _activeAuditContext;\n private string? _activeMutationBatchId;\n private int _activeAuditRecordCount;\n", - " private readonly IApplicationAuditStore _auditStore;\n private readonly ConditionalWeakTable _contextStates = new();\n private readonly object _lastCompletedReceiptLock = new();\n private ApplicationMutationAuditReceipt? _lastCompletedReceipt;\n") - replace_once( - " public ApplicationMutationAuditReceipt? LastCompletedReceipt { get; private set; }\n", - " public ApplicationMutationAuditReceipt? LastCompletedReceipt\n {\n get\n {\n lock (_lastCompletedReceiptLock)\n {\n return _lastCompletedReceipt;\n }\n }\n }\n\n /// \n public ApplicationMutationAuditReceipt? GetLastCompletedReceipt(ApplicationDbContext dbContext)\n {\n ArgumentNullException.ThrowIfNull(dbContext);\n return _contextStates.TryGetValue(dbContext, out ApplicationSaveChangesState? state)\n ? state.LastCompletedReceipt\n : null;\n }\n") - replace_once( - " ArgumentNullException.ThrowIfNull(dbContext);\n ResetActiveAuditState();\n\n IReadOnlyList entries = GetSavePipelineEntries(dbContext);", - " ArgumentNullException.ThrowIfNull(dbContext);\n ApplicationSaveChangesState state = GetContextState(dbContext);\n ResetActiveAuditState(state);\n\n IReadOnlyList entries = GetSavePipelineEntries(dbContext);") - replace_once( - " _pendingAuditEntries = OnBeforeSaveChanges(dbContext, entries);", - " state.PendingAuditEntries = OnBeforeSaveChanges(dbContext, entries, state);") - replace_once( - " cancellationToken.ThrowIfCancellationRequested();\n ResetActiveAuditState();\n\n IReadOnlyList entries = GetSavePipelineEntries(dbContext);", - " cancellationToken.ThrowIfCancellationRequested();\n ApplicationSaveChangesState state = GetContextState(dbContext);\n ResetActiveAuditState(state);\n\n IReadOnlyList entries = GetSavePipelineEntries(dbContext);") - replace_once( - " _pendingAuditEntries = await OnBeforeSaveChangesAsync(\n dbContext,\n entries,\n cancellationToken)", - " state.PendingAuditEntries = await OnBeforeSaveChangesAsync(\n dbContext,\n entries,\n state,\n cancellationToken)") - replace_once( - " ArgumentNullException.ThrowIfNull(dbContext);\n bool appendedAdditionalAuditRecords = false;\n\n foreach (AuditEntry auditEntry in _pendingAuditEntries)", - " ArgumentNullException.ThrowIfNull(dbContext);\n ApplicationSaveChangesState state = GetContextState(dbContext);\n bool appendedAdditionalAuditRecords = false;\n\n foreach (AuditEntry auditEntry in state.PendingAuditEntries)") - replace_once( - " AppendAuditRecord(dbContext, auditEntry.ToAuditRecord());", - " AppendAuditRecord(dbContext, auditEntry.ToAuditRecord(), state);") - replace_once( - " CompleteMutationReceipt();\n _pendingAuditEntries = [];", - " CompleteMutationReceipt(state);\n state.PendingAuditEntries = [];") - replace_once( - " cancellationToken.ThrowIfCancellationRequested();\n bool appendedAdditionalAuditRecords = false;\n\n foreach (AuditEntry auditEntry in _pendingAuditEntries)", - " cancellationToken.ThrowIfCancellationRequested();\n ApplicationSaveChangesState state = GetContextState(dbContext);\n bool appendedAdditionalAuditRecords = false;\n\n foreach (AuditEntry auditEntry in state.PendingAuditEntries)") - replace_once( - " await AppendAuditRecordAsync(dbContext, auditEntry.ToAuditRecord(), cancellationToken)", - " await AppendAuditRecordAsync(dbContext, auditEntry.ToAuditRecord(), state, cancellationToken)") - replace_once( - " CompleteMutationReceipt();\n _pendingAuditEntries = [];", - " CompleteMutationReceipt(state);\n state.PendingAuditEntries = [];") - replace_once( - " private void ResetActiveAuditState()\n {\n _pendingAuditEntries = [];\n _activeAuditRecords.Clear();\n _activeAuditContext = null;\n _activeMutationBatchId = null;\n _activeAuditRecordCount = 0;\n }\n", - " private ApplicationSaveChangesState GetContextState(ApplicationDbContext dbContext)\n {\n return _contextStates.GetValue(dbContext, static _ => new ApplicationSaveChangesState());\n }\n\n private static void ResetActiveAuditState(ApplicationSaveChangesState state)\n {\n state.PendingAuditEntries = [];\n state.ActiveAuditRecords.Clear();\n state.ActiveAuditContext = null;\n state.ActiveMutationBatchId = null;\n state.ActiveAuditRecordCount = 0;\n }\n") - replace_once( - " private List OnBeforeSaveChanges(\n ApplicationDbContext dbContext,\n IEnumerable entries)\n {\n List auditEntries = CreateAuditEntries(entries);", - " private List OnBeforeSaveChanges(\n ApplicationDbContext dbContext,\n IEnumerable entries,\n ApplicationSaveChangesState state)\n {\n List auditEntries = CreateAuditEntries(entries, state);") - replace_once( - " AppendAuditRecord(dbContext, auditEntry.ToAuditRecord());", - " AppendAuditRecord(dbContext, auditEntry.ToAuditRecord(), state);") - replace_once( - " private async ValueTask> OnBeforeSaveChangesAsync(\n ApplicationDbContext dbContext,\n IEnumerable entries,\n CancellationToken cancellationToken)\n {\n List auditEntries = CreateAuditEntries(entries);", - " private async ValueTask> OnBeforeSaveChangesAsync(\n ApplicationDbContext dbContext,\n IEnumerable entries,\n ApplicationSaveChangesState state,\n CancellationToken cancellationToken)\n {\n List auditEntries = CreateAuditEntries(entries, state);") - replace_once( - " await AppendAuditRecordAsync(dbContext, auditEntry.ToAuditRecord(), cancellationToken)", - " await AppendAuditRecordAsync(dbContext, auditEntry.ToAuditRecord(), state, cancellationToken)") - replace_once( - " private void AppendAuditRecord(ApplicationDbContext dbContext, AuditRecord auditRecord)\n {\n _auditStore.Append(dbContext, auditRecord);\n _activeAuditRecords.Add(auditRecord);\n }", - " private void AppendAuditRecord(\n ApplicationDbContext dbContext,\n AuditRecord auditRecord,\n ApplicationSaveChangesState state)\n {\n _auditStore.Append(dbContext, auditRecord);\n state.ActiveAuditRecords.Add(auditRecord);\n }") - replace_once( - " private async ValueTask AppendAuditRecordAsync(\n ApplicationDbContext dbContext,\n AuditRecord auditRecord,\n CancellationToken cancellationToken)\n {\n await _auditStore.AppendAsync(dbContext, auditRecord, cancellationToken).ConfigureAwait(false);\n _activeAuditRecords.Add(auditRecord);\n }", - " private async ValueTask AppendAuditRecordAsync(\n ApplicationDbContext dbContext,\n AuditRecord auditRecord,\n ApplicationSaveChangesState state,\n CancellationToken cancellationToken)\n {\n await _auditStore.AppendAsync(dbContext, auditRecord, cancellationToken).ConfigureAwait(false);\n state.ActiveAuditRecords.Add(auditRecord);\n }") - replace_once( - " private List CreateAuditEntries(IEnumerable entries)", - " private List CreateAuditEntries(\n IEnumerable entries,\n ApplicationSaveChangesState state)") - replace_once( - " _activeAuditContext = auditContext;\n _activeMutationBatchId = mutationBatchId;\n _activeAuditRecordCount = auditEntries.Count;", - " state.ActiveAuditContext = auditContext;\n state.ActiveMutationBatchId = mutationBatchId;\n state.ActiveAuditRecordCount = auditEntries.Count;") - replace_once( - " private void CompleteMutationReceipt()\n {\n if (_activeAuditContext is null ||\n string.IsNullOrWhiteSpace(_activeMutationBatchId) ||\n _activeAuditRecordCount == 0)", - " private void CompleteMutationReceipt(ApplicationSaveChangesState state)\n {\n if (state.ActiveAuditContext is null ||\n string.IsNullOrWhiteSpace(state.ActiveMutationBatchId) ||\n state.ActiveAuditRecordCount == 0)") - replace_once( - " if (_activeAuditRecords.Count != _activeAuditRecordCount)", - " if (state.ActiveAuditRecords.Count != state.ActiveAuditRecordCount)") - replace_once( - " ApplicationMutationManifest manifest = _manifestBuilder.Build(_activeAuditRecords);", - " ApplicationMutationManifest manifest = _manifestBuilder.Build(state.ActiveAuditRecords);") - replace_once( - " LastCompletedReceipt = new ApplicationMutationAuditReceipt(\n _activeMutationBatchId,\n _activeAuditRecordCount,", - " ApplicationMutationAuditReceipt receipt = new(\n state.ActiveMutationBatchId,\n state.ActiveAuditRecordCount,") - replace_once( - " _activeAuditContext.OperationExecutionId,\n _activeAuditContext.ExecutionAttemptId,\n _activeAuditContext.DecisionAuditRecordId,\n _activeAuditContext.CorrelationId,\n _activeAuditContext.TraceId);\n\n _activeAuditContext = null;\n _activeMutationBatchId = null;\n _activeAuditRecordCount = 0;\n _activeAuditRecords.Clear();", - " state.ActiveAuditContext.OperationExecutionId,\n state.ActiveAuditContext.ExecutionAttemptId,\n state.ActiveAuditContext.DecisionAuditRecordId,\n state.ActiveAuditContext.CorrelationId,\n state.ActiveAuditContext.TraceId);\n\n state.LastCompletedReceipt = receipt;\n lock (_lastCompletedReceiptLock)\n {\n _lastCompletedReceipt = receipt;\n }\n\n state.ActiveAuditContext = null;\n state.ActiveMutationBatchId = null;\n state.ActiveAuditRecordCount = 0;\n state.ActiveAuditRecords.Clear();") - replace_once( - " private static IApplicationAuditStore ResolveAuditStore(", - " private sealed class ApplicationSaveChangesState\n {\n public List PendingAuditEntries { get; set; } = [];\n\n public List ActiveAuditRecords { get; } = [];\n\n public ApplicationAuditContext? ActiveAuditContext { get; set; }\n\n public string? ActiveMutationBatchId { get; set; }\n\n public int ActiveAuditRecordCount { get; set; }\n\n public ApplicationMutationAuditReceipt? LastCompletedReceipt { get; set; }\n }\n\n private static IApplicationAuditStore ResolveAuditStore(") - - pipeline_path.write_text(text, encoding="utf-8") - - accessor_path = Path("src/ProjectTemplate.Infrastructure/Data/Auditing/IApplicationMutationAuditReceiptAccessor.cs") - accessor = accessor_path.read_text(encoding="utf-8-sig") - accessor = accessor.replace( - "/// Exposes the most recently completed mutation audit receipt for the current scoped save pipeline.", - "/// Exposes the most recently completed mutation audit receipt across the current scoped save pipeline.") - accessor = accessor.replace( - "public interface IApplicationMutationAuditReceiptAccessor\n{", - "/// \n/// When one scope owns multiple instances, this compatibility accessor\n/// represents the most recently completed receipt across those contexts. Use\n/// when context-specific receipt identity is required.\n/// \npublic interface IApplicationMutationAuditReceiptAccessor\n{") - accessor_path.write_text(accessor, encoding="utf-8") - - registration_path = Path("src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs") - registration = registration_path.read_text(encoding="utf-8-sig") - old = """ services.TryAddScoped(serviceProvider => - serviceProvider.GetRequiredService()); - services.TryAddScoped(); -""" - new = """ services.TryAddScoped(serviceProvider => - serviceProvider.GetRequiredService()); - services.TryAddScoped(serviceProvider => - serviceProvider.GetRequiredService()); - services.TryAddScoped(); -""" - if registration.count(old) != 1: - raise SystemExit("Registration replacement failed") - registration_path.write_text(registration.replace(old, new, 1), encoding="utf-8") - - transaction_path = Path("src/ProjectTemplate.Infrastructure/Data/Auditing/ApplicationAuditedTransaction.cs") - transaction = transaction_path.read_text(encoding="utf-8-sig") - transaction = transaction.replace( - " private readonly IApplicationMutationAuditReceiptAccessor _receiptAccessor;\n", - " private readonly IApplicationMutationAuditReceiptAccessor _receiptAccessor;\n private readonly IApplicationMutationAuditReceiptRegistry? _receiptRegistry;\n", - 1) - old = """ public ApplicationAuditedTransaction( - ApplicationDbContext dbContext, - IApplicationMutationAuditReceiptAccessor receiptAccessor) -""" - new = """ public ApplicationAuditedTransaction( - ApplicationDbContext dbContext, - IApplicationMutationAuditReceiptAccessor receiptAccessor, - IApplicationMutationAuditReceiptRegistry? receiptRegistry = null) -""" - if transaction.count(old) != 1: - raise SystemExit("Transaction constructor signature replacement failed") - transaction = transaction.replace(old, new, 1) - transaction = transaction.replace( - " _dbContext = dbContext;\n _receiptAccessor = receiptAccessor;\n", - " _dbContext = dbContext;\n _receiptAccessor = receiptAccessor;\n _receiptRegistry = receiptRegistry;\n", - 1) - transaction = transaction.replace( - "ApplicationMutationAuditReceipt? previousReceipt = _receiptAccessor.LastCompletedReceipt;", - "ApplicationMutationAuditReceipt? previousReceipt = GetLastCompletedReceipt();") - transaction = transaction.replace( - " ApplicationMutationAuditReceipt? currentReceipt = _receiptAccessor.LastCompletedReceipt;\n return ReferenceEquals(previousReceipt, currentReceipt) ? null : currentReceipt;", - " ApplicationMutationAuditReceipt? currentReceipt = GetLastCompletedReceipt();\n return ReferenceEquals(previousReceipt, currentReceipt) ? null : currentReceipt;", - 1) - marker = """ private IDbContextTransaction BeginTransaction(IsolationLevel? isolationLevel) -""" - helper = """ private ApplicationMutationAuditReceipt? GetLastCompletedReceipt() - { - return _receiptRegistry?.GetLastCompletedReceipt(_dbContext) - ?? _receiptAccessor.LastCompletedReceipt; - } - -""" - if transaction.count(marker) != 1: - raise SystemExit("Transaction helper insertion failed") - transaction = transaction.replace(marker, helper + marker, 1) - transaction_path.write_text(transaction, encoding="utf-8") - - - name: Commit refactor - shell: bash - run: | - if git diff --quiet; then - echo "No changes remain." - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/ProjectTemplate.Infrastructure/Data - git commit -m "Isolate audit save state per DbContext" - git push From 13ff86d4be3a75c3a96b53a514d24560af2a7a25 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:29:34 -0500 Subject: [PATCH 12/18] Remove temporary issue 374 workflow trigger --- .github/issue-374-trigger.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/issue-374-trigger.txt diff --git a/.github/issue-374-trigger.txt b/.github/issue-374-trigger.txt deleted file mode 100644 index 6e7c8db..0000000 --- a/.github/issue-374-trigger.txt +++ /dev/null @@ -1 +0,0 @@ -Temporary trigger for the issue 374 branch refactor workflow. From 94696aa4e4532f6a1ad09c2c1a965f6d49c38507 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:31:27 -0500 Subject: [PATCH 13/18] Add temporary issue 374 formatting workflow --- .github/workflows/format-issue-374.yml | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/format-issue-374.yml diff --git a/.github/workflows/format-issue-374.yml b/.github/workflows/format-issue-374.yml new file mode 100644 index 0000000..cae8ef0 --- /dev/null +++ b/.github/workflows/format-issue-374.yml @@ -0,0 +1,43 @@ +name: Format Issue 374 Branch + +on: + push: + branches: + - agent/issue-374-context-audit-state-active + +permissions: + contents: write + +jobs: + format: + runs-on: ubuntu-latest + steps: + - name: Checkout branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: agent/issue-374-context-audit-state-active + fetch-depth: 0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + global-json-file: global.json + + - name: Restore dependencies + run: dotnet restore ./NetCoreApplicationTemplate.slnx --locked-mode + + - name: Apply repository formatting + run: dotnet format ./NetCoreApplicationTemplate.slnx --no-restore --verbosity minimal + + - name: Commit formatting + shell: bash + run: | + if git diff --quiet; then + echo "No formatting changes remain." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add --all + git commit -m "Apply issue 374 formatting" + git push From 9711f4a3a916111eb44af887a30a57445aad98fe Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:31:59 -0500 Subject: [PATCH 14/18] Trigger issue 374 formatting workflow --- .github/issue-374-format-trigger.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/issue-374-format-trigger.txt diff --git a/.github/issue-374-format-trigger.txt b/.github/issue-374-format-trigger.txt new file mode 100644 index 0000000..424f46f --- /dev/null +++ b/.github/issue-374-format-trigger.txt @@ -0,0 +1 @@ +Temporary trigger for issue 374 formatting. From 5a348247780ec688cb0da363e63cb4c437c0b327 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:32:50 +0000 Subject: [PATCH 15/18] Apply issue 374 formatting --- .../Data/ContextIsolatedApplicationSaveChangesPipeline.cs | 2 +- .../Extensions/InfrastructureDataAccessServiceExtensions.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ProjectTemplate.Infrastructure/Data/ContextIsolatedApplicationSaveChangesPipeline.cs b/src/ProjectTemplate.Infrastructure/Data/ContextIsolatedApplicationSaveChangesPipeline.cs index 6b67e57..2e18663 100644 --- a/src/ProjectTemplate.Infrastructure/Data/ContextIsolatedApplicationSaveChangesPipeline.cs +++ b/src/ProjectTemplate.Infrastructure/Data/ContextIsolatedApplicationSaveChangesPipeline.cs @@ -12,7 +12,7 @@ internal sealed class ContextIsolatedApplicationSaveChangesPipeline : IApplicationSaveChangesPipeline, IApplicationMutationAuditReceiptRegistry { - private readonly ConditionalWeakTable _pipelines = new(); + private readonly ConditionalWeakTable _pipelines = []; private readonly ICurrentActorAccessor _currentActorAccessor; private readonly IOptions _dataAccessOptions; private readonly IApplicationAuditStore? _auditStore; diff --git a/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs b/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs index caad343..2c479e3 100644 --- a/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs +++ b/src/ProjectTemplate.Infrastructure/Data/Extensions/InfrastructureDataAccessServiceExtensions.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; using ProjectTemplate.Infrastructure.Data.Auditing; using ProjectTemplate.Infrastructure.Data.ExternalLogins; using ProjectTemplate.Infrastructure.Data.Options; From 8dbd6607c70fb4d8d3ada423b7cb339ef5d487c9 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:33:22 -0500 Subject: [PATCH 16/18] Remove temporary issue 374 formatting workflow --- .github/workflows/format-issue-374.yml | 43 -------------------------- 1 file changed, 43 deletions(-) delete mode 100644 .github/workflows/format-issue-374.yml diff --git a/.github/workflows/format-issue-374.yml b/.github/workflows/format-issue-374.yml deleted file mode 100644 index cae8ef0..0000000 --- a/.github/workflows/format-issue-374.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Format Issue 374 Branch - -on: - push: - branches: - - agent/issue-374-context-audit-state-active - -permissions: - contents: write - -jobs: - format: - runs-on: ubuntu-latest - steps: - - name: Checkout branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: agent/issue-374-context-audit-state-active - fetch-depth: 0 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 - with: - global-json-file: global.json - - - name: Restore dependencies - run: dotnet restore ./NetCoreApplicationTemplate.slnx --locked-mode - - - name: Apply repository formatting - run: dotnet format ./NetCoreApplicationTemplate.slnx --no-restore --verbosity minimal - - - name: Commit formatting - shell: bash - run: | - if git diff --quiet; then - echo "No formatting changes remain." - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add --all - git commit -m "Apply issue 374 formatting" - git push From fe0f06694aec6b9dd143e6af76b51950c8d5b2e5 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:33:29 -0500 Subject: [PATCH 17/18] Remove temporary issue 374 formatting trigger --- .github/issue-374-format-trigger.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/issue-374-format-trigger.txt diff --git a/.github/issue-374-format-trigger.txt b/.github/issue-374-format-trigger.txt deleted file mode 100644 index 424f46f..0000000 --- a/.github/issue-374-format-trigger.txt +++ /dev/null @@ -1 +0,0 @@ -Temporary trigger for issue 374 formatting. From e04bf53d74be97afa3b8ffcb1c3b5297467156f1 Mon Sep 17 00:00:00 2001 From: "Christopher D. Cavell" <28095137+cdcavell@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:40:34 -0500 Subject: [PATCH 18/18] Disable SQLite pooling in context isolation test --- .../ApplicationSaveChangesPipelineContextIsolationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ProjectTemplate.Web.Tests/ApplicationSaveChangesPipelineContextIsolationTests.cs b/tests/ProjectTemplate.Web.Tests/ApplicationSaveChangesPipelineContextIsolationTests.cs index 13586d6..c21df42 100644 --- a/tests/ProjectTemplate.Web.Tests/ApplicationSaveChangesPipelineContextIsolationTests.cs +++ b/tests/ProjectTemplate.Web.Tests/ApplicationSaveChangesPipelineContextIsolationTests.cs @@ -91,7 +91,7 @@ private static IConfiguration CreateConfiguration(string databasePath) .AddInMemoryCollection( new Dictionary { - ["ConnectionStrings:ApplicationDatabase"] = $"Data Source={databasePath}", + ["ConnectionStrings:ApplicationDatabase"] = $"Data Source={databasePath};Pooling=False", ["ProjectTemplate:DataAccess:Provider"] = "Sqlite", ["ProjectTemplate:DataAccess:ConnectionStringName"] = "ApplicationDatabase", ["ProjectTemplate:DataAccess:Auditing:Enabled"] = "true",