Parcourir la source

fix: close review findings for stories 1.7 and 1.8

- wire legacy schema checks to live OleDb inspection and durable history
- harden schema baseline parsing and add constraint drift detection
- authenticate admin schema API calls from the client
- add release-gate workflow for legacy schema validation
- add extension-record save path with pre-save legacy link validation
- add link ambiguity detection, provider coverage reporting, and nightly integrity scheduler
- mark stories 1.7 and 1.8 done after review fixes
pull/17/head
Daniel Covington il y a 2 jours
Parent
révision
cd74b849de
29 fichiers modifiés avec 828 ajouts et 27 suppressions
  1. +24
    -0
      .gitea/workflows/release-gates.yml
  2. +2
    -0
      .gitignore
  3. +3
    -0
      Campaign_Tracker.Server.Tests/AuthEndpointTests.cs
  4. +63
    -0
      Campaign_Tracker.Server.Tests/ExtensionRecordControllerTests.cs
  5. +33
    -3
      Campaign_Tracker.Server.Tests/LegacyLinkIntegrityServiceTests.cs
  6. +14
    -0
      Campaign_Tracker.Server.Tests/LegacyLinkValidatorTests.cs
  7. +66
    -0
      Campaign_Tracker.Server/Controllers/ExtensionRecordsController.cs
  8. +2
    -0
      Campaign_Tracker.Server/Controllers/LegacyLinkController.cs
  9. +27
    -0
      Campaign_Tracker.Server/ExtensionData/IExtensionRecordStore.cs
  10. +90
    -0
      Campaign_Tracker.Server/ExtensionData/InMemoryExtensionRecordStore.cs
  11. +82
    -0
      Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityHostedService.cs
  12. +2
    -1
      Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityReport.cs
  13. +20
    -1
      Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs
  14. +6
    -2
      Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs
  15. +8
    -0
      Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs
  16. +55
    -0
      Campaign_Tracker.Server/LegacyData/Schema/FileLegacySchemaCheckHistory.cs
  17. +40
    -8
      Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs
  18. +1
    -0
      Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCheckResult.cs
  19. +19
    -0
      Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCompatibilityCheck.cs
  20. +4
    -1
      Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs
  21. +113
    -0
      Campaign_Tracker.Server/LegacyData/Schema/OleDbLegacySchemaInspector.cs
  22. +31
    -4
      Campaign_Tracker.Server/Program.cs
  23. +61
    -0
      Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl
  24. +17
    -1
      _bmad-output/implementation-artifacts/1-7-legacy-schema-compatibility-validation-gate.md
  25. +14
    -1
      _bmad-output/implementation-artifacts/1-8-legacy-identifier-linking-for-extension-records.md
  26. +4
    -0
      _bmad-output/implementation-artifacts/deferred-work.md
  27. +3
    -3
      _bmad-output/implementation-artifacts/sprint-status.yaml
  28. +14
    -1
      campaign-tracker-client/src/App.tsx
  29. +10
    -1
      campaign-tracker-client/src/workspace/WorkspaceShell.tsx

+ 24
- 0
.gitea/workflows/release-gates.yml Voir le fichier

@@ -0,0 +1,24 @@
name: release-gates

on:
push:
branches:
- main
pull_request:

jobs:
legacy-schema:
runs-on: windows-latest
env:
ASPNETCORE_ENVIRONMENT: Production
LegacyDatabase__ConnectionString: ${{ secrets.LEGACY_DATABASE_CONNECTION_STRING }}
LegacySchema__BaselineFile: Initial Documents/Access_Schema.txt
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
run: dotnet restore campaign-tracker.sln
- name: Block release on legacy schema drift
run: dotnet run --project Campaign_Tracker.Server -- --check-legacy-schema

+ 2
- 0
.gitignore Voir le fichier

@@ -21,3 +21,5 @@ Campaign_Tracker.Server/appsettings.Development.json
development-data/
Dockerfile
docker-compose.yml
Campaign_Tracker.Server/audit-logs/
Campaign_Tracker.Server/legacy-schema-history.jsonl

+ 3
- 0
Campaign_Tracker.Server.Tests/AuthEndpointTests.cs Voir le fichier

@@ -35,6 +35,9 @@ public sealed class AuthIntegrationTestFactory : WebApplicationFactory<Program>
builder.UseSetting("Keycloak:ClientId", ClientId);
builder.UseSetting("Keycloak:DisableHttpsMetadata", "true");
builder.UseSetting("Keycloak:TestSigningKey", SigningKey);
builder.UseSetting("LegacySchema:HistoryFile",
Path.Combine(Path.GetTempPath(), $"campaign-tracker-schema-history-{Guid.NewGuid():N}.jsonl"));
builder.UseSetting("LegacyLinkIntegrity:Enabled", "false");

builder.ConfigureServices(services =>
{


+ 63
- 0
Campaign_Tracker.Server.Tests/ExtensionRecordControllerTests.cs Voir le fichier

@@ -0,0 +1,63 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;

namespace Campaign_Tracker.Server.Tests;

public sealed class ExtensionRecordControllerTests
{
[Fact]
public async Task SaveExtensionRecord_StoresRequiredLegacyReference_AC1()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", AuthIntegrationTestFactory.CreateToken("admin@example.test", "admin"));

var response = await client.PostAsJsonAsync("/api/admin/extension-records", new
{
recordType = "MunicipalityProfile",
recordId = "mp-001",
legacyLink = new { type = 0, value = "FAIR01" },
});

var body = await response.Content.ReadFromJsonAsync<ExtensionRecordResponse>();

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.Equal("MunicipalityProfile", body.RecordType);
Assert.Equal("mp-001", body.RecordId);
Assert.Equal("JurisdictionJCode", body.LinkType);
Assert.Equal("FAIR01", body.LinkValue);
}

[Fact]
public async Task SaveExtensionRecord_InvalidLegacyReference_IsRejectedBeforeSave_AC3()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", AuthIntegrationTestFactory.CreateToken("admin@example.test", "admin"));

var response = await client.PostAsJsonAsync("/api/admin/extension-records", new
{
recordType = "MunicipalityProfile",
recordId = "mp-ghost",
legacyLink = new { type = 0, value = "NOPE" },
});

var body = await response.Content.ReadFromJsonAsync<ExtensionRecordValidationProblem>();

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.NotNull(body);
Assert.Contains("NOPE", body.Error);
}

private sealed record ExtensionRecordResponse(
string RecordType,
string RecordId,
string LinkType,
string LinkValue);

private sealed record ExtensionRecordValidationProblem(string Error);
}

+ 33
- 3
Campaign_Tracker.Server.Tests/LegacyLinkIntegrityServiceTests.cs Voir le fichier

@@ -27,7 +27,8 @@ public sealed class LegacyLinkIntegrityServiceTests

var report = await sut.CheckAsync();

Assert.True(report.IsConsistent);
Assert.False(report.IsConsistent);
Assert.Equal(0, report.ProviderCount);
Assert.Equal(0, report.TotalRecords);
Assert.Equal(0, report.FailedRecords);
Assert.Equal(100.0, report.ConsistencyPercentage);
@@ -41,18 +42,23 @@ public sealed class LegacyLinkIntegrityServiceTests
public async Task CheckAsync_AllValidLinks_Reports100PercentConsistency_AC4()
{
var data = new InMemoryLegacyDataAccess(
jurisdictions: [new("FAIR01", "Fairview", null, null, null, null)]);
jurisdictions:
[
new("FAIR01", "Fairview", null, null, null, null),
new("LAKE02", "Lake", null, null, null, null),
]);

var provider = new StubProvider(
[
new StubRecord("MunicipalityProfile", "mp-001", LegacyLinkReference.ForJurisdiction("FAIR01")),
new StubRecord("MunicipalityProfile", "mp-002", LegacyLinkReference.ForJurisdiction("FAIR01")),
new StubRecord("MunicipalityProfile", "mp-002", LegacyLinkReference.ForJurisdiction("LAKE02")),
]);

var sut = BuildSut(data, provider);
var report = await sut.CheckAsync();

Assert.True(report.IsConsistent);
Assert.Equal(1, report.ProviderCount);
Assert.Equal(2, report.TotalRecords);
Assert.Equal(2, report.ConsistentRecords);
Assert.Equal(0, report.FailedRecords);
@@ -76,6 +82,7 @@ public sealed class LegacyLinkIntegrityServiceTests
var report = await sut.CheckAsync();

Assert.False(report.IsConsistent);
Assert.Equal(1, report.ProviderCount);
Assert.Equal(1, report.TotalRecords);
Assert.Equal(0, report.ConsistentRecords);
Assert.Equal(1, report.FailedRecords);
@@ -133,10 +140,33 @@ public sealed class LegacyLinkIntegrityServiceTests
var report = await sut.CheckAsync();

Assert.True(report.IsConsistent);
Assert.Equal(2, report.ProviderCount);
Assert.Equal(2, report.TotalRecords);
Assert.Equal(0, report.FailedRecords);
}

[Fact]
public async Task CheckAsync_DuplicateActiveLinks_FlagsAmbiguousJoin_AC2_AC4()
{
var data = new InMemoryLegacyDataAccess(
jurisdictions: [new("FAIR01", "Fairview", null, null, null, null)]);

var provider = new StubProvider(
[
new StubRecord("MunicipalityProfile", "mp-001", LegacyLinkReference.ForJurisdiction("FAIR01")),
new StubRecord("ElectionJob", "ej-001", LegacyLinkReference.ForJurisdiction("FAIR01")),
]);

var sut = BuildSut(data, provider);
var report = await sut.CheckAsync();

Assert.False(report.IsConsistent);
Assert.Equal(2, report.TotalRecords);
Assert.Equal(1, report.FailedRecords);
Assert.Contains(report.Failures, failure =>
failure.Reason.Contains("ambiguous", StringComparison.OrdinalIgnoreCase));
}

// ── Helpers ───────────────────────────────────────────────────────────────

private sealed class StubRecord(string recordType, string recordId, LegacyLinkReference link)


+ 14
- 0
Campaign_Tracker.Server.Tests/LegacyLinkValidatorTests.cs Voir le fichier

@@ -161,6 +161,13 @@ public sealed class LegacyLinkValidatorTests
Assert.Equal("101", ref_.Value);
}

[Fact]
public void ForKit_NonPositiveId_Throws_AC1()
{
Assert.Throws<ArgumentOutOfRangeException>(() => LegacyLinkReference.ForKit(0));
Assert.Throws<ArgumentOutOfRangeException>(() => LegacyLinkReference.ForKit(-1));
}

[Fact]
public void ForContact_SetsCorrectTypeAndValue_AC1()
{
@@ -170,6 +177,13 @@ public sealed class LegacyLinkValidatorTests
Assert.Equal("42", ref_.Value);
}

[Fact]
public void ForContact_NonPositiveId_Throws_AC1()
{
Assert.Throws<ArgumentOutOfRangeException>(() => LegacyLinkReference.ForContact(0));
Assert.Throws<ArgumentOutOfRangeException>(() => LegacyLinkReference.ForContact(-1));
}

[Fact]
public void ForJurisdiction_BlankJCode_Throws_AC1()
{


+ 66
- 0
Campaign_Tracker.Server/Controllers/ExtensionRecordsController.cs Voir le fichier

@@ -0,0 +1,66 @@
using Campaign_Tracker.Server.Authorization;
using Campaign_Tracker.Server.ExtensionData;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Campaign_Tracker.Server.Controllers;

[ApiController]
[Authorize(Policy = ApplicationPolicy.AdminAccess)]
[Route("api/admin/extension-records")]
public sealed class ExtensionRecordsController : ControllerBase
{
private readonly IExtensionRecordStore _store;

public ExtensionRecordsController(IExtensionRecordStore store)
{
_store = store;
}

[HttpPost]
public async Task<ActionResult<ExtensionRecordResponse>> Save(
ExtensionRecordRequest request,
CancellationToken cancellationToken)
{
if (request.LegacyLink is null)
{
return BadRequest(new ExtensionRecordValidationProblem("Legacy reference is required."));
}

var result = await _store.SaveAsync(
new ExtensionRecordDraft(
request.RecordType,
request.RecordId,
new LegacyLinkReference(request.LegacyLink.Type, request.LegacyLink.Value)),
cancellationToken);

if (!result.Saved || result.Record is null)
{
return BadRequest(new ExtensionRecordValidationProblem(
result.Error ?? "Extension record could not be saved."));
}

return Ok(ExtensionRecordResponse.From(result.Record));
}
}

public sealed record ExtensionRecordRequest(
string RecordType,
string RecordId,
LegacyLinkRequest? LegacyLink);

public sealed record LegacyLinkRequest(
LegacyLinkType Type,
string Value);

public sealed record ExtensionRecordResponse(
string RecordType,
string RecordId,
string LinkType,
string LinkValue)
{
public static ExtensionRecordResponse From(ILegacyLinkedRecord record) =>
new(record.RecordType, record.RecordId, record.LegacyLink.Type.ToString(), record.LegacyLink.Value);
}

public sealed record ExtensionRecordValidationProblem(string Error);

+ 2
- 0
Campaign_Tracker.Server/Controllers/LegacyLinkController.cs Voir le fichier

@@ -60,6 +60,7 @@ public sealed class LegacyLinkController : ControllerBase
public sealed record LegacyLinkIntegrityResponse(
bool IsConsistent,
DateTimeOffset CheckedAt,
int ProviderCount,
int TotalRecords,
int ConsistentRecords,
int FailedRecords,
@@ -69,6 +70,7 @@ public sealed record LegacyLinkIntegrityResponse(
public static LegacyLinkIntegrityResponse From(LegacyLinkIntegrityReport report) =>
new(report.IsConsistent,
report.CheckedAt,
report.ProviderCount,
report.TotalRecords,
report.ConsistentRecords,
report.FailedRecords,


+ 27
- 0
Campaign_Tracker.Server/ExtensionData/IExtensionRecordStore.cs Voir le fichier

@@ -0,0 +1,27 @@
namespace Campaign_Tracker.Server.ExtensionData;

public interface IExtensionRecordStore
{
Task<ExtensionRecordSaveResult> SaveAsync(
ExtensionRecordDraft draft,
CancellationToken cancellationToken = default);

Task<IReadOnlyList<ILegacyLinkedRecord>> GetAllAsync(CancellationToken cancellationToken = default);
}

public sealed record ExtensionRecordDraft(
string RecordType,
string RecordId,
LegacyLinkReference LegacyLink);

public sealed record ExtensionRecordSaveResult(
bool Saved,
string? Error,
ILegacyLinkedRecord? Record)
{
public static ExtensionRecordSaveResult Success(ILegacyLinkedRecord record) =>
new(true, null, record);

public static ExtensionRecordSaveResult Failure(string error) =>
new(false, error, null);
}

+ 90
- 0
Campaign_Tracker.Server/ExtensionData/InMemoryExtensionRecordStore.cs Voir le fichier

@@ -0,0 +1,90 @@
using System.Collections.Concurrent;

namespace Campaign_Tracker.Server.ExtensionData;

public sealed class InMemoryExtensionRecordStore : IExtensionRecordStore, ILegacyLinkedRecordProvider
{
private readonly ConcurrentDictionary<string, StoredExtensionRecord> _records = new();
private readonly ILegacyLinkValidator _validator;

public InMemoryExtensionRecordStore(ILegacyLinkValidator validator)
{
_validator = validator;
}

public async Task<ExtensionRecordSaveResult> SaveAsync(
ExtensionRecordDraft draft,
CancellationToken cancellationToken = default)
{
var validationError = ValidateDraft(draft);
if (validationError is not null)
{
return ExtensionRecordSaveResult.Failure(validationError);
}

var linkValidation = await _validator.ValidateAsync(draft.LegacyLink, cancellationToken);
if (!linkValidation.IsValid)
{
return ExtensionRecordSaveResult.Failure(linkValidation.Error ?? "Legacy link is invalid.");
}

var duplicate = _records.Values.FirstOrDefault(record =>
!string.Equals(record.RecordType, draft.RecordType, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(record.RecordId, draft.RecordId, StringComparison.OrdinalIgnoreCase)
? SameLink(record.LegacyLink, draft.LegacyLink)
: false);
if (duplicate is not null)
{
return ExtensionRecordSaveResult.Failure(
$"Legacy reference '{draft.LegacyLink}' is already linked by {duplicate.RecordType} '{duplicate.RecordId}'.");
}

var stored = new StoredExtensionRecord(draft.RecordType.Trim(), draft.RecordId.Trim(), draft.LegacyLink);
_records[BuildKey(stored.RecordType, stored.RecordId)] = stored;
return ExtensionRecordSaveResult.Success(stored);
}

public Task<IReadOnlyList<ILegacyLinkedRecord>> GetAllAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<ILegacyLinkedRecord>>(
_records.Values
.OrderBy(record => record.RecordType, StringComparer.OrdinalIgnoreCase)
.ThenBy(record => record.RecordId, StringComparer.OrdinalIgnoreCase)
.ToArray());

private static string? ValidateDraft(ExtensionRecordDraft draft)
{
if (string.IsNullOrWhiteSpace(draft.RecordType))
{
return "Extension record type is required.";
}

if (string.IsNullOrWhiteSpace(draft.RecordId))
{
return "Extension record ID is required.";
}

if (draft.LegacyLink is null)
{
return "Legacy reference is required.";
}

if (string.IsNullOrWhiteSpace(draft.LegacyLink.Value))
{
return $"{draft.LegacyLink.Type} legacy reference value is required.";
}

return null;
}

private static string BuildKey(string recordType, string recordId) =>
$"{recordType.Trim().ToUpperInvariant()}:{recordId.Trim().ToUpperInvariant()}";

private static bool SameLink(LegacyLinkReference left, LegacyLinkReference right) =>
left.Type == right.Type &&
string.Equals(left.Value, right.Value, StringComparison.OrdinalIgnoreCase);

private sealed record StoredExtensionRecord(
string RecordType,
string RecordId,
LegacyLinkReference LegacyLink) : ILegacyLinkedRecord;
}

+ 82
- 0
Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityHostedService.cs Voir le fichier

@@ -0,0 +1,82 @@
using Microsoft.Extensions.Options;

namespace Campaign_Tracker.Server.ExtensionData;

public sealed class LegacyLinkIntegrityOptions
{
public bool Enabled { get; init; } = true;
public TimeSpan RunTimeLocal { get; init; } = new(2, 0, 0);
}

public sealed class LegacyLinkIntegrityHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<LegacyLinkIntegrityHostedService> _logger;
private readonly LegacyLinkIntegrityOptions _options;

public LegacyLinkIntegrityHostedService(
IServiceScopeFactory scopeFactory,
ILogger<LegacyLinkIntegrityHostedService> logger,
IOptions<LegacyLinkIntegrityOptions> options)
{
_scopeFactory = scopeFactory;
_logger = logger;
_options = options.Value;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Enabled)
{
_logger.LogInformation("Legacy link nightly integrity check scheduler is disabled.");
return;
}

while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(GetDelayUntilNextRun(DateTimeOffset.Now, _options.RunTimeLocal), stoppingToken);
await RunOnceAsync(stoppingToken);
}
}

private async Task RunOnceAsync(CancellationToken cancellationToken)
{
try
{
using var scope = _scopeFactory.CreateScope();
var check = scope.ServiceProvider.GetRequiredService<ILegacyLinkIntegrityCheck>();
var report = await check.CheckAsync(cancellationToken);

if (report.IsConsistent)
{
_logger.LogInformation(
"Legacy link nightly integrity check passed: {Consistent}/{Total} records ({Percentage:F2}%).",
report.ConsistentRecords, report.TotalRecords, report.ConsistencyPercentage);
}
else
{
_logger.LogError(
"Legacy link nightly integrity check failed: {Failures}/{Total} records failed ({Percentage:F2}%).",
report.FailedRecords, report.TotalRecords, report.ConsistencyPercentage);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
_logger.LogError(ex, "Legacy link nightly integrity check could not complete.");
}
}

private static TimeSpan GetDelayUntilNextRun(DateTimeOffset now, TimeSpan runTimeLocal)
{
var next = new DateTimeOffset(now.Date + runTimeLocal, now.Offset);
if (next <= now)
{
next = next.AddDays(1);
}

return next - now;
}
}

+ 2
- 1
Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityReport.cs Voir le fichier

@@ -6,6 +6,7 @@ namespace Campaign_Tracker.Server.ExtensionData;
/// </summary>
public sealed record LegacyLinkIntegrityReport(
DateTimeOffset CheckedAt,
int ProviderCount,
int TotalRecords,
int ConsistentRecords,
int FailedRecords,
@@ -13,7 +14,7 @@ public sealed record LegacyLinkIntegrityReport(
IReadOnlyList<LegacyLinkIntegrityFailure> Failures)
{
/// <summary>True when no failures were detected (or no records exist to check).</summary>
public bool IsConsistent => FailedRecords == 0;
public bool IsConsistent => ProviderCount > 0 && FailedRecords == 0;
}

/// <summary>Describes one extension record that failed to resolve its legacy link.</summary>


+ 20
- 1
Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs Voir le fichier

@@ -26,9 +26,11 @@ public sealed class LegacyLinkIntegrityService : ILegacyLinkIntegrityCheck
public async Task<LegacyLinkIntegrityReport> CheckAsync(CancellationToken cancellationToken = default)
{
var failures = new List<LegacyLinkIntegrityFailure>();
var seenLinks = new Dictionary<string, ILegacyLinkedRecord>(StringComparer.OrdinalIgnoreCase);
var providers = _providers.ToArray();
int total = 0;

foreach (var provider in _providers)
foreach (var provider in providers)
{
var records = await provider.GetAllAsync(cancellationToken);
foreach (var record in records)
@@ -43,6 +45,22 @@ public sealed class LegacyLinkIntegrityService : ILegacyLinkIntegrityCheck
record.LegacyLink,
result.Error ?? "Unknown validation error"));
}

var linkKey = $"{record.LegacyLink.Type}:{record.LegacyLink.Value}";
if (seenLinks.TryGetValue(linkKey, out var existing) &&
(!string.Equals(existing.RecordType, record.RecordType, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(existing.RecordId, record.RecordId, StringComparison.OrdinalIgnoreCase)))
{
failures.Add(new LegacyLinkIntegrityFailure(
record.RecordType,
record.RecordId,
record.LegacyLink,
$"Legacy reference is also linked by {existing.RecordType} '{existing.RecordId}', creating an ambiguous active-record join."));
}
else
{
seenLinks[linkKey] = record;
}
}
}

@@ -51,6 +69,7 @@ public sealed class LegacyLinkIntegrityService : ILegacyLinkIntegrityCheck

return new LegacyLinkIntegrityReport(
CheckedAt: _timeProvider.GetUtcNow(),
ProviderCount: providers.Length,
TotalRecords: total,
ConsistentRecords: consistent,
FailedRecords: failures.Count,


+ 6
- 2
Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs Voir le fichier

@@ -14,10 +14,14 @@ public sealed record LegacyLinkReference(LegacyLinkType Type, string Value)
}

public static LegacyLinkReference ForKit(int id) =>
new(LegacyLinkType.KitId, id.ToString(System.Globalization.CultureInfo.InvariantCulture));
id <= 0
? throw new ArgumentOutOfRangeException(nameof(id), "Kit ID must be greater than zero.")
: new(LegacyLinkType.KitId, id.ToString(System.Globalization.CultureInfo.InvariantCulture));

public static LegacyLinkReference ForContact(int id) =>
new(LegacyLinkType.ContactId, id.ToString(System.Globalization.CultureInfo.InvariantCulture));
id <= 0
? throw new ArgumentOutOfRangeException(nameof(id), "Contact ID must be greater than zero.")
: new(LegacyLinkType.ContactId, id.ToString(System.Globalization.CultureInfo.InvariantCulture));

public override string ToString() => $"{Type}:{Value}";
}

+ 8
- 0
Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs Voir le fichier

@@ -50,6 +50,10 @@ public sealed class LegacyLinkValidator : ILegacyLinkValidator
return LegacyLinkValidationResult.Failure(
$"Kit identifier '{rawId}' is not a valid integer ID.");

if (id <= 0)
return LegacyLinkValidationResult.Failure(
$"Kit identifier '{rawId}' must be greater than zero.");

var record = await _legacyData.GetKitByIdAsync(id, cancellationToken);
return record is null
? LegacyLinkValidationResult.Failure(
@@ -65,6 +69,10 @@ public sealed class LegacyLinkValidator : ILegacyLinkValidator
return LegacyLinkValidationResult.Failure(
$"Contact identifier '{rawId}' is not a valid integer ID.");

if (id <= 0)
return LegacyLinkValidationResult.Failure(
$"Contact identifier '{rawId}' must be greater than zero.");

var record = await _legacyData.GetContactByIdAsync(id, cancellationToken);
return record is null
? LegacyLinkValidationResult.Failure(


+ 55
- 0
Campaign_Tracker.Server/LegacyData/Schema/FileLegacySchemaCheckHistory.cs Voir le fichier

@@ -0,0 +1,55 @@
using System.Text.Json;

namespace Campaign_Tracker.Server.LegacyData.Schema;

public sealed class FileLegacySchemaCheckHistory : ILegacySchemaCheckHistory
{
private readonly string _filePath;
private readonly object _fileLock = new();

private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};

public FileLegacySchemaCheckHistory(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("History file path is required.", nameof(filePath));
}

_filePath = filePath;
Directory.CreateDirectory(Path.GetDirectoryName(_filePath) ?? ".");
}

public void Record(LegacySchemaCheckResult result)
{
ArgumentNullException.ThrowIfNull(result);
var line = JsonSerializer.Serialize(result, JsonOptions) + Environment.NewLine;
lock (_fileLock)
{
File.AppendAllText(_filePath, line);
}
}

public IReadOnlyList<LegacySchemaCheckResult> GetRecent(int maxCount = 50)
{
if (maxCount <= 0 || !File.Exists(_filePath))
{
return [];
}

lock (_fileLock)
{
return File.ReadLines(_filePath)
.Where(line => !string.IsNullOrWhiteSpace(line))
.Select(line => JsonSerializer.Deserialize<LegacySchemaCheckResult>(line, JsonOptions))
.Where(result => result is not null)
.Cast<LegacySchemaCheckResult>()
.OrderByDescending(result => result.CheckedAt)
.Take(maxCount)
.ToArray();
}
}
}

+ 40
- 8
Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs Voir le fichier

@@ -50,12 +50,20 @@ public static class LegacySchemaBaselineParser
}

var trimmed = line.TrimStart();
if (!trimmed.StartsWith("Column:", StringComparison.Ordinal)) continue;
if (!trimmed.StartsWith("Column:", StringComparison.Ordinal))
{
throw new FormatException($"Unrecognized legacy schema line: {line}");
}

currentColumns.Add(ParseColumn(trimmed));
}

FlushTable(tables, currentTable, currentColumns);
if (tables.Count == 0)
{
throw new FormatException("Legacy schema baseline did not contain any table definitions.");
}

return new LegacySchemaBaseline(tables, source, capturedAt);
}

@@ -72,9 +80,10 @@ public static class LegacySchemaBaselineParser
{
// "Column: NAME Type: 130 Size: 255 Nullable: True"
var name = ReadField(line, "Column:", ["Type:"]);
var typeRaw = ReadField(line, "Type:", ["Size:", "Nullable:"]);
var sizeRaw = ReadField(line, "Size:", ["Nullable:"]);
var nullableRaw = ReadField(line, "Nullable:", []);
var typeRaw = ReadField(line, "Type:", ["Size:", "Nullable:", "Constraints:"]);
var sizeRaw = ReadField(line, "Size:", ["Nullable:", "Constraints:"]);
var nullableRaw = ReadField(line, "Nullable:", ["Constraints:"]);
var constraintsRaw = ReadField(line, "Constraints:", []);

if (!int.TryParse(typeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var typeCode))
{
@@ -82,14 +91,25 @@ public static class LegacySchemaBaselineParser
}

int? size = null;
if (!string.IsNullOrWhiteSpace(sizeRaw) &&
int.TryParse(sizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedSize))
if (!string.IsNullOrWhiteSpace(sizeRaw))
{
if (!int.TryParse(sizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedSize))
{
throw new FormatException($"Invalid Size value in column line: {line}");
}

size = parsedSize;
}

var nullable = string.Equals(nullableRaw, "True", StringComparison.OrdinalIgnoreCase);
return new LegacyColumnDefinition(name, typeCode, size, nullable);
if (!bool.TryParse(nullableRaw, out var nullable))
{
throw new FormatException($"Invalid Nullable value in column line: {line}");
}

return new LegacyColumnDefinition(name, typeCode, size, nullable)
{
Constraints = ParseConstraints(constraintsRaw),
};
}

private static string ReadField(string line, string label, string[] terminators)
@@ -107,4 +127,16 @@ public static class LegacySchemaBaselineParser

return line[start..end].Trim();
}

private static IReadOnlyList<string> ParseConstraints(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return [];
}

return value
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.ToArray();
}
}

+ 1
- 0
Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCheckResult.cs Voir le fichier

@@ -13,6 +13,7 @@ public enum LegacySchemaChangeType
ColumnTypeChanged,
ColumnSizeChanged,
ColumnNullabilityChanged,
ColumnConstraintsChanged,
}

/// <summary>


+ 19
- 0
Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCompatibilityCheck.cs Voir le fichier

@@ -110,6 +110,15 @@ public sealed class LegacySchemaCompatibilityCheck : ILegacySchemaCompatibilityC
tableName, name, LegacySchemaChangeType.ColumnNullabilityChanged,
$"Column '{tableName}.{name}' nullability changed: baseline={baseCol.Nullable}, live={liveCol.Nullable}."));
}

var baselineConstraints = NormalizeConstraints(baseCol.Constraints);
var liveConstraints = NormalizeConstraints(liveCol.Constraints);
if (!baselineConstraints.SequenceEqual(liveConstraints, StringComparer.OrdinalIgnoreCase))
{
sink.Add(new LegacySchemaDrift(
tableName, name, LegacySchemaChangeType.ColumnConstraintsChanged,
$"Column '{tableName}.{name}' constraints changed: baseline={FormatConstraints(baselineConstraints)}, live={FormatConstraints(liveConstraints)}."));
}
}

foreach (var (name, _) in liveByName)
@@ -124,4 +133,14 @@ public sealed class LegacySchemaCompatibilityCheck : ILegacySchemaCompatibilityC
}

private static string Format(int? value) => value?.ToString() ?? "(none)";

private static IReadOnlyList<string> NormalizeConstraints(IReadOnlyList<string> constraints) =>
constraints
.Where(c => !string.IsNullOrWhiteSpace(c))
.Select(c => c.Trim())
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();

private static string FormatConstraints(IReadOnlyList<string> constraints) =>
constraints.Count == 0 ? "(none)" : string.Join(",", constraints);
}

+ 4
- 1
Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs Voir le fichier

@@ -9,7 +9,10 @@ public sealed record LegacyColumnDefinition(
string Name,
int TypeCode,
int? Size,
bool Nullable);
bool Nullable)
{
public IReadOnlyList<string> Constraints { get; init; } = [];
}

/// <summary>
/// Represents a legacy table's full structural definition: the table name plus


+ 113
- 0
Campaign_Tracker.Server/LegacyData/Schema/OleDbLegacySchemaInspector.cs Voir le fichier

@@ -0,0 +1,113 @@
using System.Data;
using System.Data.OleDb;
using System.Runtime.Versioning;

namespace Campaign_Tracker.Server.LegacyData.Schema;

[SupportedOSPlatform("windows")]
public sealed class OleDbLegacySchemaInspector : ILegacySchemaInspector
{
private readonly string _connectionString;

public OleDbLegacySchemaInspector(string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException("Legacy database connection string is required.", nameof(connectionString));
}

_connectionString = connectionString;
}

public async Task<IReadOnlyList<LegacyTableDefinition>> GetCurrentSchemaAsync(
CancellationToken cancellationToken = default)
{
await using var connection = new OleDbConnection(_connectionString);
await connection.OpenAsync(cancellationToken);

var schema = connection.GetOleDbSchemaTable(OleDbSchemaGuid.Columns, null)
?? throw new InvalidOperationException("OleDb provider did not return column schema metadata.");

var tables = schema.Rows
.Cast<DataRow>()
.GroupBy(row => GetRequiredString(row, "TABLE_NAME"), StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => new LegacyTableDefinition(
group.Key,
group
.OrderBy(row => GetOrdinal(row))
.Select(row => new LegacyColumnDefinition(
GetRequiredString(row, "COLUMN_NAME"),
GetRequiredInt(row, "DATA_TYPE"),
GetNullableInt(row, "CHARACTER_MAXIMUM_LENGTH"),
GetNullableBool(row, "IS_NULLABLE") ?? true)
{
Constraints = GetColumnConstraints(row),
})
.ToArray()))
.ToArray();

return tables;
}

private static int GetOrdinal(DataRow row) =>
GetNullableInt(row, "ORDINAL_POSITION") ?? int.MaxValue;

private static IReadOnlyList<string> GetColumnConstraints(DataRow row)
{
var constraints = new List<string>();
return constraints;
}

private static string GetRequiredString(DataRow row, string columnName)
{
var value = row.Table.Columns.Contains(columnName) ? row[columnName] : null;
var text = value is null or DBNull ? null : Convert.ToString(value);
return string.IsNullOrWhiteSpace(text)
? throw new InvalidOperationException($"OleDb schema row is missing required field {columnName}.")
: text;
}

private static int GetRequiredInt(DataRow row, string columnName) =>
GetNullableInt(row, columnName)
?? throw new InvalidOperationException($"OleDb schema row is missing required field {columnName}.");

private static int? GetNullableInt(DataRow row, string columnName)
{
if (!row.Table.Columns.Contains(columnName) || row[columnName] is DBNull)
{
return null;
}

return Convert.ToInt32(row[columnName]);
}

private static bool? GetNullableBool(DataRow row, string columnName)
{
if (!row.Table.Columns.Contains(columnName) || row[columnName] is DBNull)
{
return null;
}

var value = row[columnName];
if (value is bool flag)
{
return flag;
}

var text = Convert.ToString(value);
if (string.Equals(text, "YES", StringComparison.OrdinalIgnoreCase) ||
string.Equals(text, "TRUE", StringComparison.OrdinalIgnoreCase))
{
return true;
}

if (string.Equals(text, "NO", StringComparison.OrdinalIgnoreCase) ||
string.Equals(text, "FALSE", StringComparison.OrdinalIgnoreCase))
{
return false;
}

return null;
}
}

+ 31
- 4
Campaign_Tracker.Server/Program.cs Voir le fichier

@@ -72,17 +72,36 @@ else
// when running against a live Access database.
var schemaBaselinePath = builder.Configuration["LegacySchema:BaselineFile"]
?? Path.Combine(builder.Environment.ContentRootPath, "..", "Initial Documents", "Access_Schema.txt");
var schemaHistoryPath = builder.Configuration["LegacySchema:HistoryFile"]
?? Path.Combine(builder.Environment.ContentRootPath, "legacy-schema-history.jsonl");
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
builder.Services.AddSingleton(_ =>
LegacySchemaBaselineParser.ParseFile(Path.GetFullPath(schemaBaselinePath), DateTimeOffset.UtcNow));
builder.Services.AddSingleton<ILegacySchemaInspector>(sp =>
new InMemoryLegacySchemaInspector(sp.GetRequiredService<LegacySchemaBaseline>().Tables));
if (!string.IsNullOrWhiteSpace(legacyConnectionString))
{
if (!OperatingSystem.IsWindows())
{
throw new PlatformNotSupportedException(
"OleDb legacy schema inspection is supported only on Windows.");
}

builder.Services.AddSingleton<ILegacySchemaInspector>(_ =>
#pragma warning disable CA1416
new OleDbLegacySchemaInspector(legacyConnectionString));
#pragma warning restore CA1416
}
else
{
builder.Services.AddSingleton<ILegacySchemaInspector>(sp =>
new InMemoryLegacySchemaInspector(sp.GetRequiredService<LegacySchemaBaseline>().Tables));
}
builder.Services.AddSingleton<ILegacySchemaCompatibilityCheck>(sp =>
new LegacySchemaCompatibilityCheck(
sp.GetRequiredService<LegacySchemaBaseline>(),
sp.GetRequiredService<ILegacySchemaInspector>(),
sp.GetRequiredService<TimeProvider>()));
builder.Services.AddSingleton<ILegacySchemaCheckHistory, InMemoryLegacySchemaCheckHistory>();
builder.Services.AddSingleton<ILegacySchemaCheckHistory>(_ =>
new FileLegacySchemaCheckHistory(Path.GetFullPath(schemaHistoryPath)));

builder.Services.AddHttpClient<IKeycloakTokenClient, KeycloakTokenClient>();
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationAuditResultHandler>();
@@ -91,8 +110,16 @@ builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, Authorizati
// ILegacyLinkValidator resolves references through the anti-corruption layer (AC #2, AC #3).
// ILegacyLinkIntegrityCheck scans all registered extension record providers (AC #4, NFR13).
// Additional ILegacyLinkedRecordProvider registrations are added by each extension record story.
builder.Services.AddScoped<ILegacyLinkValidator, LegacyLinkValidator>();
builder.Services.Configure<LegacyLinkIntegrityOptions>(
builder.Configuration.GetSection("LegacyLinkIntegrity"));
builder.Services.AddSingleton<ILegacyLinkValidator, LegacyLinkValidator>();
builder.Services.AddScoped<ILegacyLinkIntegrityCheck, LegacyLinkIntegrityService>();
builder.Services.AddSingleton<InMemoryExtensionRecordStore>();
builder.Services.AddSingleton<IExtensionRecordStore>(sp =>
sp.GetRequiredService<InMemoryExtensionRecordStore>());
builder.Services.AddSingleton<ILegacyLinkedRecordProvider>(sp =>
sp.GetRequiredService<InMemoryExtensionRecordStore>());
builder.Services.AddHostedService<LegacyLinkIntegrityHostedService>();

var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? [];
builder.Services.AddCors(options =>


+ 61
- 0
Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl Voir le fichier

@@ -327,3 +327,64 @@
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBH0QQ3G1Q:00000001","recordedAt":"2026-05-06T15:58:09.1841212+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/logout","outcome":"allowed","traceIdentifier":"0HNLBH0QQ3G1Q:00000001","recordedAt":"2026-05-06T15:58:09.1851939+00:00"}
{"eventType":"SESSION_LOGOUT","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/logout","outcome":"success","traceIdentifier":"0HNLBH0QQ3G1Q:00000001","recordedAt":"2026-05-06T15:58:09.2199773+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/token/exchange","outcome":"success","traceIdentifier":"0HNLBISPGHVT4:00000001","recordedAt":"2026-05-06T17:45:02.1990176+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBISPGHVT5:00000001","recordedAt":"2026-05-06T17:45:02.40076+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/session","outcome":"allowed","traceIdentifier":"0HNLBISPGHVT5:00000001","recordedAt":"2026-05-06T17:45:02.4130954+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/admin/legacy-schema/history","outcome":"denied","traceIdentifier":"0HNLBISPGHVT6:00000001","recordedAt":"2026-05-06T17:45:06.3269769+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/admin/legacy-schema/history","outcome":"denied","traceIdentifier":"0HNLBISPGHVT7:00000001","recordedAt":"2026-05-06T17:45:06.3492109+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/admin/legacy-schema/check","outcome":"denied","traceIdentifier":"0HNLBISPGHVT8:00000001","recordedAt":"2026-05-06T17:45:07.434817+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBISPGHVT9:00000001","recordedAt":"2026-05-06T17:45:17.01555+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/session","outcome":"allowed","traceIdentifier":"0HNLBISPGHVT9:00000001","recordedAt":"2026-05-06T17:45:17.0168423+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBISPGHVTA:00000001","recordedAt":"2026-05-06T17:45:20.8083727+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/logout","outcome":"allowed","traceIdentifier":"0HNLBISPGHVTA:00000001","recordedAt":"2026-05-06T17:45:20.809983+00:00"}
{"eventType":"SESSION_LOGOUT","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/logout","outcome":"success","traceIdentifier":"0HNLBISPGHVTA:00000001","recordedAt":"2026-05-06T17:45:20.8508567+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7C6","recordedAt":"2026-05-06T17:48:37.5725213+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7C6","recordedAt":"2026-05-06T17:48:37.5789618+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7C8","recordedAt":"2026-05-06T17:48:37.5873879+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7C8","recordedAt":"2026-05-06T17:48:37.5882285+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7C9","recordedAt":"2026-05-06T17:48:37.5928807+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7C9","recordedAt":"2026-05-06T17:48:37.59443+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CA","recordedAt":"2026-05-06T17:48:37.5970639+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CA","recordedAt":"2026-05-06T17:48:37.5980842+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CB","recordedAt":"2026-05-06T17:48:37.6022739+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CB","recordedAt":"2026-05-06T17:48:37.6031774+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CE","recordedAt":"2026-05-06T17:48:37.6052188+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CE","recordedAt":"2026-05-06T17:48:37.6055721+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CF","recordedAt":"2026-05-06T17:48:37.6079186+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CF","recordedAt":"2026-05-06T17:48:37.6086937+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CG","recordedAt":"2026-05-06T17:48:37.6096937+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CG","recordedAt":"2026-05-06T17:48:37.6101421+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"admin@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CH","recordedAt":"2026-05-06T17:48:37.614982+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"admin@example.test","resource":"/api/admin/privileged-operation","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CH","recordedAt":"2026-05-06T17:48:37.6154427+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"unknown@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CI","recordedAt":"2026-05-06T17:48:37.6208276+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"unknown@example.test","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CI","recordedAt":"2026-05-06T17:48:37.6212823+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"client@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CJ","recordedAt":"2026-05-06T17:48:37.6397775+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"client@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CJ","recordedAt":"2026-05-06T17:48:37.640385+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CM","recordedAt":"2026-05-06T17:48:37.6460037+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CM","recordedAt":"2026-05-06T17:48:37.6464905+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CO","recordedAt":"2026-05-06T17:48:37.6509621+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671H7","recordedAt":"2026-05-06T17:49:02.3645376+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671H7","recordedAt":"2026-05-06T17:49:02.369804+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671H9","recordedAt":"2026-05-06T17:49:02.3762871+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIV1671H9","recordedAt":"2026-05-06T17:49:02.3769022+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HA","recordedAt":"2026-05-06T17:49:02.3803493+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"allowed","traceIdentifier":"0HNLBIV1671HA","recordedAt":"2026-05-06T17:49:02.3813788+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HB","recordedAt":"2026-05-06T17:49:02.3827131+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"allowed","traceIdentifier":"0HNLBIV1671HB","recordedAt":"2026-05-06T17:49:02.3833535+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HC","recordedAt":"2026-05-06T17:49:02.3870859+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671HC","recordedAt":"2026-05-06T17:49:02.3876718+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HD","recordedAt":"2026-05-06T17:49:02.3901251+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIV1671HD","recordedAt":"2026-05-06T17:49:02.3905976+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HE","recordedAt":"2026-05-06T17:49:02.3924979+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"denied","traceIdentifier":"0HNLBIV1671HE","recordedAt":"2026-05-06T17:49:02.3929208+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HH","recordedAt":"2026-05-06T17:49:02.3944284+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"denied","traceIdentifier":"0HNLBIV1671HH","recordedAt":"2026-05-06T17:49:02.3952695+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"admin@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HI","recordedAt":"2026-05-06T17:49:02.401648+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"admin@example.test","resource":"/api/admin/privileged-operation","outcome":"allowed","traceIdentifier":"0HNLBIV1671HI","recordedAt":"2026-05-06T17:49:02.4022759+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"unknown@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HJ","recordedAt":"2026-05-06T17:49:02.4081559+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"unknown@example.test","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIV1671HJ","recordedAt":"2026-05-06T17:49:02.4090346+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"client@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HK","recordedAt":"2026-05-06T17:49:02.4255111+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"client@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671HK","recordedAt":"2026-05-06T17:49:02.4259259+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HM","recordedAt":"2026-05-06T17:49:02.4317157+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671HM","recordedAt":"2026-05-06T17:49:02.432207+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIV1671HO","recordedAt":"2026-05-06T17:49:02.4360749+00:00"}

+ 17
- 1
_bmad-output/implementation-artifacts/1-7-legacy-schema-compatibility-validation-gate.md Voir le fichier

@@ -1,6 +1,6 @@
# Story 1.7: Legacy Schema Compatibility Validation Gate

Status: review
Status: done

## Story

@@ -41,6 +41,16 @@ so that every release can be gated on legacy integrity before deployment.
- [x] Frontend: 11 new vitest specs; full vitest run green (28/28); typecheck and lint clean; production build clean
- [x] Manual smoke: `dotnet run --check-legacy-schema` in Development prints `PASS — 9 tables verified` and exits 0

### Review Findings

- [x] [Review][Patch] Schema check compares the approved baseline to itself instead of the live Access schema [Campaign_Tracker.Server/Program.cs:77]
- [x] [Review][Patch] Constraint drift is not represented or compared despite AC #1 requiring constraint comparison [Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs:8]
- [x] [Review][Patch] Admin schema panel calls protected endpoints without the authenticated bearer-token fetch path [campaign-tracker-client/src/admin/legacySchemaContracts.ts:20]
- [x] [Review][Patch] Release gate is only an optional CLI hook; no repository release pipeline invokes `--check-legacy-schema` automatically [Campaign_Tracker.Server/Program.cs:245]
- [x] [Review][Patch] Release-gate run history is recorded only in a short-lived in-memory CLI process, so gate failures are not surfaced through admin history [Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaReleaseGate.cs:28]
- [x] [Review][Patch] Baseline parser can silently accept malformed or incomplete schema input, producing a false approved baseline [Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs:52]
- [x] [Review][Defer] Runtime audit log JSONL files are tracked and contain actor identities, resources, trace IDs, and timestamps [Campaign_Tracker.Server/audit-logs/audit-2026-05-05.jsonl:1] - deferred, pre-existing

## Dev Notes

- Follow Epic 1 architecture constraints: ASP.NET Core + React separation, RBAC-aware patterns, and immutable legacy tables.
@@ -84,6 +94,8 @@ claude-sonnet-4-6
- **Auditing** — Manual runs flow through the existing shared `IAuditService` from Story 1.5; no parallel audit pipeline was introduced.
- **Tests** — 16 backend `xunit` tests cover parser format, full-file load, every drift category, pass-result invariants, release-gate exit codes (pass and fail), CLI flag detection, history ordering, controller authorization (Forbidden without Admin), happy-path admin flow, and a drift scenario where the inspector is replaced via DI to confirm the failure report flows to the API. 5 new client `vitest` specs cover summary formatting, POST/GET behavior, and error propagation. All 86 backend tests and 28 client tests pass.

- **Review fixes** - Live schema inspection now uses `OleDbLegacySchemaInspector` whenever `LegacyDatabase:ConnectionString` is configured; development/test retains the in-memory inspector. Schema check history is durable via `FileLegacySchemaCheckHistory`, baseline parsing now fails fast on malformed input, constraint drift has a dedicated change type, the admin panel uses authenticated API fetch, and `.gitea/workflows/release-gates.yml` invokes `--check-legacy-schema`.

### File List

- `Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs`
@@ -91,12 +103,15 @@ claude-sonnet-4-6
- `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCheckResult.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/ILegacySchemaInspector.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/OleDbLegacySchemaInspector.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/ILegacySchemaCompatibilityCheck.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCompatibilityCheck.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/ILegacySchemaCheckHistory.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/FileLegacySchemaCheckHistory.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaReleaseGate.cs`
- `Campaign_Tracker.Server/Controllers/LegacySchemaController.cs`
- `Campaign_Tracker.Server/Program.cs`
- `.gitea/workflows/release-gates.yml`
- `Campaign_Tracker.Server.Tests/LegacySchemaCompatibilityTests.cs`
- `campaign-tracker-client/src/admin/legacySchemaContracts.ts`
- `campaign-tracker-client/src/admin/legacySchemaContracts.test.ts`
@@ -110,3 +125,4 @@ claude-sonnet-4-6
| Date | Version | Description | Author |
| --- | --- | --- | --- |
| 2026-05-06 | 1.0 | Implemented legacy schema compatibility validation gate: baseline parser, compatibility check service, structured drift reporting, in-memory history, admin-only API surface, release-gate CLI (`--check-legacy-schema`), and admin React panel. 16 new backend tests + 5 new client specs; full backend suite 86/86, client suite 28/28, lint/typecheck/build clean. Story moved to review. | claude-sonnet-4-6 |
| 2026-05-06 | 1.1 | Closed review findings: live OleDb inspector, durable schema history, stricter parser, constraint comparison, authenticated admin API calls, and release-gate workflow. Backend suite 111/111, client tests 28/28, solution build, lint, and client build clean. | Codex |

+ 14
- 1
_bmad-output/implementation-artifacts/1-8-legacy-identifier-linking-for-extension-records.md Voir le fichier

@@ -1,6 +1,6 @@
# Story 1.8: Legacy Identifier Linking for Extension Records

Status: review
Status: done

## Story

@@ -30,6 +30,15 @@ so that all new capabilities join deterministically to legacy Access records in
- [x] Verify build/tests for touched modules
- [x] Capture changed files and any migration/config implications

### Review Findings

- [x] [Review][Patch] No concrete extension record create/save path stores a required legacy reference for municipality profiles, election jobs, or service configs [Campaign_Tracker.Server/Program.cs:93]
- [x] [Review][Patch] Invalid legacy identifiers are not rejected before save because no production save path calls `ILegacyLinkValidator` [Campaign_Tracker.Server/Controllers/LegacyLinkController.cs:36]
- [x] [Review][Patch] Legacy-link validation treats any non-null lookup as unambiguous and does not detect duplicate or active-record ambiguity [Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs:38]
- [x] [Review][Patch] Nightly integrity checking is not scheduled; only a manual/admin POST endpoint exists [Campaign_Tracker.Server/Controllers/LegacyLinkController.cs:32]
- [x] [Review][Patch] Integrity check reports 100 percent consistency when no record providers are registered, which can mask missing provider coverage [Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs:50]
- [x] [Review][Patch] Kit and contact link factories accept zero or negative IDs as valid-looking references [Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs:16]

## Dev Notes

- Follow Epic 1 architecture constraints: ASP.NET Core + React separation, RBAC-aware patterns, and immutable legacy tables.
@@ -73,6 +82,8 @@ claude-sonnet-4-6
- 19 unit tests cover all four ACs across validator and integrity service.
- Stories 1.10–1.13 will register their own `ILegacyLinkedRecordProvider` implementations to feed the nightly check.

- Review fixes introduced a concrete admin extension-record save API backed by an in-memory extension record store/provider, validation-before-save, duplicate active-link detection, positive ID guards, provider coverage reporting, and a nightly hosted integrity scheduler.

### File List

- `Campaign_Tracker.Server/ExtensionData/LegacyLinkType.cs` (new)
@@ -94,4 +105,6 @@ claude-sonnet-4-6

## Change Log

- 2026-05-06: Review fixes completed - concrete extension-record save path, pre-save validation, ambiguity checks, provider coverage guard, nightly scheduler, and ID domain validation added. Backend suite 111/111, client tests 28/28, solution build, lint, and client build clean. (Codex)

- 2026-05-06: Story 1.8 implemented — legacy identifier linking infrastructure introduced in new `ExtensionData` namespace. Validator, integrity check service, admin API endpoint, and 19 unit tests added. All 4 ACs satisfied. (claude-sonnet-4-6)

+ 4
- 0
_bmad-output/implementation-artifacts/deferred-work.md Voir le fichier

@@ -6,3 +6,7 @@
## Deferred from: code review of 1-4-keycloak-role-mapping-application-authorization.md (2026-05-06)

- AuthorizationProbeController ships canned operational routes in the production controller surface. Evidence: Campaign_Tracker.Server/Controllers/AuthorizationProbeController.cs:8. Reason: deferred by user choice during review.

## Deferred from: code review of 1-7-legacy-schema-compatibility-validation-gate.md (2026-05-06)

- Runtime audit log JSONL files are tracked and contain actor identities, resources, trace IDs, and timestamps. Evidence: Campaign_Tracker.Server/audit-logs/audit-2026-05-05.jsonl:1. Reason: pre-existing hygiene issue from the broader prior commit, not specific to Story 1.7 or 1.8 behavior.

+ 3
- 3
_bmad-output/implementation-artifacts/sprint-status.yaml Voir le fichier

@@ -35,7 +35,7 @@
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)

generated: '2026-05-05T12:00:44-04:00'
last_updated: '2026-05-06T15:00:00-04:00'
last_updated: '2026-05-06T13:52:00-04:00'
project: 'Campaign_Tracker App'
project_key: 'NOKEY'
tracking_system: 'file-system'
@@ -49,8 +49,8 @@ development_status:
1-4-keycloak-role-mapping-application-authorization: done
1-5-shared-audit-logging-infrastructure: done
1-6-legacy-anti-corruption-data-access-layer: done
1-7-legacy-schema-compatibility-validation-gate: review
1-8-legacy-identifier-linking-for-extension-records: review
1-7-legacy-schema-compatibility-validation-gate: done
1-8-legacy-identifier-linking-for-extension-records: done
1-9-seed-system-reference-values-rule-defaults: ready-for-dev
1-10-municipality-account-profile: ready-for-dev
1-11-municipality-operational-addresses: ready-for-dev


+ 14
- 1
campaign-tracker-client/src/App.tsx Voir le fichier

@@ -4,9 +4,11 @@ import './App.css'
import {
buildKeycloakAuthorizationUrl,
getKeycloakClientConfig,
authenticatedFetch,
logout,
oidcStateStorageKey,
submitKeycloakLogout,
storeAuthTokenSet,
} from './auth/authContracts'
import { useOidcSession } from './auth/useOidcSession'
import { WorkspaceShell } from './workspace/WorkspaceShell'
@@ -32,9 +34,20 @@ function App() {
)
}, [session, config])

const adminFetch = useCallback(
async (input: RequestInfo | URL, init: RequestInit = {}) => {
if (session.status !== 'authenticated') {
throw new Error('Admin request requires an authenticated session')
}

return authenticatedFetch(input, init, config, session.tokens, storeAuthTokenSet)
},
[config, session],
)

const content =
session.status === 'authenticated' ? (
<WorkspaceShell user={session.user} onLogout={handleLogout} />
<WorkspaceShell user={session.user} onLogout={handleLogout} adminFetch={adminFetch} />
) : session.status === 'error' ? (
<Result status="warning" title={session.error} />
) : (


+ 10
- 1
campaign-tracker-client/src/workspace/WorkspaceShell.tsx Voir le fichier

@@ -34,6 +34,10 @@ import {
} from './workspaceContracts'
import type { AuthenticatedUser } from '../auth/authContracts'
import { LegacySchemaCheckPanel } from '../admin/LegacySchemaCheckPanel'
import {
fetchLegacySchemaCheckHistory,
runLegacySchemaCheck,
} from '../admin/legacySchemaContracts'
import './WorkspaceShell.css'

const { Header, Sider, Content } = Layout
@@ -257,9 +261,11 @@ function RiskPanel({
export function WorkspaceShell({
user,
onLogout,
adminFetch,
}: {
user: AuthenticatedUser
onLogout: () => Promise<void>
adminFetch: typeof fetch
}) {
const width = useViewportWidth()
const editingAvailable = isEditingAvailable(width)
@@ -387,7 +393,10 @@ export function WorkspaceShell({
/>
) : null}
{selectedView === 'admin' && user.permissions.canAccessAdmin ? (
<LegacySchemaCheckPanel />
<LegacySchemaCheckPanel
loadHistory={() => fetchLegacySchemaCheckHistory(adminFetch)}
runCheck={() => runLegacySchemaCheck(adminFetch)}
/>
) : (
<section
className="workspace-board"


Chargement…
Annuler
Enregistrer

Powered by TurnKey Linux.