Files
SluijsensandClaude Sonnet 5 0447993181 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>
2026-07-04 19:53:52 +02:00

11 KiB

Business Rules — Unit 1: master-backend

BR-01 — Status Update Decision Logic

graph TD
    Start(["UpdateStatusAsync called"])
    CheckExists{"Entity exists\nfor given id?"}
    NotFound["Throw NotFoundException\n404 to caller"]
    CheckMsg{"newStatus = NotAvailable\nAND disableMessage\nis null or empty?"}
    ValidationErr["Throw ValidationException\nDisableMessage required"]
    CheckInactive{"newStatus\n= Inactive?"}
    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"]
    PushOk{"HTTP push\nsucceeded?"}
    UpdatePushed["LastStatusPushedAt = UtcNow\nSave"]
    ReturnOk["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=true"]
    ReturnWarn["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=false"]
    Done(["Return result to controller"])

    Start --> CheckExists
    CheckExists -->|"no"| NotFound
    CheckExists -->|"yes"| CheckMsg
    CheckMsg -->|"yes — invalid"| ValidationErr
    CheckMsg -->|"no — valid"| CheckInactive
    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

    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 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, 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.


BR-02 — Integrity Check Decision Logic

graph TD
    Start(["VerifyIntegrityAsync\nper instance"])
    GetUrl["GetRegisteredMasterUrlAsync\n(slaveUrl, plainApiKey)"]
    Reachable{"Slave\nreachable?"}
    SetFailed["LastIntegrityCheckFailedAt = UtcNow\nSave — continue to next"]
    UrlMatch{"registeredMasterUrl\n= expected masterUrl?"}
    ClearOk["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
    ReRegister["RegisterMasterAsync\n(slaveUrl, plainApiKey, masterUrl)"]
    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 --> RePushStatus
    UrlMatch -->|"mismatch"| ReRegister --> RegOk
    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,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 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.


Validation Rules

Rule Field Condition Error
BR-VAL-01 Name Must not be null or whitespace Name is required
BR-VAL-02 Url Must not be null or whitespace Url is required
BR-VAL-03 Url Must start with http:// or https:// Url must be a valid absolute HTTP(S) URL
BR-VAL-04 ApiKey Must not be null or whitespace (on create) ApiKey is required
BR-VAL-05 DisableMessage Required (non-empty) when Status = NotAvailable DisableMessage is required when status is NotAvailable
BR-VAL-06 Status Must be a valid CmsInstanceStatus enum value Invalid status value
BR-VAL-07 id (update) CmsInstance with given id must exist CmsInstance not found (404)

Status Transition Rules

From To DisableMessage HTTP Push Notes
Any Available Clear to null Yes Slave re-enabled
Any NotAvailable Required, non-empty Yes Slave disabled with message
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

Rule Description
BR-ENC-01 ApiKey is encrypted via IDataProtector before writing to MasterDbContext
BR-ENC-02 ApiKey is decrypted via IDataProtector immediately before each HTTP call requiring it
BR-ENC-03 ApiKey is never included in CmsInstanceDto or any other API response
BR-ENC-04 ApiKey is accepted in CreateCmsInstanceRequest on creation only; no update endpoint for ApiKey

Master URL Resolution Rules

Context Resolution Strategy
Controller-originated calls (Add) Derive from HttpContext.Request scheme + host + (optional port) via IHttpContextAccessor
Background service calls (Integrity Check) Read MasterModuleOptions.MasterUrl from configuration
MasterModuleOptions.MasterUrl is null in background context Log a warning; skip registration/integrity for that cycle

HTTP Contact Exclusion Rules

Rule Description
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

Background Service Rules

Rule Description
BR-BG-01 IntegrityCheckBackgroundService resolves ICmsInstanceService via IServiceScopeFactory per tick (not injected directly, as service is Scoped)
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