Files
slp-modular-cms/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-code-generation-plan.md
T
SluijsensandClaude Opus 5 5f3eda2680 Makes a redeploy safe for the key ring and the schema
Nothing here is visible in normal operation. Its whole purpose is that
swapping the release directory on deploy cannot silently destroy state.

Data Protection secures the API keys that authenticate master/slave
communication. Two separate defaults would each have destroyed them:
keys are held on the filesystem, which a release swap discards, and the
application discriminator is derived from the content root path, which
changes with every release directory — so even keys stored in a database
would have stopped being derivable. Keys now live in
ApplicationDbContext and the discriminator is a fixed constant.

Losing them produces no error. It produces stored keys that no longer
decrypt, which presents as an apparent network fault between a Master
and its slaves and is easily misdiagnosed. That is also why the tests
assert the resulting configuration rather than the registration: the
XmlRepository must be the EF one and the discriminator must be the
constant, plus a round-trip proving a value encrypted before a deploy is
readable after one. A test that only checked "Data Protection is
registered" would have passed in the broken case too.

Both modules previously called AddDataProtection() themselves. Module
registration runs after the host's, so those calls re-registered the
configuration chain and would have overridden the persistent store while
IDataProtector still resolved. They are removed, with a comment at each
site — the deletion otherwise looks like a regression. Each module's own
test project now guards against it being reintroduced.

ApplicationDbContext also migrates itself at startup. Deploy targets
offer no CLI, so migrations cannot be a manual step on the server.
Failures are classified rather than treated alike: a connection failure
means the database is not up yet, normal when the app and the database
start together after a reboot, and is retried with backoff; a migration
failure means something is broken and fails at once. Either way the
process does not start, which is what makes the liveness health check
trustworthy — an application that cannot reach its schema never answers
/health, so monitoring goes red instead of reporting a healthy instance
that cannot serve a request.

The cost of migrating without a human gate is that migrations must stay
forward-compatible and non-destructive, since rollback is "redeploy the
previous release". The new migration is purely additive.

Also wires this and the preceding hosting commit into both hosts, as
they touch the same lines of Program.cs.

Two constraints are enforced by documentation rather than code, and
belong in the deployment instructions: the key table must never be
pruned, and only one instance may migrate a given database at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
2026-07-28 00:00:45 +02:00

8.6 KiB

Code Generation Plan — U2 Data Durability

This plan is the single source of truth for Code Generation of U2. Generation executes exactly these steps in order; no step is added or skipped during execution.


Unit Context

Aspect Detail
Unit U2 Data Durability
Round R1 (with U1 Hosting & Serving)
Workspace root K:\Development\Projects\SlpModularCms
Project type Brownfield — existing structure retained, files modified in place
Requirements FR-11, FR-12
Components C-05 Data Protection, C-06 keys table, C-07 migration runner, U2 portion of C-16
Business rules BR-U2-01 … BR-U2-18
Depends on Nothing. U1 and U2 are mutually independent
Depended on by U6 — durability must land before the first automated deploy
New database entities One: the Data Protection keys table, owned by ApplicationDbContext

Requirement traceability

Requirement Implemented by steps
FR-11 — automatic ApplicationDbContext migration at startup 4, 5, 6
FR-12 — persistent Data Protection key ring 1, 2, 3, 5, 6

Why this unit exists

Nothing here is user-visible. Its whole purpose is that U6's atomic release switch cannot silently destroy schema state or Master↔slave trust. Two failure modes are being closed, both of which would otherwise appear only after the first production deploy and present as something else entirely.


Generation Steps

Step 1: Package reference

  • Modify src/SlpModularCms.Core/SlpModularCms.Core.csproj to add Microsoft.AspNetCore.DataProtection.EntityFrameworkCore version 10.0.9, matching the existing 10.0.x line

Step 2: Keys table on the Core context

  • Modify src/SlpModularCms.Core/Data/ApplicationDbContext.cs:
    • Implement IDataProtectionKeyContext
    • Add DbSet<DataProtectionKey> DataProtectionKeys
    • Leave every existing entity configuration untouched

Step 3: Data Protection registration

  • Create src/SlpModularCms.Core/Hosting/DataProtectionExtensions.cs with AddCmsDataProtection()
  • Configure PersistKeysToDbContext<ApplicationDbContext>() (BR-U2-01)
  • Set the application discriminator to a fixed constant in code (BR-U2-02) — not configurable, not derived from any path
  • Leave key lifetime at the framework default of 90 days (BR-U2-05)
  • Document in code why the discriminator is a constant: the default derives from the content root path, which changes on every atomic release switch

Step 4: Startup migration runner

  • Create src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs with MigrateCoreDatabase()
  • Apply ApplicationDbContext migrations before the application accepts traffic (BR-U2-09)
  • Classify failures (BR-U2-11, BR-U2-12): retry connection failures with increasing delay up to a bounded number of attempts; fail migration failures immediately with no retry
  • Log the reason before failing, with diagnostic context but no connection string, credentials or secrets (BR-U2-14)
  • Propagate the exception when retries are exhausted or the failure is a migration failure, so the process does not start (BR-U2-13)

Step 5: Remove the conflicting module registrations

  • Modify src/SlpModularCms.Modules.Availability/AvailabilityModule.cs — remove services.AddDataProtection()
  • Modify src/SlpModularCms.Modules.Master/MasterModule.cs — remove services.AddDataProtection()
  • Leave every other registration in both modules unchanged; they continue consuming IDataProtector (BR-U2-04)

This is the § 5.1 conflict. Module registration runs after the host's, so these bare calls would override the persistent key store. IDataProtector resolves either way, so the defect would surface only after the first release switch as slave API keys that no longer decrypt — presenting as a network fault between Master and slave.

Step 6: Host composition

  • Modify src/SlpModularCms.Api/Program.cs — call AddCmsDataProtection() before orchestrator.RegisterModuleServices(...) (BR-U2-03), and MigrateCoreDatabase() after builder.Build() and before orchestrator.UseModules(app) (BR-U2-10)
  • Modify src/SlpModularCms.Api.Slave/Program.cs — the same two calls in the same positions

Step 7: Core migration

  • Generate the EF Core migration for the keys table into src/SlpModularCms.Core/Migrations/
  • Verify the migration is purely additive — no dropped or narrowed columns, so redeploying an earlier release stays safe (BR-U2-16)

Step 8: Data Protection unit tests

  • Create src/SlpModularCms.Core.Tests/Hosting/DataProtectionExtensionsTests.cs:
    • The persistent key store survives module registration — the highest-value assertion in this unit, since registration alone passes in both the broken and fixed cases
    • The application discriminator is the fixed constant, not a path-derived value
    • A protected value round-trips across a simulated content-root change
    • Neither module registers Data Protection, so the conflict cannot be reintroduced by a future change

Step 9: Migration runner unit tests

  • Create src/SlpModularCms.Core.Tests/Hosting/DatabaseMigrationExtensionsTests.cs:
    • A connection failure is retried
    • A migration failure is not retried and fails immediately
    • Retry exhaustion propagates
    • Failure logging contains no connection string or credentials

Step 10: Documentation

  • Create aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/code/generation-summary.md — files created and modified, decisions taken, and any deviation from this plan
  • Record the two operational constraints that are enforced by documentation rather than code, for later inclusion in the Operations deployment instructions: the keys table must never be pruned (BR-U2-06), and only one instance may migrate a given database at a time (BR-U2-17)

Step 11: Build and test verification (automatic)

  • dotnet build SlpModularCms.sln -c Release
  • dotnet test for SlpModularCms.Core.Tests, SlpModularCms.Modules.Availability.Tests and SlpModularCms.Modules.Master.Tests
  • Fix any failure directly and re-run until green
  • Record the outcome for the completion message

Files Touched

Created

Path Purpose
src/SlpModularCms.Core/Hosting/DataProtectionExtensions.cs Persistent key ring registration
src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs Startup migration with failure classification
src/SlpModularCms.Core/Migrations/*_AddDataProtectionKeys.cs Keys table migration
src/SlpModularCms.Core.Tests/Hosting/DataProtectionExtensionsTests.cs Tests
src/SlpModularCms.Core.Tests/Hosting/DatabaseMigrationExtensionsTests.cs Tests

Modified

Path Change
src/SlpModularCms.Core/SlpModularCms.Core.csproj Add the Data Protection EF Core package
src/SlpModularCms.Core/Data/ApplicationDbContext.cs Implement IDataProtectionKeyContext, add the keys set
src/SlpModularCms.Modules.Availability/AvailabilityModule.cs Remove AddDataProtection()
src/SlpModularCms.Modules.Master/MasterModule.cs Remove AddDataProtection()
src/SlpModularCms.Api/Program.cs Data Protection registration and startup migration
src/SlpModularCms.Api.Slave/Program.cs Same

Brownfield rule: every file above that exists is modified in place. No parallel copies.


Risk Notes for the Executor

Risk Mitigation in this plan
A test that merely asserts "Data Protection is registered" passes in both the broken and fixed cases Step 8 asserts the resulting configuration, not the registration
The discriminator silently reverting to the path-derived default Step 8 asserts the constant explicitly
The new migration being non-additive and breaking rollback Step 7 verifies additivity
Startup migration masking a genuine migration fault by retrying it Step 4 classifies failures; Step 9 asserts the classification
Both hosts must still start Step 11 builds the whole solution; the composed startup is verified at the phase-level Build and Test stage, where the Slave — which has no test project — is started

Out of Scope for U2

  • Static content, health endpoint, availability-gate changes — U1
  • Security headers — U3
  • Sentry, Umami, frontend configuration — U4
  • Anything under .gitea/ — U5 and U6
  • Certificate-based key encryption — deferred as DEV-05 follow-up
  • A distributed migration lock — Q4 = C, handled by documented operational constraint