Completes local-dev-master-slave-setup: dual-instance frontend tooling, module-capability gating, and master/slave protocol self-healing fixes

Frontend (Unit 2 completion): dual dev-server tooling (pnpm dev:slave,
pnpm dev:all), per-instance browser tab titles, and a backend
capability check (SystemController + useSystemCapabilities +
ModuleGuard) so a Master-only page is hidden on a slave instance
instead of assuming every backend has every module.

Master/slave protocol fixes surfaced by actually running master and
slave side by side locally:
- Deactivating a CMS instance (Inactive) now releases the slave's
  master gate instead of leaving it stuck on its last pushed status.
- The periodic integrity check now also re-pushes status to every
  reachable slave (previously URL-verification only) and runs once
  immediately on startup.
- Added the originally-specified (but never implemented) slave-pull
  path: a slave now periodically polls its own status from the master
  (GET /api/v1/SlaveStatus) and fails open to Available if the master
  is unreachable for too long, complementing the existing push.
- The slave's own Settings page can no longer "successfully" change
  local availability while the master controls it; it's now locked
  with an explanatory banner and the backend rejects the write with
  409 instead of silently no-op'ing it.
- CMS instance status badges now match the dashboard's color/icon
  styling instead of a plain grey badge.

Also corrected the master-cms-module design docs to match this
as-built behavior, and flagged (without a full rewrite) a larger,
pre-existing divergence between its inception-stage application
design and what construction actually built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 19:53:52 +02:00
co-authored by Claude Sonnet 5
parent 274946dbff
commit 0447993181
81 changed files with 2191 additions and 86 deletions
@@ -1,5 +1,17 @@
# Audit Log — Master CMS Module
## Documentation Audit & Correction — Post-`local-dev-master-slave-setup` Follow-Up Fixes
**Timestamp**: 2026-07-04T00:00:00Z
**User Input**: "ja zet het erbij en check alles ook tegenover de aidlc documentatie" (in response to a request to also add README docs for a new master-controlled availability lock, and to cross-check everything against the AI-DLC documentation)
**AI Response**: "Dispatched an Explore subagent to compare the functional-design docs here against the actual as-built code, given a string of same-day fixes (in the local-dev-master-slave-setup feature's session) that changed real master/slave protocol behavior: Inactive now releases the gate instead of doing nothing, VerifyIntegrityAsync now re-pushes status (not just URL-verification) and IntegrityCheckBackgroundService runs once on startup, a new slave-pull mechanism (SlaveStatusController + MasterStatusPollingBackgroundService + fail-open via MasterRegistration.LastPolledAt) was added, and PersistentAvailabilityService now merges/enforces the master gate (IsMasterControlled, MasterControlledAvailabilityException, 409 Conflict) instead of ignoring it. Found and corrected multiple actively-contradicted statements: construction/master-backend/functional-design/{business-rules,business-logic-model,domain-entities}.md (Inactive-transition push behavior, SlaveContactSuccess semantics, VerifyIntegrityAsync flow, added new Slave Pull and Master-Controlled-Lock rule sections); construction/slave-availability-extension/functional-design/{business-rules,business-logic-model,domain-entities}.md (master-gate cache no longer 'no expiry', added LastPolledAt field, added Rule Sets 5-6 and Flows 5-6 for the pull/fail-open and lock mechanisms); inception/application-design/application-design.md (Inactive/fail-open constraint rows). Also fixed one long-standing (pre-2026-07-04, unrelated to today) inaccuracy found in passing: slave-availability-extension/domain-entities.md claimed MasterRegistration.ApiKey is stored plain-text, but the code (MasterAvailabilityService using IMasterApiKeyProtector) actually encrypts it. Separately flagged (added superseded-warning banners rather than rewriting) a much larger, pre-existing divergence in inception/application-design/{services,components,component-methods}.md: these inception-stage docs describe an entirely different pull-based design (static cache + MasterModuleOptions.CacheMinutes + /api/internal/master/* routes) that construction never actually built — this predates today's session and is a separate, bigger gap than what today's fixes caused; not fully rewritten, just clearly marked as historical/superseded pending a dedicated pass. Also added a code-summary.md addendum for master-backend listing the new SlaveStatusController/methods. Re-ran the full backend suite after all doc edits (docs-only + two appsettings.json additions) to confirm nothing was inadvertently broken: 219/219 pass."
**Context**: This is a documentation-only audit and correction pass — no application code was changed (only aidlc-docs/*.md files and two appsettings.json files that added a previously-Development-only MasterPolling config section to the base/production configs for consistency, functionally a no-op since the C# options class already had matching defaults).
---
## Build and Test Stage — Approved
**Timestamp**: 2026-07-01T00:10:00Z
@@ -61,3 +61,14 @@ This generates the `Migrations/` folder contents. The migration is applied autom
- `Microsoft.Extensions.Http.Resilience` version `9.6.0` — verify/update during `dotnet restore` if a newer version is available for .NET 10
- `IntegrityCheckIntervalMinutes = 0` in tests forces immediate PeriodicTimer ticks (valid for test scenarios only)
- Slave-side endpoints (`/api/v1/master/register`, `/api/v1/master/status`, `/api/v1/master/registered-url`) are implemented in Unit 2 (slave-availability-extension)
## Addendum — 2026-07-04 (added outside this unit's original scope, in `local-dev-master-slave-setup` follow-up fixes)
This unit predates the following; see `slave-availability-extension/functional-design/business-rules.md` Rule Set 5 and `application-design/application-design.md` for the full picture:
| File | Description |
|------|-------------|
| `Controllers/SlaveStatusController.cs` | New. `[AllowAnonymous]` `GET /api/v1/SlaveStatus`; authenticates via `X-Master-Api-Key` header matched against each active `CmsInstance`'s decrypted key; lets a slave pull its own status instead of relying solely on the master's push |
| `Services/ICmsInstanceService.cs` / `CmsInstanceService.cs` | `GetStatusForApiKeyAsync(plainApiKey)` added; `UpdateStatusAsync`'s `Inactive` branch now pushes `Available`/null to release the gate (previously a no-op); `VerifyIntegrityAsync` now also re-pushes persisted status to every reachable active slave each cycle |
| `BackgroundServices/IntegrityCheckBackgroundService.cs` | Now runs one tick immediately on startup, in addition to the periodic timer |
| `Models/SlaveStatusPollResponse` (in `ICmsInstanceService.cs`) | New record: `IsAvailable`, `DisableMessage` |
@@ -97,7 +97,19 @@ sequenceDiagram
Svc->>Repo: UpdateAsync (Status=Inactive, DisableMessage=null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
Svc->>DP: Unprotect(entity.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, isAvailable=true, disableMessage=null)
Note over Svc: Releases the master gate — the master no longer manages this slave, so it must not stay stuck on its last pushed status
alt Release success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
else Release failed
Client-->>Svc: false
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=false)
end
else newStatus = Available or NotAvailable
Svc->>Repo: UpdateAsync (Status, DisableMessage)
Svc->>Repo: SaveChangesAsync()
@@ -119,13 +131,15 @@ sequenceDiagram
Ctrl-->>Ctrl: return 200 OK with UpdateStatusResult
```
Text alternative: Controller calls service with id and new status; service loads entity, validates, updates DB, then for non-Inactive transitions decrypts ApiKey and pushes status to slave; returns SlaveContactSuccess=false if push fails but DB is always the authority.
Text alternative: Controller calls service with id and new status; service loads entity, validates, updates DB, then decrypts ApiKey and pushes status to slave — for `Inactive` this push is always `isAvailable=true, disableMessage=null` (releasing the gate); for `Available`/`NotAvailable` it pushes the new status as-is. Returns SlaveContactSuccess=false if the push fails, but the DB write is always the authority.
> **Updated 2026-07-04**: the `Inactive` branch previously did not push anything to the slave at all (see history below) — this left the slave stuck on whatever status it had last received, indefinitely. Fixed by always releasing the gate on deactivation.
---
## Flow 3 — VerifyIntegrityAsync (Background Integrity Check)
**Trigger**: `IntegrityCheckBackgroundService` periodic timer (every `IntegrityCheckIntervalMinutes`)
**Trigger**: `IntegrityCheckBackgroundService` — one tick immediately on host startup, then every `IntegrityCheckIntervalMinutes` **(startup tick added 2026-07-04)**
```mermaid
sequenceDiagram
@@ -163,6 +177,7 @@ sequenceDiagram
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Note over Svc: Unreachable for URL check — skip the status re-push for this instance this cycle
else Slave reachable
Client-->>Svc: registeredMasterUrl
alt URLs match
@@ -183,9 +198,21 @@ sequenceDiagram
Repo->>DB: UPDATE CmsInstances
end
end
Note over Svc: Status re-push (added 2026-07-04) — runs whenever the slave was reachable, independent of the URL-match outcome
Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, isAvailable=(Status==Available), disableMessage)
alt Push success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
else Push failed
Client-->>Svc: false
Note over Svc: Logged; no flag change — next cycle (or the immediate startup tick) will retry
end
end
end
Svc-->>BgSvc: done
```
Text alternative: Background timer triggers integrity service; for each non-Inactive slave: decrypts key, retrieves registered master URL, clears failure flag on match, re-registers on mismatch, sets LastIntegrityCheckFailedAt when slave is unreachable or re-registration fails.
Text alternative: Background timer triggers integrity service; for each non-Inactive slave: decrypts key, retrieves registered master URL, clears failure flag on match, re-registers on mismatch, sets LastIntegrityCheckFailedAt when slave is unreachable or re-registration fails. **(Added 2026-07-04)** For every slave that was reachable, the service additionally re-pushes the master's currently persisted `Status`/`DisableMessage` to that slave — this is what lets a slave that reset its in-memory gate (e.g. after a restart) catch up without waiting for the next explicit admin status change. Combined with the new immediate startup tick on `IntegrityCheckBackgroundService`, this reconciliation now also runs right after the master process (re)starts.
> **Updated 2026-07-04**: previously this flow only verified/re-registered the master URL and never re-pushed status (see history below) — a restarted slave (whose in-memory master-gate defaults to `Available`) would show the wrong status until the master's next explicit UI-driven change.
@@ -10,7 +10,11 @@ graph TD
CheckMsg{"newStatus = NotAvailable\nAND disableMessage\nis null or empty?"}
ValidationErr["Throw ValidationException\nDisableMessage required"]
CheckInactive{"newStatus\n= Inactive?"}
SetInactive["Status = Inactive\nDisableMessage = null\nNo HTTP push\nSlaveContactSuccess = true"]
SetInactive["Status = Inactive\nDisableMessage = null\nPersist to MasterDbContext"]
ReleaseGate["Decrypt ApiKey\nPushStatusAsync(isAvailable=true,\ndisableMessage=null)\n— release the master gate"]
ReleaseOk{"Release push\nsucceeded?"}
ReleaseDone["LastStatusPushedAt = UtcNow\nSave"]
ReturnRelease["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=(release result)"]
PersistStatus["Persist Status + DisableMessage\nto MasterDbContext"]
DecryptKey["Decrypt ApiKey\nvia IDataProtector"]
PushSlave["PushStatusAsync\nto slave endpoint"]
@@ -25,7 +29,8 @@ graph TD
CheckExists -->|"yes"| CheckMsg
CheckMsg -->|"yes — invalid"| ValidationErr
CheckMsg -->|"no — valid"| CheckInactive
CheckInactive -->|"yes"| SetInactive --> Done
CheckInactive -->|"yes"| SetInactive --> ReleaseGate --> ReleaseOk
ReleaseOk -->|"yes/no"| ReleaseDone --> ReturnRelease --> Done
CheckInactive -->|"no"| PersistStatus --> DecryptKey --> PushSlave --> PushOk
PushOk -->|"yes"| UpdatePushed --> ReturnOk --> Done
PushOk -->|"no"| ReturnWarn --> Done
@@ -34,13 +39,15 @@ graph TD
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class CheckExists,CheckMsg,CheckInactive,PushOk decision
class PersistStatus,DecryptKey,PushSlave,UpdatePushed,SetInactive action
class CheckExists,CheckMsg,CheckInactive,PushOk,ReleaseOk decision
class PersistStatus,DecryptKey,PushSlave,UpdatePushed,SetInactive,ReleaseGate,ReleaseDone action
class Start,Done terminal
class NotFound,ValidationErr error
```
Text alternative: Load entity (404 if missing) → validate DisableMessage required for NotAvailable → for Inactive skip push → for others persist, decrypt key, push to slave, set SlaveContactSuccess based on push result.
Text alternative: Load entity (404 if missing) → validate DisableMessage required for NotAvailable → for Inactive, persist the Inactive status **and then explicitly push `Available` (disableMessage=null) to the slave** to release the master gate (the master no longer manages this slave, so it must not leave the slave stuck on a stale status) → for other statuses persist, decrypt key, push to slave, set SlaveContactSuccess based on push result.
> **Updated 2026-07-04**: originally `Inactive` skipped the HTTP push entirely (see history below); this was found to leave slaves permanently stuck on their last pushed status (e.g. `NotAvailable`) after being detached from the master, and was fixed to always release the gate on deactivation.
---
@@ -58,26 +65,34 @@ graph TD
RegOk{"Re-registration\nsucceeded?"}
ClearAfterReg["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
SetFailedReg["LastIntegrityCheckFailedAt = UtcNow\nSave"]
RePushStatus["PushStatusAsync\n(re-push persisted Status/DisableMessage\nto this active slave)"]
RePushOk{"Push\nsucceeded?"}
UpdatePushedAt["LastStatusPushedAt = UtcNow\nSave"]
Next(["Next instance"])
Start --> GetUrl --> Reachable
Reachable -->|"no"| SetFailed --> Next
Reachable -->|"yes"| UrlMatch
UrlMatch -->|"match"| ClearOk --> Next
UrlMatch -->|"match"| ClearOk --> RePushStatus
UrlMatch -->|"mismatch"| ReRegister --> RegOk
RegOk -->|"yes"| ClearAfterReg --> Next
RegOk -->|"yes"| ClearAfterReg --> RePushStatus
RegOk -->|"no"| SetFailedReg --> Next
RePushStatus --> RePushOk
RePushOk -->|"yes"| UpdatePushedAt --> Next
RePushOk -->|"no"| Next
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Reachable,UrlMatch,RegOk decision
class GetUrl,SetFailed,ClearOk,ReRegister,ClearAfterReg,SetFailedReg action
class Reachable,UrlMatch,RegOk,RePushOk decision
class GetUrl,SetFailed,ClearOk,ReRegister,ClearAfterReg,SetFailedReg,RePushStatus,UpdatePushedAt action
class Start,Next terminal
```
Text alternative: For each active slave — attempt to get its registered master URL; if unreachable set failure flag; if reachable and URL matches clear flag; if mismatch re-register; clear flag on success, set flag on failure.
Text alternative: For each active slave — attempt to get its registered master URL; if unreachable set failure flag and move on; if reachable and URL matches clear the flag, if mismatch re-register (clearing the flag on success, setting it on failure); then — regardless of the URL check's outcome, as long as the slave was reachable — **re-push the master's currently persisted Status/DisableMessage to that slave** (this is the reconciliation path for a slave that reset its in-memory gate, e.g. after a restart, or missed an earlier push). `IntegrityCheckBackgroundService` also now runs one tick immediately on host startup, in addition to its periodic interval, so this resync happens right after the master (re)starts rather than waiting a full cycle.
> **Updated 2026-07-04**: originally this check only verified/re-registered the master URL and never re-pushed status (see history below); this left restarted slaves stuck on a stale in-memory status (reset to `Available` by default) until the next explicit status change — fixed by adding the re-push step and the immediate startup run.
---
@@ -101,10 +116,12 @@ Text alternative: For each active slave — attempt to get its registered master
|------|----|---------------|-----------|-------|
| Any | `Available` | Clear to null | Yes | Slave re-enabled |
| Any | `NotAvailable` | Required, non-empty | Yes | Slave disabled with message |
| Any | `Inactive` | Clear to null | **No** | Master stops all contact |
| Any | `Inactive` | Clear to null | **Yes** — pushes `Available`/null | Master stops managing the slave, but must first release the gate so the slave doesn't stay stuck on its last pushed status |
| `Inactive` | `Available` | Clear to null | Yes | Reactivation |
| `Inactive` | `NotAvailable` | Required, non-empty | Yes | Reactivation with disable |
> **Updated 2026-07-04**: the `Inactive` row previously said "No" HTTP push (see history below) — corrected after the no-push behavior was found to leave slaves permanently stuck on their last status.
---
## ApiKey Encryption Rules
@@ -132,9 +149,9 @@ Text alternative: For each active slave — attempt to get its registered master
| Rule | Description |
|------|-------------|
| BR-CONTACT-01 | Instances with `Status = Inactive` are excluded from `GetActiveAsync` and never contacted via HTTP |
| BR-CONTACT-02 | Status push is skipped when transitioning any status → `Inactive` |
| BR-CONTACT-03 | Integrity check runs only against instances where `Status != Inactive` |
| BR-CONTACT-01 | Instances with `Status = Inactive` are excluded from `GetActiveAsync` and are not contacted by the periodic integrity check / re-push cycle |
| BR-CONTACT-02 | **(Updated 2026-07-04)** The transition to `Inactive` itself always performs exactly one status push — `Available`, `disableMessage=null` — to release the master gate on the slave before the instance drops out of `GetActiveAsync` for good. Previously this push was skipped entirely; that left the slave stuck on its last pushed status indefinitely. |
| BR-CONTACT-03 | Integrity check (and its new status re-push, see BR-02) runs only against instances where `Status != Inactive` |
---
@@ -146,3 +163,17 @@ Text alternative: For each active slave — attempt to get its registered master
| BR-BG-02 | Each tick creates and disposes its own `IServiceScope` |
| BR-BG-03 | Exceptions within a single slave's integrity check are caught, logged, and do not abort processing for remaining slaves |
| BR-BG-04 | If `MasterModuleOptions.MasterUrl` is null or empty, the background service logs a warning and skips the entire integrity check for that cycle |
| BR-BG-05 | **(Added 2026-07-04)** `IntegrityCheckBackgroundService` runs one tick immediately on host startup (in addition to its periodic `PeriodicTimer` cycle), so a freshly (re)started master resyncs slave statuses right away instead of waiting up to `IntegrityCheckIntervalMinutes` |
---
## Slave Pull (Status Poll) Rules — Added 2026-07-04
This closes a design gap: the original inception requirements (`inception/requirements/requirements.md`, FR-MASTER-06/07, NFR-MASTER-01) specified a slave-initiated periodic pull with fail-open, but construction implemented push-only. The pull side was added alongside the existing push mechanism (not instead of it) after a slave was observed staying on a stale status through a restart and a deactivation.
| Rule | Description |
|------|-------------|
| BR-PULL-01 | `SlaveStatusController` exposes `GET /api/v1/SlaveStatus`, authenticated via the `X-Master-Api-Key` header — no `[Authorize]`/JWT, since the caller is a slave process, not a logged-in user |
| BR-PULL-02 | `CmsInstanceService.GetStatusForApiKeyAsync` identifies the calling slave by decrypting each active `CmsInstance.ApiKey` and comparing it to the caller's plain key (no separate slave-identity field exists; the shared key is the only credential) — returns `null` (→ 401) when no match is found |
| BR-PULL-03 | A successful poll updates `CmsInstance.LastContactedAt`, mirroring the existing convention used by push-based master↔slave calls |
| BR-PULL-04 | `/api/v1/SlaveStatus` is added to `AvailabilityMiddleware`'s bypass list on the master's own instance, so it stays reachable regardless of the master's own local availability status |
@@ -71,7 +71,9 @@ public enum CmsInstanceStatus
|-------|---------|----------------------|
| `Available` | Slave is enabled; normal operation | Yes (status push + integrity checks) |
| `NotAvailable` | Slave is disabled; `DisableMessage` served to end-users | Yes (status push + integrity checks) |
| `Inactive` | Soft-removed; greyed out in UI | **No** — all HTTP contact is halted |
| `Inactive` | Soft-removed; greyed out in UI | On the transition **into** `Inactive`: one final push (`Available`, no message) to release the gate. Afterwards: **No** further contact — excluded from `GetActiveAsync`, so no more pushes/integrity checks/re-pushes |
> **Updated 2026-07-04**: previously `Inactive` meant no HTTP contact at all, including on the transition itself — this left slaves stuck on their last pushed status after being detached. See BR-CONTACT-02 in `business-rules.md`.
---
@@ -129,4 +131,19 @@ public enum CmsInstanceStatus
| Property | Type | Notes |
|----------|------|-------|
| `Success` | `bool` | Always `true` when status persisted to DB (DB is the authority) |
| `SlaveContactSuccess` | `bool` | `true` if HTTP push to slave succeeded; `false` if push failed (slave unreachable); not applicable for `Inactive` transitions (returns `true`) |
| `SlaveContactSuccess` | `bool` | `true` if the HTTP push to the slave succeeded; `false` if it failed (slave unreachable). Applies to `Inactive` transitions too — reflects whether the gate-release push succeeded, not a hardcoded `true` |
> **Updated 2026-07-04**: `SlaveContactSuccess` for `Inactive` used to always be hardcoded `true` (no push happened, so nothing could fail) — now reflects the real result of the release push.
---
## SlaveStatusPollResponse (API Response) — Added 2026-07-04
Response shape for the new slave-pull endpoint (`GET /api/v1/SlaveStatus`, `SlaveStatusController`), used by `MasterStatusPollingBackgroundService` on the slave side (see `slave-availability-extension/functional-design/domain-entities.md`). This is the counterpart of the push-based flow above — added to close a gap versus the original inception requirements (FR-MASTER-06/07), which specified a slave-initiated pull in addition to the push that construction actually implemented.
| Property | Type | Notes |
|----------|------|-------|
| `IsAvailable` | `bool` | `true` when `CmsInstance.Status == Available` |
| `DisableMessage` | `string?` | `CmsInstance.DisableMessage` |
Identified by matching the caller's plain API key (header `X-Master-Api-Key`) against each active `CmsInstance`'s decrypted key — there is no separate slave-identity field, the shared key doubles as the credential. No `[Authorize]`/JWT on this endpoint.
@@ -150,3 +150,108 @@ sequenceDiagram
```
Text alternative: Middleware first checks bypass paths, then admin JWT. If neither applies: checks static master cache; if master unavailable return 503. If master available: checks local availability service; if locally unavailable return 503. Otherwise pass through.
---
## Flow 5: Slave Poll (Slave → Master) — Added 2026-07-04
**Trigger**: `MasterStatusPollingBackgroundService` — one tick immediately on slave startup, then every `MasterPolling:PollIntervalSeconds` (default 30s).
```mermaid
sequenceDiagram
box rgba(76,175,80,0.15) Slave CMS
participant Timer as PeriodicTimer
participant BgSvc as MasterStatusPollingBackgroundService
participant Svc as MasterAvailabilityService
participant Client as MasterStatusPollClient
participant DB as AvailabilityDbContext
participant Cache as StaticCache
end
box rgba(33,150,243,0.15) Master CMS
participant MC as SlaveStatusController
end
Timer->>BgSvc: Tick
BgSvc->>Svc: GetPollTargetAsync()
Svc->>DB: GetRegistrationAsync()
alt No registration
DB-->>Svc: null
Svc-->>BgSvc: null
BgSvc-->>Timer: no-op, wait for next tick
else Registration exists
DB-->>Svc: MasterUrl, encrypted ApiKey
Svc-->>BgSvc: MasterUrl, plain ApiKey
BgSvc->>Client: GetStatusAsync(masterUrl, plainApiKey)
Client->>MC: GET /api/v1/SlaveStatus with X-Master-Api-Key header
alt Poll succeeds
MC-->>Client: 200 OK { IsAvailable, DisableMessage }
Client-->>BgSvc: PolledMasterStatus
BgSvc->>Svc: ApplyPolledStatusAsync(isAvailable, disableMessage)
Svc->>Cache: set _masterIsAvailable / _masterDisableMessage
Svc->>DB: Update LastPolledAt=now, LastContactedAt=now
else Poll fails (network error, timeout, 401, etc.)
Client-->>BgSvc: null
BgSvc->>Svc: RecordPollFailureAsync(failOpenAfter)
Svc->>DB: read LastPolledAt (or RegisteredAt if never polled)
alt Unreachable longer than failOpenAfter
Svc->>Cache: force _masterIsAvailable=true, _masterDisableMessage=null
Note over Svc: Fail-open — a dead/unreachable master must never permanently block this slave
else Still within grace period
Note over Svc: No change — leave the existing cached gate as-is
end
end
end
```
Text alternative: Background timer triggers the poller; if no master is registered, it's a no-op. Otherwise it calls `GET /api/v1/SlaveStatus` on the registered master. On success, the response overwrites the in-memory gate and updates `LastPolledAt`/`LastContactedAt`. On failure, the gate is left alone unless the master has been unreachable (via poll) for longer than `MasterPolling:FailOpenAfterMinutes`, in which case the gate is forced open (`Available`, no message).
---
## Flow 6: Local Availability Status Read/Write with Master-Gate Override — Added 2026-07-04
Applies to the slave's own `/settings` admin UI/API — `GET /api/v1/Availability/status` and `POST /api/v1/Availability/admin/status` — layered on top of `PersistentAvailabilityService`, which previously only ever reflected the locally-persisted `GlobalAvailabilityState` regardless of the master gate.
```mermaid
sequenceDiagram
box rgba(33,150,243,0.15) Frontend (SettingsPage)
participant FE as Owner Browser
end
box rgba(76,175,80,0.15) Slave CMS
participant Ctrl as AvailabilityController
participant Svc as PersistentAvailabilityService
participant MasterSvc as MasterAvailabilityService
participant DB as ApplicationDbContext
end
FE->>Ctrl: GET /api/v1/Availability/status
Ctrl->>Svc: GetStatusDetailsAsync()
Svc->>MasterSvc: GetMasterStatus()
alt Master gate closed (IsAvailable = false)
MasterSvc-->>Svc: NotAvailable, masterMessage
Svc-->>Ctrl: Status=NotAvailable, Message=masterMessage, IsMasterControlled=true
else Master gate open
MasterSvc-->>Svc: Available
Svc->>DB: read GlobalAvailabilityState
DB-->>Svc: local Status/Message
Svc-->>Ctrl: Status, Message, IsMasterControlled=false
end
Ctrl-->>FE: 200 OK
FE->>Ctrl: POST /api/v1/Availability/admin/status (attempted local change)
Ctrl->>Svc: UpdateStatusAsync(newStatus, reason, updatedBy)
Svc->>MasterSvc: GetMasterStatus()
alt Master gate closed
MasterSvc-->>Svc: NotAvailable
Svc-->>Ctrl: throw MasterControlledAvailabilityException
Ctrl-->>FE: 409 Conflict (ProblemDetails)
else Master gate open
MasterSvc-->>Svc: Available
Svc->>DB: persist newStatus/reason
Svc-->>Ctrl: success
Ctrl-->>FE: 200 OK
end
```
Text alternative: Reading status now checks the master gate first — if closed, the response reflects the master's forced status and message, with `IsMasterControlled=true`, regardless of what's persisted locally. Writing a status change now performs the same master-gate check first: if closed, the write is rejected outright with `409 Conflict` instead of silently succeeding with no visible effect, since the master gate would have overridden it on the very next read anyway.
> Previously (before 2026-07-04): `GetStatusDetailsAsync` ignored the master gate entirely and always returned the locally-persisted status; `UpdateStatusAsync` always wrote the requested change regardless of the master gate, giving a misleading "success" for a change that was immediately invisible.
@@ -69,6 +69,7 @@ Text alternative: Bypass path check first. Admin JWT next (bypasses both gates).
| `/api/v1/Auth/` | Already bypassed — login must always work |
| `/api/v1/Setup/status` | Already bypassed — frontend init check |
| `/api/v1/master/` | **NEW** — master management endpoints must bypass gate so master can always push status or re-register |
| `/api/v1/SlaveStatus` | **NEW, added 2026-07-04** — this is actually the *master's* incoming endpoint for slave pulls, but it's added to this same bypass list on any instance that also loads `Modules.Availability` (i.e. the master itself), so the master's own local-gate status never blocks a slave from reading it |
**Cache behavior rules**:
@@ -76,7 +77,7 @@ Text alternative: Bypass path check first. Admin JWT next (bypasses both gates).
|---|------|
| BR-SLAVE-08 | `_masterIsAvailable` defaults to `true` (fail-open) on process startup |
| BR-SLAVE-09 | `_masterDisableMessage` defaults to `null` on process startup |
| BR-SLAVE-10 | Cache has no expiry (Q4=A); only updated on `POST /status` with valid API key |
| BR-SLAVE-10 | **(Updated 2026-07-04)** Cache is updated on `POST /status` (push, valid API key) **and** periodically overwritten by `MasterStatusPollingBackgroundService` pulling `GET /api/v1/SlaveStatus` from the master (see Rule Set 5). It is no longer purely push-driven or expiry-free: a poll failure that persists past `MasterPolling:FailOpenAfterMinutes` (default 5 min, measured from `MasterRegistration.LastPolledAt`) forcibly resets the cache to `Available`/`null` regardless of the last pushed value. |
| BR-SLAVE-11 | 503 response from master gate includes `_masterDisableMessage` in `ProblemDetails.Detail` |
---
@@ -89,6 +90,7 @@ Text alternative: Bypass path check first. Admin JWT next (bypasses both gates).
| BR-SLAVE-13 | On `RegisterAsync`: if row with that Id exists → update. If not → insert. Never delete. |
| BR-SLAVE-14 | `RegisteredAt` is set once at creation and never updated |
| BR-SLAVE-15 | `LastContactedAt` is updated on every successful master call (register, status push, get-url) |
| BR-SLAVE-16 | **(Added 2026-07-04)** `LastPolledAt` is updated whenever this slave successfully polls the master via `MasterStatusPollingBackgroundService` (distinct from `LastContactedAt`, which tracks master-initiated contact) |
---
@@ -101,3 +103,31 @@ Text alternative: Bypass path check first. Admin JWT next (bypasses both gates).
| Get registered URL | `GET` | `/api/v1/master/registered-url` | None (API key in header) |
All three endpoints are unauthenticated from ASP.NET Core's perspective — they use the custom `X-Master-Api-Key` header validation implemented in `MasterAvailabilityService`. They are also in the middleware bypass list so the gate cannot block master management calls.
---
## Rule Set 5: Slave Pull + Fail-Open — Added 2026-07-04
Closes a gap versus the original inception requirements (`inception/requirements/requirements.md`, FR-MASTER-06 "Slave Pull Model", FR-MASTER-07 "Slave Fallback Behavior", NFR-MASTER-01 "Fail-Open Safety"): construction had implemented push-only, with fail-open surviving only as an in-memory startup default (BR-SLAVE-08/09) rather than an actively-reconciling pull. Added after a slave was observed remaining on a stale status through a restart and again after being deactivated on the master.
| # | Rule |
|---|------|
| BR-PULL-01 | `MasterStatusPollingBackgroundService` runs one tick immediately on slave startup, then every `MasterPolling:PollIntervalSeconds` (default `30`) |
| BR-PULL-02 | If no `MasterRegistration` exists yet, the poll tick is a no-op (nothing to poll) |
| BR-PULL-03 | On a successful poll (`GET /api/v1/SlaveStatus` on the registered master, header `X-Master-Api-Key`), the response overwrites the in-memory gate (`_masterIsAvailable`/`_masterDisableMessage`) and updates `MasterRegistration.LastPolledAt` + `LastContactedAt` |
| BR-PULL-04 | On a failed poll (network error, timeout, or non-success HTTP status), the gate is **not** changed immediately — instead `RecordPollFailureAsync` checks how long it's been since `LastPolledAt` (or `RegisteredAt` if never polled) |
| BR-PULL-05 | **Fail-open**: if that elapsed time exceeds `MasterPolling:FailOpenAfterMinutes` (default `5`), the gate is forced to `Available`/`null` — a master that is dead or unreachable must never permanently block a slave |
| BR-PULL-06 | Push (BR-SLAVE-01 through 11) and pull (this rule set) are independent and complementary: push gives instant reactivity to an explicit admin status change; pull is the self-healing safety net for everything push can miss (slave restarts, dropped pushes, local tampering with the in-memory gate) |
---
## Rule Set 6: Master-Controlled Availability Lock — Added 2026-07-04
Applies to the slave's own local availability admin UI/API (`PersistentAvailabilityService` / `AvailabilityController` — the endpoints an Owner uses on `/settings` to set *this instance's own* Maintenance/NotAvailable status), not the master↔slave protocol endpoints above. Added because an Owner on a master-disabled slave could previously "successfully" set local status to `Available` with no visible effect, since the master gate silently overrode the display.
| # | Rule |
|---|------|
| BR-LOCK-01 | `GET /api/v1/Availability/status` returns the master-gate status (not the locally-persisted one) whenever the master gate is closed (`IsAvailable = false`), and includes a new `IsMasterControlled: true` flag in that case |
| BR-LOCK-02 | `POST /api/v1/Availability/admin/status` (`PersistentAvailabilityService.UpdateStatusAsync`) throws `MasterControlledAvailabilityException` and makes **no** DB write when the master gate is closed, instead of silently persisting a change that would have no visible effect |
| BR-LOCK-03 | The controller translates that exception into `409 Conflict` (`ProblemDetails`) |
| BR-LOCK-04 | The frontend Settings page reads `isMasterControlled` and disables the mode selector, the reason field, and the save button, showing a banner explaining that the Master CMS controls this status |
@@ -40,9 +40,10 @@ Singleton row — at most one record exists per slave instance. Upserted on each
|-------|------|-------------|-------|
| `Id` | `Guid` | PK | Fixed value (e.g. `Guid.Empty`) enforces singleton |
| `MasterUrl` | `string` | Required, max 500 | URL of the master CMS that registered this slave |
| `ApiKey` | `string` | Required, max 1000 | Plain-text API key sent in first registration; used for subsequent validation |
| `ApiKey` | `string` | Required, max 1000 | API key sent in first registration, encrypted via `IMasterApiKeyProtector` (ASP.NET Core Data Protection) before storage, decrypted for each subsequent validation **corrected 2026-07-04**, this was previously (incorrectly) documented as stored plain-text |
| `RegisteredAt` | `DateTimeOffset` | Required | Timestamp of first registration |
| `LastContactedAt` | `DateTimeOffset?` | Optional | Updated on every successful master call (register, status push, get-url) |
| `LastContactedAt` | `DateTimeOffset?` | Optional | Updated on every successful master-initiated call (register, status push, get-url) |
| `LastPolledAt` | `DateTimeOffset?` | Optional | **Added 2026-07-04**. Updated on every successful *slave-initiated* poll (`MasterStatusPollingBackgroundService`) — the counterpart to `LastContactedAt`, tracking the opposite direction of contact. Also the basis for the fail-open timeout (see below). |
> **Singleton enforcement**: The `Id` is a fixed known value (`Guid.Parse("00000000-0000-0000-0000-000000000001")`). On first `POST /api/v1/master/register` the row is created; on re-registration the same row is updated in-place. This avoids a composite unique constraint and makes EF upsert trivial.
@@ -54,10 +55,10 @@ Not a DB entity — lives in memory on the slave process.
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `_masterIsAvailable` | `bool` | `true` | Set by status push; `true` = pass gate |
| `_masterDisableMessage` | `string?` | `null` | Message forwarded from master to 503 response |
| `_masterIsAvailable` | `bool` | `true` | Set by status push **or** successful poll; forced back to `true` on prolonged poll failure (fail-open) |
| `_masterDisableMessage` | `string?` | `null` | Message forwarded from master to 503 response; cleared to `null` on fail-open |
**No expiry** (Q4=A): cache is valid indefinitely until the master pushes again. If the master goes offline permanently, the last known status is used. Default = `true` (Available) = fail-open.
> **Updated 2026-07-04**: originally documented as having "no expiry" (Q4=A), valid indefinitely until the next push. This is no longer accurate now that `MasterStatusPollingBackgroundService` actively polls the master (see Rule Set 5 / Flow 5 in the sibling docs) and forces the cache back to `Available`/`null` if the master has been unreachable via poll for longer than `MasterPolling:FailOpenAfterMinutes` (default 5 min, measured from `MasterRegistration.LastPolledAt`). Push-driven updates (this section's original description) are unchanged and still apply; the poll is an additional, independent path that can also write to this same cache.
---
@@ -145,11 +145,11 @@ Text alternative: Master CMS has new module with controller, service, repository
| Constraint | Source | Impact |
|-----------|--------|--------|
| `ApiKey` never returned in API responses | NFR-MASTER-03 | `CmsInstanceDto` excludes `ApiKey`; only accepted in `CreateCmsInstanceRequest` |
| Fail-open on Master unreachable | NFR-MASTER-01 | `MasterAvailabilityService` returns last cached status (default Available) on HTTP failure |
| Fail-open on Master unreachable | NFR-MASTER-01 | **(Updated 2026-07-04)** Originally only an in-memory startup default (`_masterIsAvailable = true`); now actively enforced by `MasterStatusPollingBackgroundService.RecordPollFailureAsync`, which forces the gate open if the master has been unreachable via poll for longer than `MasterPolling:FailOpenAfterMinutes` — this is the slave-pull half of FR-MASTER-06/07 that was originally specified but not implemented until 2026-07-04 (see `slave-availability-extension/functional-design/business-rules.md` Rule Set 5) |
| Per-module DbContext + migrations | NFR-MASTER-06 | New `MasterDbContext` in `Modules.Master`; new `AvailabilityDbContext` in `Modules.Availability` |
| Master exemption from own gate | FR-MASTER-09 | Handled naturally: no `MasterRegistration` record exists on Master instance → gate skipped |
| Disable message required for NotAvailable | FR-MASTER-14 | Validated in `CmsInstanceService.UpdateStatusAsync` before persistence |
| Inactive slaves: no HTTP contact | FR-MASTER-13 | `GetActiveAsync()` filters out Inactive before integrity checks and status pushes |
| Inactive slaves: no further HTTP contact | FR-MASTER-13 | **(Updated 2026-07-04)** `GetActiveAsync()` filters out Inactive instances from ongoing integrity checks and status re-pushes, as before — but the transition *into* `Inactive` itself now always performs one final push (`Available`, no message) to release the master gate before the instance drops out; previously this transition performed no push at all, leaving the slave stuck on its last status |
| Owner role only | FR-MASTER-10 | `[Authorize(Policy = "OwnerOnly")]` on all `CmsInstanceController` actions |
---
@@ -2,6 +2,8 @@
> Method signatures at the interface level. Detailed business rules and implementation logic are deferred to Functional Design (CONSTRUCTION phase).
> ⚠️ **Partially superseded, found stale 2026-07-04**: the `IMasterAvailabilityService` methods and `/api/internal/master/*` routes below describe an inception-stage pull design that construction did not build as-is (actual routes are `/api/v1/master/*`; the method set differs — see `RegisterAsync`/`PushStatusAsync`/`GetRegisteredUrlAsync`/`GetMasterStatus` in `construction/slave-availability-extension/`, plus 2026-07-04 additions `GetPollTargetAsync`/`ApplyPolledStatusAsync`/`RecordPollFailureAsync`). Treat the functional-design docs under `construction/` as current truth for exact signatures.
---
## Unit 1 — master-backend
@@ -1,5 +1,7 @@
# Components — Master CMS Module
> ⚠️ **Partially superseded, found stale 2026-07-04**: the slave-side `IMasterAvailabilityService` description below (static `_cachedStatus`/`_lastFetchedAt`, `CacheMinutes`-based staleness, `/api/internal/master/*` routes) describes an inception-stage design that construction did not build as-is — the actual implementation is push-based (`/api/v1/master/*`, `_masterIsAvailable`/`_masterDisableMessage`), plus a 2026-07-04 addition of a differently-shaped slave-pull (`MasterStatusPollingBackgroundService` + `GET /api/v1/SlaveStatus`, time-based fail-open). The Unit 1 (`master-backend`) component list above the slave-side section is accurate. See `construction/master-backend/functional-design/*.md` and `construction/slave-availability-extension/functional-design/*.md` for the as-built design. This gap predates 2026-07-04 and was found (not caused) during today's documentation audit.
## Unit 1 — master-backend (`SlpModularCms.Modules.Master`)
### MasterModule
@@ -1,5 +1,7 @@
# Services — Master CMS Module
> ⚠️ **Superseded, found stale 2026-07-04**: this document is the *inception-stage* design and describes an earlier pull-based `IMasterAvailabilityService` (static cache + `MasterModuleOptions.CacheMinutes` + `/api/internal/master/*` routes) that was **not what got built**. Construction pivoted to a push-based protocol instead (`/api/v1/master/*` routes, `_masterIsAvailable`/`_masterDisableMessage` fields, no `CacheMinutes`). A slave-pull mechanism was eventually added too, but on 2026-07-04 and with a different shape (`MasterStatusPollingBackgroundService` polling `GET /api/v1/SlaveStatus` on an interval, with a time-based fail-open) than what's described below. **Treat this file as historical intent, not current truth** — the accurate, as-built design lives in `construction/master-backend/functional-design/*.md` and `construction/slave-availability-extension/functional-design/*.md`. This divergence predates 2026-07-04 and was found (not caused) during today's documentation audit; `CacheMinutes`/`ApiKey` dead-config fallout is already tracked as TD-001 in `tech-debt-backlog`.
## Service Definitions
### Master-Side Services