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
5.7 KiB
Code Generation Summary — U2 Data Durability
Date: 2026-07-27 Requirements: FR-11, FR-12
Files Created
| Path | Purpose |
|---|---|
src/SlpModularCms.Core/Hosting/DataProtectionExtensions.cs |
AddCmsDataProtection() — database key ring, fixed discriminator |
src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs |
MigrateCoreDatabase() — startup migration with failure classification |
src/SlpModularCms.Core/Migrations/20260727203036_AddDataProtectionKeys.cs |
Keys table migration |
src/SlpModularCms.Core.Tests/Hosting/DataProtectionExtensionsTests.cs |
4 tests |
src/SlpModularCms.Core.Tests/Hosting/DatabaseMigrationExtensionsTests.cs |
7 tests |
src/SlpModularCms.Modules.Availability.Tests/AvailabilityModuleDataProtectionTests.cs |
1 test |
src/SlpModularCms.Modules.Master.Tests/MasterModuleDataProtectionTests.cs |
1 test |
Files Modified
| Path | Change |
|---|---|
src/SlpModularCms.Core/SlpModularCms.Core.csproj |
Microsoft.AspNetCore.DataProtection.EntityFrameworkCore 10.0.9 |
src/SlpModularCms.Core/Data/ApplicationDbContext.cs |
Implements IDataProtectionKeyContext; DataProtectionKeys set |
src/SlpModularCms.Modules.Availability/AvailabilityModule.cs |
AddDataProtection() removed |
src/SlpModularCms.Modules.Master/MasterModule.cs |
AddDataProtection() removed |
src/SlpModularCms.Api/Program.cs |
AddCmsDataProtection() before module registration; MigrateCoreDatabase() after build |
src/SlpModularCms.Api.Slave/Program.cs |
Same |
No duplicate or parallel files were created.
Implementation Decisions
The removals are the point of this unit, so they are commented in place
Deleting services.AddDataProtection() from two modules looks like a regression to anyone who does not know the ordering issue. Both call sites therefore carry a comment explaining that the host owns Data Protection and that a bare call here would silently discard the persistent key store.
The tests assert configuration, not registration
A test asserting "Data Protection is registered" passes in both the broken and fixed cases, because IDataProtector resolves either way. Every test here inspects the resulting configuration instead:
KeyManagementOptions.XmlRepositoryisEntityFrameworkCoreXmlRepository<ApplicationDbContext>— not the filesystem defaultDataProtectionOptions.ApplicationDiscriminatoris the fixed constant — not the path-derived default- A protected value survives a simulated restart from a different release directory, which is the property that actually matters
- The discriminator contains no path separator, guarding against a future "improvement" that makes it computed or configurable
The module-level tests live in each module's own test project rather than in Core.Tests, because Core.Tests does not reference the modules. Each registers the host's Data Protection first and the module second — the real ordering — and asserts the EF repository survives.
SqlException is produced genuinely, not faked
SqlException has no public constructor. Rather than substituting a stand-in type, the test provokes a real one by opening a connection to an unreachable host with a one-second timeout. The classifier is therefore exercised against the exact type it will meet in production.
Wrong credentials are classified as a connection failure
Distinguishing bad credentials from an unreachable server would add branching for no benefit: retries are exhausted and the process does not start either way. The simpler classification is the honest one.
Migration verified as additive
The generated migration only creates a table — no dropped or narrowed columns. Rollback by redeploying an earlier release therefore stays safe, which BR-U2-16 requires and which the whole rollback strategy depends on.
Operational Constraints Enforced by Documentation, Not Code
Both were decided deliberately (U2 FD Q4 = C, Q5 = C). They must appear in the Operations deployment instructions:
| Constraint | Why it is not enforced in code |
|---|---|
The DataProtectionKeys table must never be pruned. Deleting a key makes every value encrypted with it permanently unreadable, including stored slave API keys. |
Nothing in the application deletes these rows; the risk comes from a human treating the table as housekeeping. It is the single most destructive maintenance action available against this system, and it looks harmless. |
| Only one instance may migrate a given database at a time. | One instance per database holds by design today — the Master and each slave have their own. A distributed migration lock would add failure modes without removing any. If the deployment model ever changes to multiple instances sharing a database, automatic startup migration must be revisited before that change is made. |
Also recorded for Operations: DEV-05 — keys are stored unencrypted at rest, with TLS on the database connection and a non-public database as the compensating controls (BR-U2-08). These are not optional extras; they are what makes the deviation acceptable.
Verification
| Check | Result |
|---|---|
dotnet build SlpModularCms.sln -c Release |
✅ 0 errors |
SlpModularCms.Core.Tests |
✅ 83 passed |
SlpModularCms.Modules.Availability.Tests |
✅ 82 passed |
SlpModularCms.Modules.Identity.Tests |
✅ 37 passed |
SlpModularCms.Modules.Master.Tests |
✅ 51 passed |
| Migration is purely additive | ✅ Inspected — creates one table, drops nothing |
No failures occurred during generation.
Not verifiable at this stage: the composed startup path (MigrateCoreDatabase against a real database, and both hosts actually starting) requires SQL Server. Carried to the phase-level Build and Test stage.