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
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.csprojto addMicrosoft.AspNetCore.DataProtection.EntityFrameworkCoreversion 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
- Implement
Step 3: Data Protection registration
- Create
src/SlpModularCms.Core/Hosting/DataProtectionExtensions.cswithAddCmsDataProtection() - 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.cswithMigrateCoreDatabase() - Apply
ApplicationDbContextmigrations 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— removeservices.AddDataProtection() - Modify
src/SlpModularCms.Modules.Master/MasterModule.cs— removeservices.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— callAddCmsDataProtection()beforeorchestrator.RegisterModuleServices(...)(BR-U2-03), andMigrateCoreDatabase()afterbuilder.Build()and beforeorchestrator.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 Releasedotnet testforSlpModularCms.Core.Tests,SlpModularCms.Modules.Availability.TestsandSlpModularCms.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