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

8.9 KiB

Business Rules — Unit 2: slave-availability-extension

Rule Set 1: API Key Validation

graph TD
    A{Registration\nexists in DB?} -->|No| B{Is this a\nregister call?}
    B -->|Yes| C[Accept and create\nnew registration]
    B -->|No| D[Return 401\nUnauthorized]
    A -->|Yes| E{X-Master-Api-Key\nmatches stored key?}
    E -->|Yes| F[Proceed with\nbusiness logic]
    E -->|No| G[Return 401\nUnauthorized]

    classDef decision fill:#FFC107,stroke:#F57F17,color:#000
    classDef pass fill:#9ae6b4,stroke:#2f855a,color:#000
    classDef fail fill:#FC8181,stroke:#C53030,color:#000

    class A,B,E decision
    class C,F pass
    class D,G fail

Text alternative: If no registration exists and this is a register call, create it. If no registration and not a register call, 401. If registration exists, validate key; match = proceed, mismatch = 401.

Validation rules:

# Rule Applies to
BR-SLAVE-01 First POST /register with no existing registration: accept unconditionally, store ApiKey from X-Master-Api-Key header Register endpoint
BR-SLAVE-02 Subsequent POST /register: validate header against stored ApiKey. Match → update MasterUrl + LastContactedAt. Mismatch → 401. Register endpoint
BR-SLAVE-03 POST /status without existing registration → 401 Status push endpoint
BR-SLAVE-04 POST /status with key mismatch → 401 Status push endpoint
BR-SLAVE-05 GET /registered-url without existing registration → 401 Get-URL endpoint
BR-SLAVE-06 GET /registered-url with key mismatch → 401 Get-URL endpoint
BR-SLAVE-07 Missing or empty X-Master-Api-Key header → 401 on all endpoints All master endpoints

Rule Set 2: Master Gate Bypass

graph TD
    A{Path starts with\nbypass prefix?} -->|Yes| B[Pass through\nunconditionally]
    A -->|No| C{Valid admin\nJWT bearer?}
    C -->|Yes| B
    C -->|No| D{Master gate\nenabled?}
    D -->|_masterIsAvailable = true\nor default| E[Proceed to\nlocal gate]
    D -->|_masterIsAvailable = false| F[Return 503\nwith master message]
    E --> G{Local availability\ncheck}
    G -->|Available| H[Pass to next\nmiddleware]
    G -->|Unavailable| I[Return 503\nwith local message]

    classDef decision fill:#FFC107,stroke:#F57F17,color:#000
    classDef pass fill:#9ae6b4,stroke:#2f855a,color:#000
    classDef fail fill:#FC8181,stroke:#C53030,color:#000

    class A,C,D,G decision
    class B,E,H pass
    class F,I fail

Text alternative: Bypass path check first. Admin JWT next (bypasses both gates). Then master gate (static field). If master blocks: 503. If master passes: local gate. If local blocks: 503. Otherwise pass through.

Bypass prefix list (extended from existing):

Path prefix Reason
/api/v1/Availability/status Already bypassed — public status endpoint
/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:

# Rule
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 (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

Rule Set 3: MasterRegistration Singleton

# Rule
BR-SLAVE-12 MasterRegistration is a singleton: Id is always Guid.Parse("00000000-0000-0000-0000-000000000001")
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)

Rule Set 4: Controller Routing

Endpoint Method Route Auth
Register master POST /api/v1/master/register None (API key in header)
Receive status push POST /api/v1/master/status None (API key in header)
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