# Business Rules — Unit 1: master-backend ## BR-01 — Status Update Decision Logic ```mermaid 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 ```mermaid 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 |