Files
slp-modular-cms/aidlc-docs/features/master-cms-module/construction/slave-availability-extension/functional-design/business-logic-model.md
T
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

10 KiB

Business Logic Model — Unit 2: slave-availability-extension

Flow 1: Register Master (POST /api/v1/master/register)

sequenceDiagram
    box rgba(33,150,243,0.15) Master CMS
        participant MC as MasterCms
    end
    box rgba(76,175,80,0.15) Slave CMS
        participant Ctrl as MasterController
        participant Svc as MasterAvailabilityService
        participant DB as AvailabilityDbContext
    end

    MC->>Ctrl: POST register with X-Master-Api-Key header and MasterUrl body
    Ctrl->>Svc: RegisterAsync(masterUrl, apiKey)
    Svc->>DB: GetRegistrationAsync()
    alt No registration exists
        DB-->>Svc: null
        Svc->>DB: Insert MasterRegistration with masterUrl apiKey RegisteredAt LastContactedAt=now
        DB-->>Svc: saved
        Svc-->>Ctrl: success
        Ctrl-->>MC: 200 OK
    else Registration exists and ApiKey matches
        DB-->>Svc: existing record
        Svc->>DB: Update MasterUrl and LastContactedAt=now
        DB-->>Svc: saved
        Svc-->>Ctrl: success
        Ctrl-->>MC: 200 OK
    else Registration exists and ApiKey does not match
        DB-->>Svc: existing record
        Svc-->>Ctrl: unauthorized
        Ctrl-->>MC: 401 Unauthorized
    end

Text alternative: Master posts to slave register endpoint. Service checks DB for existing registration. If none: insert new row. If exists and key matches: update MasterUrl. If exists but key mismatch: return 401.


Flow 2: Status Push (POST /api/v1/master/status)

sequenceDiagram
    box rgba(33,150,243,0.15) Master CMS
        participant MC as MasterCms
    end
    box rgba(76,175,80,0.15) Slave CMS
        participant Ctrl as MasterController
        participant Svc as MasterAvailabilityService
        participant DB as AvailabilityDbContext
        participant Cache as StaticCache
    end

    MC->>Ctrl: POST status with X-Master-Api-Key header isAvailable and disableMessage
    Ctrl->>Svc: PushStatusAsync(apiKey, isAvailable, disableMessage)
    Svc->>DB: GetRegistrationAsync()
    alt No registration
        DB-->>Svc: null
        Svc-->>Ctrl: unauthorized
        Ctrl-->>MC: 401 Unauthorized
    else ApiKey mismatch
        DB-->>Svc: record with different key
        Svc-->>Ctrl: unauthorized
        Ctrl-->>MC: 401 Unauthorized
    else ApiKey valid
        DB-->>Svc: valid registration
        Svc->>Cache: set _masterIsAvailable and _masterDisableMessage
        Svc->>DB: Update LastContactedAt=now
        DB-->>Svc: saved
        Svc-->>Ctrl: success
        Ctrl-->>MC: 200 OK
    end

Text alternative: Master posts status. Service validates API key. If valid: update static cache and LastContactedAt. On any validation failure: 401.


Flow 3: Get Registered URL (GET /api/v1/master/registered-url)

sequenceDiagram
    box rgba(33,150,243,0.15) Master CMS
        participant MC as MasterCms
    end
    box rgba(76,175,80,0.15) Slave CMS
        participant Ctrl as MasterController
        participant Svc as MasterAvailabilityService
        participant DB as AvailabilityDbContext
    end

    MC->>Ctrl: GET registered-url with X-Master-Api-Key header
    Ctrl->>Svc: GetRegisteredUrlAsync(apiKey)
    Svc->>DB: GetRegistrationAsync()
    alt No registration
        DB-->>Svc: null
        Svc-->>Ctrl: unauthorized
        Ctrl-->>MC: 401 Unauthorized
    else ApiKey mismatch
        DB-->>Svc: record
        Svc-->>Ctrl: unauthorized
        Ctrl-->>MC: 401 Unauthorized
    else ApiKey valid
        DB-->>Svc: valid registration
        Svc->>DB: Update LastContactedAt=now
        DB-->>Svc: saved
        Svc-->>Ctrl: return MasterUrl
        Ctrl-->>MC: 200 OK with MasterUrl payload
    end

Text alternative: Master requests the URL it registered on this slave. Service validates key, updates LastContactedAt, returns stored MasterUrl. 401 on any validation failure.


Flow 4: Middleware Gate Evaluation (every request)

sequenceDiagram
    box rgba(33,150,243,0.15) Incoming Request
        participant Req as HttpRequest
    end
    box rgba(76,175,80,0.15) Slave CMS Pipeline
        participant MW as AvailabilityMiddleware
        participant Cache as StaticCache
        participant Local as IAvailabilityService
        participant Next as NextMiddleware
    end

    Req->>MW: any request
    alt Path in bypass list
        MW-->>Next: pass through unconditionally
    else Admin JWT bearer token present
        MW-->>Next: pass through unconditionally
    else Check master gate
        MW->>Cache: read _masterIsAvailable
        alt Master is available or no status received yet
            MW->>Local: IsAvailableAsync()
            alt Locally available
                MW-->>Next: pass through
            else Locally unavailable
                MW-->>Req: 503 with local disable message
            end
        else Master says unavailable
            MW-->>Req: 503 with master disable message
        end
    end

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

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.

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.