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
9.5 KiB
Business Logic Model — U2 Data Durability
Unit: U2 Data Durability Requirements: FR-11, FR-12
1. Scope of the Logic
U2 delivers nothing a user can see. Its entire value is negative: after this unit, a redeploy cannot silently destroy schema state or the trust relationship between a Master and its slaves.
Two mechanisms:
- Key ring durability — Data Protection keys move from the filesystem (discarded by every atomic release switch) into the database, with an application discriminator that does not change when the release directory does
- Schema convergence —
ApplicationDbContextmigrates itself at startup, so a deployment needs no CLI access to the host
Both are startup-time concerns. Neither participates in request handling.
2. Startup Sequence
graph TD
boot["Host builder starts"]
log["Configure logging and Sentry"]
disc["Discover modules"]
core["AddCoreInfrastructure"]
dp["AddCmsDataProtection<br/>persistent key store plus<br/>fixed application discriminator"]
mods["Module RegisterServices<br/>AddDataProtection removed from both"]
build["Build application"]
mig["Migrate ApplicationDbContext"]
classify{"Failure type ?"}
retry["Retry with backoff"]
fail["Propagate: process does not start"]
usemods["UseModules<br/>module contexts migrate"]
serve["Accept traffic"]
boot --> log
log --> disc
disc --> core
core --> dp
dp --> mods
mods --> build
build --> mig
mig -->|success| usemods
mig -->|failure| classify
classify -->|"connection failure"| retry
classify -->|"migration failure"| fail
retry -->|"attempts remain"| mig
retry -->|"attempts exhausted"| fail
usemods --> serve
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef step fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef critical fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
class boot entry;
class log,disc,core,mods,build,usemods,serve step;
class dp,mig,classify,retry critical;
class fail bad;
Text alternative: Data Protection is configured with a persistent store before module registration, the Core context migrates before the module contexts, and a migration failure is classified — connection failures are retried with backoff while genuine migration failures stop the process immediately.
3. The Registration-Order Conflict
This is the unit's most important piece of logic, and it is a removal rather than an addition.
AvailabilityModule.RegisterServices and MasterModule.RegisterServices each call services.AddDataProtection() today. Module registration runs after the host's registration. In ASP.NET Core, a later AddDataProtection() re-registers the configuration chain, so the modules' bare calls would discard the persistent key store configured by the host.
graph TD
subgraph broken["Without the fix"]
h1["Host: AddCmsDataProtection<br/>persistent store configured"]
m1["AvailabilityModule: AddDataProtection"]
m2["MasterModule: AddDataProtection"]
r1["Result: filesystem key ring<br/>FR-12 silently ineffective"]
h1 --> m1
m1 --> m2
m2 --> r1
end
subgraph fixed["With the fix"]
h2["Host: AddCmsDataProtection<br/>persistent store configured"]
m3["Modules: no Data Protection call<br/>they consume IDataProtector only"]
r2["Result: database key ring<br/>survives release switches"]
h2 --> m3
m3 --> r2
end
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
classDef neutral fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
class h1,h2,m3 neutral;
class m1,m2,r1 bad;
class r2 good;
Text alternative: leaving the modules' own Data Protection calls in place would override the host's persistent key store and leave the key ring on the filesystem, whereas removing them lets the host's single configuration stand.
Why this is dangerous rather than merely wrong: registration tests pass either way — IDataProtector resolves in both cases. The defect appears only after the first atomic release switch, as slave API keys that no longer decrypt, presenting as a network fault between Master and slave. The verification for this unit must therefore assert the resulting configuration, not merely that Data Protection is registered.
4. Application Discriminator Stability
The application discriminator determines whether two processes derive the same keys. By default it is derived from the content root path — which changes on every atomic release switch. Persisting keys in the database while letting the discriminator move would produce keys that are stored but unusable: a second, quieter version of the same failure.
Per Q1 = A the discriminator is a fixed constant in code.
graph TD
r1["Release directory 1<br/>content root /srv/cms/releases/001"]
r2["Release directory 2<br/>content root /srv/cms/releases/002"]
disc["Fixed discriminator constant"]
keys[("Key ring in database")]
same["Same keys derived<br/>stored values stay readable"]
r1 --> disc
r2 --> disc
disc --> keys
keys --> same
classDef release fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef fixed fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef store fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
class r1,r2 release;
class disc fixed;
class keys store;
class same good;
Text alternative: two different release directories both use the same fixed discriminator, so the keys stored in the database remain derivable and previously encrypted values stay readable across deploys.
Why a constant rather than configuration (Q1 = A): it cannot be set wrong, forgotten during a host migration, or accidentally differ between two instances that share a database. A configurable value would add a way to reintroduce the exact failure this unit exists to prevent.
5. Migration Failure Classification
Per Q3 = C, failures are classified rather than treated uniformly:
| Failure kind | Meaning | Response |
|---|---|---|
| Connection failure | The database is not reachable yet — typically the app and SQL Server starting together after a host reboot | Retry with increasing delay, then fail |
| Migration failure | A migration is invalid, conflicts, or cannot be applied | Fail immediately, no retry |
Retrying a genuine migration failure would only delay the inevitable while making the log harder to read. Failing instantly on a transient connection error would make a host reboot look like a broken deployment.
Interaction with U1's health check: after retries are exhausted the exception propagates and the process does not start. /health then does not answer, and UptimeRobot goes red. That chain is the entire reason a liveness-only check is sufficient — it is meaningful precisely because startup is strict.
6. Migration Ordering
graph TD
corectx["ApplicationDbContext<br/>Identity, refresh tokens,<br/>invitations, Data Protection keys"]
availctx["AvailabilityDbContext<br/>master registration"]
masterctx["MasterDbContext<br/>CMS instances"]
protector["IDataProtector consumers<br/>encrypted API keys"]
corectx -->|"migrates first, at startup"| availctx
corectx -->|"migrates first, at startup"| masterctx
corectx -->|"keys table must exist before"| protector
availctx -->|"uses"| protector
masterctx -->|"uses"| protector
classDef core fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef consumer fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
class corectx core;
class availctx,masterctx module;
class protector consumer;
Text alternative: the Core context migrates first because it now owns the Data Protection keys table, which both module contexts depend on indirectly through their encrypted API key handling.
Why Core must be first: the keys table lives in ApplicationDbContext (Q7 of Application Design = A). Both modules encrypt and decrypt API keys. If a module migrated and immediately used an IDataProtector before the keys table existed, key generation would fail against a missing table.
Scope boundary: U2 adds automatic migration for ApplicationDbContext only. AvailabilityDbContext and MasterDbContext already migrate themselves in their UseModule implementations, and that existing behaviour is left untouched — changing it would alter module behaviour beyond this feature's scope.
7. Concurrent Migration
Per Q4 = C, no locking mechanism is built. The design assumption is one instance per database, which holds today: the Master and each slave have their own database.
This is documented in the deployment instructions as an operational constraint rather than enforced in code — building a distributed migration lock for a situation that cannot currently occur would add failure modes without removing any.
Recorded as an assumption: if the deployment model ever changes to multiple instances sharing one database, automatic startup migration must be revisited before that change is made.