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
6.5 KiB
Domain Entities — U2 Data Durability
U2 adds one persisted entity and one migration. Everything else in the unit is configuration and startup behaviour.
Entity Relationships
graph TD
appctx["ApplicationDbContext<br/>implements IDataProtectionKeyContext"]
key["DataProtectionKey<br/>NEW"]
user["ApplicationUser<br/>existing"]
refresh["RefreshToken<br/>existing"]
invite["Invitation<br/>existing"]
avail["GlobalAvailabilityState<br/>existing"]
protector["IDataProtector<br/>derived from keys"]
cmsinst["CmsInstance<br/>MasterDbContext"]
mastreg["MasterRegistration<br/>AvailabilityDbContext"]
appctx -->|"owns"| key
appctx -->|"owns"| user
appctx -->|"owns"| refresh
appctx -->|"owns"| invite
appctx -->|"owns"| avail
key -->|"derives"| protector
protector -->|"encrypts API key of"| cmsinst
protector -->|"encrypts API key of"| mastreg
classDef ctx fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef newent fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef existing fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
classDef derived fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef consumer fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
class appctx ctx;
class key newent;
class user,refresh,invite,avail existing;
class protector derived;
class cmsinst,mastreg consumer;
Text alternative: the Core context gains a Data Protection keys table alongside its existing Identity entities; those keys derive the protector that encrypts the API keys stored on CMS instances and master registrations in the two module contexts.
Cross-context dependency worth noting: the keys live in ApplicationDbContext, while the values they protect live in MasterDbContext and AvailabilityDbContext. There is no foreign key between them — all three contexts share one database, but the relationship is behavioural, not relational. Losing the keys does not produce a referential-integrity error; it produces rows whose encrypted column can no longer be read. That is exactly why the failure is silent.
DataProtectionKey (new)
Provided by the framework via IDataProtectionKeyContext; the schema is not authored by this project.
| Field | Type | Purpose |
|---|---|---|
Id |
int, identity | Primary key |
FriendlyName |
string, nullable | Human-readable key identifier |
Xml |
string | The serialized key material |
Constraints and rules
| Aspect | Rule |
|---|---|
| Owning context | ApplicationDbContext (Q7 of Application Design = A) |
| Migration | One new Core migration, applied automatically at startup by FR-11 |
| Encryption at rest | None — see DEV-05. Xml contains usable key material in plain text |
| Retention | Rows are never deleted (BR-U2-06) |
| Rotation | Framework default, 90 days; new rows are added, old rows retained |
| Access | Only through the Data Protection API. No application code reads or writes this table directly |
Why Xml being plaintext matters: anyone who can read this table can decrypt every stored slave API key. This is the substance of DEV-05, and why BR-U2-08 requires TLS on the connection and a database that is not publicly reachable. Those compensating controls are not optional extras — they are what makes the deviation acceptable.
ApplicationDbContext (modified)
| Change | Detail |
|---|---|
| Interface | Implements IDataProtectionKeyContext |
| New set | DbSet<DataProtectionKey> DataProtectionKeys |
| Existing sets | Unchanged — Identity, RefreshToken, Invitation, ModulePermission, GlobalAvailabilityState |
| Migration behaviour | Changed: now migrates automatically at startup (FR-11). Previously required a manual dotnet ef database update |
Note on the behaviour change: automatic migration is a genuine change in operational semantics, not merely a convenience. Previously a schema change reached production only when a human ran a command; now it happens whenever a new release starts. This is why forward-compatible, non-destructive migrations (BR-U2-16) and a pre-deploy backup (FR-20) are load-bearing rather than nice to have.
Configuration Values
U2 introduces no new appsettings section.
| Value | Source | Rationale |
|---|---|---|
| Application discriminator | Constant in code | Q1 = A. Cannot be misconfigured, forgotten, or made to differ between instances sharing a database |
| Key lifetime | Framework default (90 days) | Q5 = C. No reason to differ |
| Migration retry attempts and delays | Constants in code | Values chosen to cover a host-reboot window; not an operational tuning knob |
| Connection string | Existing ConnectionStrings:DefaultConnection |
Unchanged. BR-U2-08 requires TLS to be enforced in it |
Why nothing is configurable here: every value in this unit exists to prevent a silent failure. A configuration surface would be a way to reintroduce that failure — a discriminator set wrong on one instance, or a key lifetime set so short that rotation outpaces retention.
Persistence Summary
| Question | Answer |
|---|---|
| New tables? | One — the Data Protection keys table |
| New migrations? | One, in SlpModularCms.Core |
| Modified entities? | None. ApplicationDbContext gains a set but no existing entity changes |
| Destructive schema changes? | None. Purely additive, so rollback by redeploying an earlier release stays safe |
| New configuration? | None |
Verification Targets
What this unit's tests must actually prove, given that the failure mode is silent:
| Target | Why it needs asserting |
|---|---|
| The persistent key store survives module registration | Registration alone passes in both the broken and fixed cases — only the resulting configuration distinguishes them |
| The application discriminator is the fixed constant | The default would change per release directory, defeating persistence |
| A protected value round-trips across a simulated content-root change | This is the actual user-visible property: an API key encrypted before a deploy is still readable after it |
| Neither module registers Data Protection | Prevents the conflict from being reintroduced by a future change to either module |
| A connection failure retries; a migration failure does not | The two paths differ deliberately (BR-U2-11, BR-U2-12) |
| Both hosts still start | SlpModularCms.Api.Slave has no test project and is a reference instance (Q2 of Application Design = A) |