Files
slp-modular-cms/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/business-logic-model.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

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:

  1. 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
  2. Schema convergenceApplicationDbContext migrates 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.