# Business Rules — Unit 2: slave-availability-extension ## Rule Set 1: API Key Validation ```mermaid 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 ```mermaid 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 |