Adds 2 units and docs for unit 3. nfr-requirements plan

This commit is contained in:
2026-06-29 22:18:37 +02:00
parent 0e01ca1e1c
commit c156107cb1
126 changed files with 15204 additions and 80199 deletions
+1
View File
@@ -4,3 +4,4 @@
|---------|--------|--------|---------------------|---------------|
| SlpModularCms.Api Implementation (slp-modular-cms-api) | ✅ Complete | unknown | Core, Identity, Availability, Shell | 2026-06-07 |
| CMS Frontend (cms-frontend) | ✅ Complete | unknown | Frontend, Identity (Auth), Availability | 2026-06-16 |
| Master CMS Module (master-cms-module) | 🟢 Construction | unknown | Modules, Availability | 2026-06-26 |
+16
View File
@@ -0,0 +1,16 @@
# Feature Resolution
I found an existing AI-DLC workspace with the following features:
| # | Feature | Status | Branch |
|---|---------|--------|--------|
| 1 | SlpModularCms.Api Implementation (slp-modular-cms-api) | ✅ Complete | unknown |
| 2 | CMS Frontend (cms-frontend) | ✅ Complete | unknown |
**What would you like to do?**
A) Continue working on an existing feature (neither of the above match this request)
B) Start a NEW feature for this request — Master CMS Module (recommended)
C) Other (please describe)
[Answer]: B
@@ -0,0 +1,59 @@
# AI-DLC State Tracking
## Project Information
- **Feature Name**: Master CMS Module
- **Feature Slug**: master-cms-module
- **Project Type**: Brownfield
- **Start Date**: 2026-06-26T00:00:00Z
- **Current Stage**: CONSTRUCTION - Unit 2 (slave-availability-extension) Code Generation
- **Branch**: unknown
## Workspace State
- **Existing Code**: Yes
- **Reverse Engineering Needed**: No (artifacts exist in `aidlc-docs/_shared/reverse-engineering/`)
- **Workspace Root**: K:\Development\Projects\SlpModularCms
## Code Location Rules
- **Application Code**: Workspace root (NEVER in aidlc-docs/)
- **Feature Documentation**: aidlc-docs/features/master-cms-module/ only
- **Shared Artifacts**: aidlc-docs/_shared/
- **Structure patterns**: See code-generation.md Critical Rules
## Language Configuration
- **Documentation Language**: English
- **Conversation Language**: Dutch (User Language)
## Extension Configuration
[Will be populated during Requirements Analysis]
## Execution Plan Summary
- **Total Units**: 4 (master-backend, slave-availability-extension, frontend-cms-page, documentation)
- **Stages to Execute**: Application Design, Units Generation, Functional Design (per unit), NFR Requirements (per unit), NFR Design (per unit), Code Generation (per unit), Build and Test
- **Stages to Skip**: User Stories, Infrastructure Design
## Stage Progress
### 🔵 INCEPTION PHASE
- [x] Workspace Detection — Complete
- [x] Reverse Engineering — Skipped (shared artifacts exist)
- [x] Requirements Analysis — Complete
- [x] User Stories — Skipped
- [x] Workflow Planning — Complete
- [x] Application Design — Complete
- [x] Units Generation — Complete
### 🟢 CONSTRUCTION PHASE
- [x] Functional Design — Complete (Unit 1)
- [x] NFR Requirements — Complete (Unit 1)
- [x] NFR Design — Complete (Unit 1)
- [x] Code Generation — Complete (Unit 1)
- [x] Functional Design — Complete (Unit 2)
- [x] NFR Requirements — Complete (Unit 2)
- [x] NFR Design — Complete (Unit 2)
- [x] Code Generation — Complete (Unit 2)
- [ ] Infrastructure Design — Skipped
- [ ] Code Generation — Execute (per unit)
- [ ] Build and Test — Execute
### 🟡 OPERATIONS PHASE
- [ ] Operations — Placeholder
@@ -0,0 +1,349 @@
# Audit Log — Master CMS Module
## Initial Request — Workspace Detection
**Timestamp**: 2026-06-26T00:00:00Z
**User Input**: "Ik wil een nieuwe feature toevoegen. Een module die de CMS-pagina vult. Deze module zal de beschikbaarheid van andere CMS-en kunnen regelen en zal als een soort "Master"-module fungeren. De availability feature zal hier ook naar moeten gaan kijken, behalve een CMS die deze module aan heeft staan."
**AI Response**: "Workspace Detection gestart. Bestaand multi-feature workspace gevonden met 2 afgeronde features. Nieuwe feature 'master-cms-module' aangemaakt. Taalvoorkeur gevraagd."
**Context**: Workspace Detection — nieuw feature aangemaakt; brownfield project; reverse engineering artefacten bestaan al in `aidlc-docs/_shared/`
---
## Workflow Planning — Complete
**Timestamp**: 2026-06-26T00:04:00Z
**User Input**: "Goedkeuren & doorgaan"
**AI Response**: "Requirements goedgekeurd. Execution plan aangemaakt met 4 units en 7 actieve fases. Doorgaan naar Application Design."
**Context**: Workflow Planning voltooid; execution-plan.md aangemaakt
---
## Requirements Analysis — Complete
**Timestamp**: 2026-06-26T00:03:00Z
**User Input**: "Ik heb de vragen beantwoord"
**AI Response**: "Clarification answers verwerkt. Requirements document gegenereerd: 14 functionele requirements en 5 NFRs. Requirements Analysis afgerond."
**Context**: Requirements Analysis voltooid; requirements.md aangemaakt
---
## Requirements Analysis — User Answers Received (Round 1)
**Timestamp**: 2026-06-26T00:02:00Z
**User Input**: "Ik heb de vragen beantwoord"
**AI Response**: "Antwoorden gelezen. Tegenstrijdigheden gedetecteerd op Q3/Q4 (hoe weet de slave de master URL) en ontbrekende details over authenticatie, cache-duur, fallback-waarde, en UI-acties. Clarification questions aangemaakt."
**Context**: Requirements Analysis — ronde 2 clarification questions aangemaakt
---
## Requirements Analysis — Clarifying Questions Created
**Timestamp**: 2026-06-26T00:01:00Z
**User Input**: "[Language preference answered: A — English for documentation, Dutch for conversation]"
**AI Response**: "Created requirement-verification-questions.md with 10 clarifying questions covering: CMS page content, slave CMS registration model, availability enforcement mechanism, middleware extension strategy, exemption logic, role access, module structure, database entities, frontend scope, and deployment model."
**Context**: Requirements Analysis — awaiting user answers to clarifying questions
---
## Session Resume — Application Design
**Timestamp**: 2026-06-27T00:00:00Z
**User Input**: "hervat de feature master-cms-module"
**AI Response**: "Session resumed. Loaded artifacts: requirements.md, execution-plan.md, architecture.md, code-structure.md. Current stage: INCEPTION — Application Design (not yet started). Creating application-design-plan.md with design questions."
**Context**: Session resumed; proceeding with Application Design stage
---
## Code Generation Unit 2 — Complete
**Timestamp**: 2026-06-29T02:30:00Z
**User Input**: "Plan goedkeuren"
**AI Response**: "Code Generation Unit 2 voltooid. 23 stappen uitgevoerd: 13 nieuwe bronbestanden, 5 nieuwe testbestanden, 2 aanpassingen (middleware + module), 1 bestaande test bijgewerkt, sln bijgewerkt. 38+20+42=100 tests slagen."
**Context**: Unit 2 (slave-availability-extension) klaar; wachten op goedkeuring
---
## NFR Design Unit 2 — Approved
**Timestamp**: 2026-06-29T02:00:00Z
**User Input**: "Doorgaan naar volgende fase"
**AI Response**: "NFR Design Unit 2 goedgekeurd. Doorgaan naar Code Generation Unit 2."
**Context**: NFR Design afgesloten; Code Generation gestart
---
## NFR Design Unit 2 — Artifacts Generated
**Timestamp**: 2026-06-29T01:55:00Z
**User Input**: "ik heb de vragen beantwoord"
**AI Response**: "Q1=A (InvokeAsync injection), Q2=B (deps record). Gegenereerd: nfr-design-patterns.md, logical-components.md. Awaiting approval."
**Context**: NFR Design Unit 2 artefacten klaar; wachten op goedkeuring
---
## NFR Requirements Unit 2 — Approved
**Timestamp**: 2026-06-29T01:40:00Z
**User Input**: "Doorgaan naar volgende fase"
**AI Response**: "NFR Requirements Unit 2 goedgekeurd. Doorgaan naar NFR Design Unit 2."
**Context**: NFR Requirements afgesloten; NFR Design gestart
---
## NFR Requirements Unit 2 — Artifacts Generated
**Timestamp**: 2026-06-29T01:35:00Z
**User Input**: "ik heb de vragen beantwoord"
**AI Response**: "Q1=C (Data Protection), Q2=A (volatile), Q3=B (Module+DTOs excluded), Q4=B (structured logging). Gegenereerd: nfr-requirements.md, tech-stack-decisions.md. Awaiting approval."
**Context**: NFR Requirements Unit 2 artefacten klaar; wachten op goedkeuring
---
## Functional Design Unit 2 — Approved
**Timestamp**: 2026-06-29T01:20:00Z
**User Input**: "Doorgaan naar volgende fase"
**AI Response**: "Functional Design Unit 2 goedgekeurd. Doorgaan naar NFR Requirements Unit 2."
**Context**: FD Unit 2 afgesloten; NFR Requirements gestart
---
## Functional Design Unit 2 — Artifacts Generated
**Timestamp**: 2026-06-29T01:15:00Z
**User Input**: "ik heb de vragen beantwoord"
**AI Response**: "Q1=B (stored at first registration), Q2=A (fail-open), Q3=A (same bypasses + /api/v1/master/), Q4=A (no expiry), Q5=B (extended + ApiKey added). Generated: domain-entities.md, business-logic-model.md, business-rules.md."
**Context**: FD Unit 2 artefacten klaar; wachten op goedkeuring
---
## Functional Design Unit 2 — Plan Created
**Timestamp**: 2026-06-29T01:05:00Z
**User Input**: "Ziet er nu goed uit. ga verder"
**AI Response**: "Functional Design plan aangemaakt voor Unit 2 (slave-availability-extension): 5 vragen over API key validatie, no-master gedrag, gate bypass, cache expiry, entity fields."
**Context**: Unit 2 FD plan klaar; wachten op antwoorden
---
## Code Generation Unit 1 — Approved
**Timestamp**: 2026-06-29T01:00:00Z
**User Input**: "Ziet er nu goed uit. ga verder"
**AI Response**: "Unit 1 goedgekeurd. Doorgaan naar Construction Unit 2 (slave-availability-extension) — Functional Design."
**Context**: Unit 1 volledig klaar (42/42 tests); start Unit 2
---
## Code Generation Unit 1 — Complete
**Timestamp**: 2026-06-29T00:30:00Z
**User Input**: "Plan goedkeuren"
**AI Response**: "Code Generation Unit 1 voltooid. 21 stappen uitgevoerd: 2 nieuwe projecten, 19 bronbestanden, 6 testbestanden, 2 aanpassingen (Api.csproj + sln)."
**Context**: Unit 1 (master-backend) klaar; feature status bijgewerkt naar Construction
---
## Code Generation Unit 1 — Plan Approved
**Timestamp**: 2026-06-29T00:05:00Z
**User Input**: "Plan goedkeuren"
**AI Response**: "Plan goedgekeurd. Start Code Generation Part 2 — uitvoering stap voor stap."
**Context**: Code Generation Part 2 gestart
---
## Code Generation Unit 1 — Plan Created
**Timestamp**: 2026-06-29T00:00:00Z
**User Input**: "Doorgaan naar volgende fase"
**AI Response**: "Code Generation plan aangemaakt voor Unit 1 (master-backend): 21 stappen, 24 nieuwe bestanden + 2 aanpassingen + 6 testbestanden. Wachten op goedkeuring."
**Context**: Code Generation Part 1 (Planning) klaar; plan staat in master-backend-code-generation-plan.md
---
## NFR Design Unit 1 — Approved
**Timestamp**: 2026-06-27T00:50:00Z
**User Input**: "Doorgaan naar volgende fase"
**AI Response**: "NFR Design Unit 1 goedgekeurd. Doorgaan naar Code Generation Unit 1 (Part 1 — Planning)."
**Context**: NFR Design afgesloten; Code Generation gestart
---
## NFR Design Unit 1 — Artifacts Generated
**Timestamp**: 2026-06-27T00:45:00Z
**User Input**: "ik heb de vragen beantwoord"
**AI Response**: "Antwoorden verwerkt: Q1=B (IApiKeyProtector wrapper), Q2=A (AddResilienceHandler), Q3=B (MasterServiceDependencies record). Gegenereerd: nfr-design-patterns.md, logical-components.md. Awaiting approval."
**Context**: NFR Design Unit 1 artefacten klaar; wachten op goedkeuring
---
## NFR Requirements Unit 1 — Approved
**Timestamp**: 2026-06-27T00:40:00Z
**User Input**: "Doorgaan naar volgende fase"
**AI Response**: "NFR Requirements Unit 1 goedgekeurd. Doorgaan naar NFR Design Unit 1."
**Context**: NFR Requirements afgesloten; NFR Design gestart
---
## NFR Requirements Unit 1 — Artifacts Generated
**Timestamp**: 2026-06-27T00:35:00Z
**User Input**: "ik heb de vragen beantwoord"
**AI Response**: "Antwoorden verwerkt: Q1=D (HttpTimeoutSeconds, default 10), Q2=C (exponential backoff 3 pogingen via Polly), Q3=A (file system), Q4=B (exclusie module+migrations+DTOs), Q5=C (Warning voor integrity, Error voor status push). Gegenereerd: nfr-requirements.md, tech-stack-decisions.md. Awaiting approval."
**Context**: NFR Requirements Unit 1 artefacten klaar; wachten op goedkeuring
---
## Functional Design Unit 1 — Approved
**Timestamp**: 2026-06-27T00:30:00Z
**User Input**: "Goedkeuren & doorgaan"
**AI Response**: "Functional Design Unit 1 goedgekeurd. Doorgaan naar NFR Requirements Unit 1."
**Context**: Functional Design Unit 1 afgesloten; NFR Requirements gestart
---
## Functional Design Unit 1 — Artifacts Generated
**Timestamp**: 2026-06-27T00:25:00Z
**User Input**: "ik heb de vragen beantwoord"
**AI Response**: "Antwoorden verwerkt: Q1=A (persist+null), Q2=A (geen push voor Inactive), Q3=B (LastIntegrityCheckFailedAt), Q4=C (HttpContext + config fallback), Q5=B (Data Protection), Q6=A (200+SlaveContactSuccess). Gegenereerd: domain-entities.md, business-logic-model.md, business-rules.md. Awaiting approval."
**Context**: Functional Design Unit 1 artefacten klaar; wachten op goedkeuring
---
## Units Generation — Approved
**Timestamp**: 2026-06-27T00:20:00Z
**User Input**: "Goedkeuren & doorgaan"
**AI Response**: "Units Generation goedgekeurd. aidlc-state.md bijgewerkt: Construction Phase gestart, Unit 1 Functional Design. Functional Design plan aangemaakt voor master-backend."
**Context**: Units Generation afgesloten; start Construction Phase Unit 1
---
## Units Generation — Artifacts Generated
**Timestamp**: 2026-06-27T00:15:00Z
**User Input**: "ik heb de vragen beantwoord"
**AI Response**: "Antwoorden verwerkt — geen ambiguïteiten. Gegenereerd: unit-of-work.md, unit-of-work-dependency.md, unit-of-work-story-map.md. Awaiting approval."
**Context**: Units Generation artefacten klaar; wachten op goedkeuring
---
## Units Generation — Plan Created
**Timestamp**: 2026-06-27T00:11:00Z
**User Input**: "Goedkeuren & doorgaan"
**AI Response**: "Application Design goedgekeurd. Units Generation plan aangemaakt: unit-of-work-plan.md met 3 vragen over construction cycle, test project Unit 1, en test project Unit 2."
**Context**: Units Generation gestart; wachten op antwoorden gebruiker
---
## Application Design — Approved
**Timestamp**: 2026-06-27T00:10:00Z
**User Input**: "Goedkeuren & doorgaan"
**AI Response**: "Application Design goedgekeurd. aidlc-state.md bijgewerkt: Application Design = Complete. Doorgaan naar Units Generation."
**Context**: Application Design stage afgesloten; start Units Generation
---
## Application Design — Questions Answered
**Timestamp**: 2026-06-27T00:05:00Z
**User Input**: "ik heb de vragen beantwoord"
**AI Response**: "Answers analyzed — no ambiguities. Generated: components.md, component-methods.md, services.md, component-dependency.md, application-design.md. Awaiting user approval."
**Context**: Application Design artifacts generated; awaiting approval before marking stage complete
---
@@ -0,0 +1,76 @@
# Business Logic Model — Unit 3: frontend-cms-page
## Component Orchestration
`CmsPage` is the root orchestrator. It owns all modal state and delegates data operations to hooks.
```
CmsPage
├── useCmsInstances() → fetches instance list
├── CmsInstanceList → renders table, emits onSetStatus
├── AddCmsInstanceDialog → useAddCmsInstance() internally
└── SetStatusDialog → useUpdateCmsInstanceStatus() internally
```
---
## Data Flows
### Flow 1 — Load instances
```
CmsPage mounts
→ useCmsInstances fires GET /api/v1/CmsInstances
→ isPending: show loading indicator
→ isError: show error message
→ data=[]: render EmptyState (centered placeholder + Add button)
→ data=[...]: render CmsInstanceList
```
### Flow 2 — Add CmsInstance
```
User clicks "Add CMS" button
→ addDialogOpen = true → AddCmsInstanceDialog opens
User fills Name, URL, ApiKey and submits
→ useAddCmsInstance fires POST /api/v1/CmsInstances
→ HTTP 201: close dialog, invalidate ['cmsInstances'], toast success
→ HTTP 400: show FormBannerError inside dialog (do not close)
→ Network error: show FormBannerError inside dialog
```
### Flow 3 — Update instance status
```
User clicks "Set Status" in row actions dropdown
→ statusTarget = instance → SetStatusDialog opens
User selects status, optionally fills DisableMessage, submits
→ useUpdateCmsInstanceStatus fires PUT /api/v1/CmsInstances/{id}/status
→ HTTP 200 + slaveContactSuccess=true:
close dialog, invalidate ['cmsInstances'],
toast "Status updated — cliënt confirmed"
→ HTTP 200 + slaveContactSuccess=false:
close dialog, invalidate ['cmsInstances'],
toast "Status saved — cliënt unreachable"
→ HTTP 404: toast error "Instance not found"
→ HTTP 400: toast error (generic)
→ Network error: toast error (generic)
```
### Flow 4 — DisableMessage conditional visibility
```
SetStatusDialog: status field changes
→ status === 'NotAvailable': show DisableMessage field (required)
→ status !== 'NotAvailable': hide DisableMessage field, clear value
```
---
## Cache Strategy
| Hook | Query key | Stale time | Invalidated by |
|------|-----------|------------|----------------|
| `useCmsInstances` | `['cmsInstances']` | 30 000 ms | `useAddCmsInstance.onSuccess`, `useUpdateCmsInstanceStatus.onSuccess` |
| `useAddCmsInstance` | — | — | — |
| `useUpdateCmsInstanceStatus` | — | — | — |
@@ -0,0 +1,49 @@
# Business Rules — Unit 3: frontend-cms-page
## Table Display (Q1: B+C)
- **BR-FE-01**: The `CmsInstanceList` table displays six columns: Name, URL, Status, Last Contact, DisableMessage, Actions.
- **BR-FE-02**: `lastContactedAt` is formatted as a localised date/time string; displays "—" when `null`.
- **BR-FE-03**: `disableMessage` displays "—" when `null` or empty.
## Inactive Row Styling (Q2: A)
- **BR-FE-04**: A `<TableRow>` whose `instance.status === 'Inactive'` receives `className="opacity-50"`. All cells within that row are visually dimmed as a result.
## Status Badge Colors
- **BR-FE-05**: `Available` → green badge (`secondary` or custom green variant).
- **BR-FE-06**: `NotAvailable` → red badge (`destructive` variant).
- **BR-FE-07**: `Inactive` → muted badge (`outline` variant).
## SetStatusDialog — DisableMessage (Q3 note, existing unit-of-work)
- **BR-FE-08**: The DisableMessage field is only rendered when the selected status is `NotAvailable`.
- **BR-FE-09**: DisableMessage is required (non-empty) when status is `NotAvailable`. Form submit is blocked if it is empty.
- **BR-FE-10**: When status changes away from `NotAvailable`, the DisableMessage field is hidden and its value is reset to `""`.
- **BR-FE-11**: The value sent to the API is `null` for all statuses except `NotAvailable`; for `NotAvailable` it is the trimmed string value.
## UpdateStatusResult Toast (Q3: B, "cliënt" terminology)
- **BR-FE-12**: On HTTP 200 with `slaveContactSuccess === true`: show toast "Status updated — cliënt confirmed".
- **BR-FE-13**: On HTTP 200 with `slaveContactSuccess === false`: show toast "Status saved — cliënt unreachable".
- **BR-FE-14**: The word "slave" must not appear in any user-visible text. Use "cliënt" in all UI strings.
## AddCmsInstanceDialog Error Handling (Q4: A)
- **BR-FE-15**: On HTTP 400 from `POST /api/v1/CmsInstances`: display a `FormBannerError` at the top of the dialog. The dialog remains open.
- **BR-FE-16**: On network error: display a `FormBannerError` at the top of the dialog. The dialog remains open.
- **BR-FE-17**: On success (HTTP 201): close the dialog and show a success toast.
## Empty State (Q5: A)
- **BR-FE-18**: When `useCmsInstances` returns an empty array, render a centered placeholder instead of the table. The placeholder contains:
- A `LayoutGrid` icon (consistent with the existing `CmsPage` placeholder).
- A heading: "No CMS instances yet".
- A description: "Add your first CMS instance to get started."
- An "Add CMS" button that opens `AddCmsInstanceDialog`.
- **BR-FE-19**: The "Add CMS" button in the page header is always visible regardless of empty state.
## Access Control
- **BR-FE-20**: The `/cms` route is already guarded by `RoleGuard allowedRoles={['Owner']}`. No additional guard logic is needed inside `CmsPage`.
@@ -0,0 +1,74 @@
# Domain Entities — Unit 3: frontend-cms-page
## TypeScript Types
### CmsInstanceStatus
```typescript
export type CmsInstanceStatus = 'Available' | 'NotAvailable' | 'Inactive';
```
String union matching `CmsInstanceStatus` enum values serialized by the backend (`Available`, `NotAvailable`, `Inactive`).
---
### CmsInstance
```typescript
export interface CmsInstance {
id: string; // UUID
name: string;
url: string;
status: CmsInstanceStatus;
disableMessage: string | null;
lastContactedAt: string | null; // ISO 8601, null if never contacted
lastStatusPushedAt: string | null; // ISO 8601
lastIntegrityCheckFailedAt: string | null; // ISO 8601
}
```
---
### CreateCmsInstanceRequest
```typescript
export interface CreateCmsInstanceRequest {
name: string;
url: string;
apiKey: string;
}
```
Sent as body to `POST /api/v1/CmsInstances`.
---
### UpdateCmsInstanceStatusRequest
```typescript
export interface UpdateCmsInstanceStatusRequest {
status: CmsInstanceStatus;
disableMessage: string | null;
}
```
Sent as body to `PUT /api/v1/CmsInstances/{id}/status`. `disableMessage` is `null` unless `status === 'NotAvailable'`.
---
### UpdateStatusResult
```typescript
export interface UpdateStatusResult {
success: boolean;
slaveContactSuccess: boolean; // true = cliënt was reachable and confirmed
}
```
Returned by `PUT /api/v1/CmsInstances/{id}/status`. `slaveContactSuccess` drives the differentiated toast message.
---
## File Location
All types added to `src/api/types.ts`.
@@ -0,0 +1,214 @@
# Frontend Components — Unit 3: frontend-cms-page
## Component Hierarchy
```
CmsPage (src/pages/CmsPage.tsx)
├── AddCmsInstanceDialog (src/components/cms/AddCmsInstanceDialog.tsx)
├── SetStatusDialog (src/components/cms/SetStatusDialog.tsx)
└── CmsInstanceList (src/components/cms/CmsInstanceList.tsx)
```
---
## CmsPage
**File**: `src/pages/CmsPage.tsx`
**State**:
| State | Type | Initial | Description |
|-------|------|---------|-------------|
| `addDialogOpen` | `boolean` | `false` | Controls AddCmsInstanceDialog visibility |
| `statusTarget` | `CmsInstance \| null` | `null` | Instance passed to SetStatusDialog; null = closed |
**Hooks**: `useCmsInstances()`
**Render logic**:
```
Header row: title + "Add CMS" button (always visible)
isPending → loading text
isError → error text
data === [] → EmptyState (icon + heading + description + Add button)
data.length > 0 → CmsInstanceList
AddCmsInstanceDialog (controlled by addDialogOpen)
SetStatusDialog (controlled by statusTarget !== null)
```
---
## CmsInstanceList
**File**: `src/components/cms/CmsInstanceList.tsx`
**Props**:
```typescript
interface CmsInstanceListProps {
instances: CmsInstance[];
onSetStatus: (instance: CmsInstance) => void;
}
```
**Columns** (BR-FE-01):
| Column | Source field | Notes |
|--------|-------------|-------|
| Name | `instance.name` | — |
| URL | `instance.url` | — |
| Status | `instance.status` | Rendered as badge (BR-FE-05..07) |
| Last Contact | `instance.lastContactedAt` | Formatted date; "—" when null (BR-FE-02) |
| Disable Message | `instance.disableMessage` | "—" when null (BR-FE-03) |
| Actions | — | Dropdown with "Set Status" item |
**Row styling**: `<TableRow className={instance.status === 'Inactive' ? 'opacity-50' : ''}>` (BR-FE-04)
**Interactions**: Clicking "Set Status" in the actions dropdown calls `onSetStatus(instance)`.
---
## AddCmsInstanceDialog
**File**: `src/components/cms/AddCmsInstanceDialog.tsx`
**Props**:
```typescript
interface AddCmsInstanceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
```
**Form fields**:
| Field | Type | Validation |
|-------|------|-----------|
| Name | text | Required, non-empty |
| URL | text | Required, valid URL format |
| ApiKey | password | Required, non-empty |
**Hooks**: `useAddCmsInstance()`
**Submit behaviour**:
- Success (HTTP 201): call `onOpenChange(false)`, show success toast
- HTTP 400: show `FormBannerError` inside dialog, keep open (BR-FE-15)
- Network error: show `FormBannerError` inside dialog, keep open (BR-FE-16)
---
## SetStatusDialog
**File**: `src/components/cms/SetStatusDialog.tsx`
**Props**:
```typescript
interface SetStatusDialogProps {
instance: CmsInstance | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
```
**Form fields**:
| Field | Type | Condition | Validation |
|-------|------|-----------|-----------|
| Status | select (`Available` \| `NotAvailable` \| `Inactive`) | always | Required |
| DisableMessage | text | only when `status === 'NotAvailable'` | Required when visible |
**Hooks**: `useUpdateCmsInstanceStatus()`
**Submit behaviour**:
- HTTP 200 + `slaveContactSuccess=true`: close dialog, toast "Status updated — cliënt confirmed" (BR-FE-12)
- HTTP 200 + `slaveContactSuccess=false`: close dialog, toast "Status saved — cliënt unreachable" (BR-FE-13)
- HTTP 404: toast error, keep open
- HTTP 400 / network error: toast error, keep open
**DisableMessage field logic** (BR-FE-08..11):
- Rendered only when selected status is `'NotAvailable'`
- On status change away from `'NotAvailable'`: clear field value
- Value sent to API: `null` unless status is `'NotAvailable'`
---
## Hooks
### useCmsInstances
**File**: `src/api/useCmsInstances.ts`
```typescript
useQuery<CmsInstance[], Error>({
queryKey: ['cmsInstances'],
queryFn: () => api.get<CmsInstance[]>('/api/v1/CmsInstances'),
staleTime: 30_000,
})
```
### useAddCmsInstance
**File**: `src/api/useAddCmsInstance.ts`
```typescript
useMutation<CmsInstance, Error, CreateCmsInstanceRequest>({
mutationFn: (data) => api.post<CmsInstance>('/api/v1/CmsInstances', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }),
})
```
### useUpdateCmsInstanceStatus
**File**: `src/api/useUpdateCmsInstanceStatus.ts`
```typescript
useMutation<UpdateStatusResult, Error, { id: string } & UpdateCmsInstanceStatusRequest>({
mutationFn: ({ id, ...body }) =>
api.put<UpdateStatusResult>(`/api/v1/CmsInstances/${id}/status`, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }),
})
```
---
## i18n Keys (additions to `cms` namespace)
```json
"cms": {
"title": "CMS Instances",
"addButton": "Add CMS",
"emptyState": {
"heading": "No CMS instances yet",
"description": "Add your first CMS instance to get started."
},
"table": {
"name": "Name",
"url": "URL",
"status": "Status",
"lastContact": "Last Contact",
"disableMessage": "Disable Message",
"actions": "Actions",
"never": "—"
},
"status": {
"Available": "Available",
"NotAvailable": "Unavailable",
"Inactive": "Inactive"
},
"actions": {
"setStatus": "Set Status"
},
"add": {
"title": "Add CMS Instance",
"nameLabel": "Name",
"urlLabel": "URL",
"apiKeyLabel": "API Key",
"submitButton": "Add",
"successToast": "CMS instance added successfully"
},
"setStatus": {
"title": "Set Status",
"statusLabel": "Status",
"disableMessageLabel": "Disable Message",
"disableMessagePlaceholder": "Reason for disabling…",
"submitButton": "Save",
"successContactedToast": "Status updated — cliënt confirmed",
"successUnreachableToast": "Status saved — cliënt unreachable"
}
}
```
@@ -0,0 +1,63 @@
# Code Summary — Unit 1: master-backend
## New Project: SlpModularCms.Modules.Master
| File | Description |
|------|-------------|
| `SlpModularCms.Modules.Master.csproj` | Project file; references Core; adds Microsoft.Extensions.Http.Resilience 9.6.0 |
| `Options/MasterModuleOptions.cs` | Configuration POCO: IntegrityCheckIntervalMinutes, HttpTimeoutSeconds, MasterUrl, CacheMinutes, ApiKey |
| `Data/Entities/CmsInstanceStatus.cs` | Enum: Available=0, NotAvailable=1, Inactive=2 |
| `Data/Entities/CmsInstance.cs` | EF Core entity with all domain fields including LastIntegrityCheckFailedAt |
| `Data/MasterDbContext.cs` | Per-module DbContext; table MasterCmsInstances; configured via OnModelCreating |
| `Models/CmsInstanceDto.cs` | Record DTO (excludes ApiKey); ExcludeFromCodeCoverage |
| `Models/CreateCmsInstanceRequest.cs` | Record request for POST; ExcludeFromCodeCoverage |
| `Models/UpdateStatusRequest.cs` | Record request for status update; ExcludeFromCodeCoverage |
| `Models/UpdateStatusResult.cs` | Record result with Success + SlaveContactSuccess; ExcludeFromCodeCoverage |
| `Repositories/ICmsInstanceRepository.cs` | Interface: GetAllAsync, GetActiveAsync, GetByIdAsync, AddAsync, Update, SaveChangesAsync |
| `Repositories/CmsInstanceRepository.cs` | EF Core implementation; GetActiveAsync excludes Inactive |
| `Services/IApiKeyProtector.cs` | Interface: Protect/Unprotect |
| `Services/ApiKeyProtector.cs` | Data Protection wrapper; purpose string "SlpModularCms.Master.ApiKey" |
| `Services/ISlaveApiClient.cs` | Interface: RegisterMasterAsync, PushStatusAsync, GetRegisteredMasterUrlAsync |
| `Services/SlaveApiClient.cs` | Typed HTTP client; X-Master-Api-Key header on each call; fail-open (returns false on exception) |
| `Services/MasterServiceDependencies.cs` | Record aggregating 6 CmsInstanceService dependencies; ExcludeFromCodeCoverage |
| `Services/ICmsInstanceService.cs` | Interface: GetAllAsync, AddAsync, UpdateStatusAsync, VerifyIntegrityAsync |
| `Services/CmsInstanceService.cs` | Business logic; HttpContext → config fallback for MasterUrl; never returns ApiKey in DTO |
| `BackgroundServices/IntegrityCheckBackgroundService.cs` | PeriodicTimer; per-tick IServiceScope; catches all exceptions per tick |
| `Controllers/CmsInstanceController.cs` | [Authorize(Policy="OwnerOnly")]; GET / POST / PUT /{id}/status |
| `MasterModule.cs` | IModule implementation; DI registration; db.Database.Migrate() in UseModule; ExcludeFromCodeCoverage |
| `Migrations/.gitkeep` | Placeholder; run CLI to generate migration (see below) |
## New Project: SlpModularCms.Modules.Master.Tests
| File | Description |
|------|-------------|
| `SlpModularCms.Modules.Master.Tests.csproj` | xUnit + NSubstitute + FluentAssertions + EF InMemory |
| `Repositories/CmsInstanceRepositoryTests.cs` | EF InMemory; covers all repository methods |
| `Services/ApiKeyProtectorTests.cs` | Uses EphemeralDataProtectionProvider; round-trip + invalid ciphertext tests |
| `Services/SlaveApiClientTests.cs` | FakeHttpMessageHandler; tests success/failure/exception paths + header assertion |
| `Services/CmsInstanceServiceTests.cs` | NSubstitute; covers all business logic branches including HttpContext fallback |
| `BackgroundServices/IntegrityCheckBackgroundServiceTests.cs` | PeriodicTimer integration; verifies exception isolation |
| `Controllers/CmsInstanceControllerTests.cs` | NSubstitute ICmsInstanceService; verifies all HTTP response codes |
## Modified Files
| File | Change |
|------|--------|
| `src/SlpModularCms.Api/SlpModularCms.Api.csproj` | Added ProjectReference to SlpModularCms.Modules.Master |
| `SlpModularCms.sln` | Added both new projects with GUIDs and src folder nesting |
## EF Core Migration
After building the solution, run:
```bash
dotnet ef migrations add InitialCreate --project src/SlpModularCms.Modules.Master --startup-project src/SlpModularCms.Api
```
This generates the `Migrations/` folder contents. The migration is applied automatically on startup via `db.Database.Migrate()` in `MasterModule.UseModule`.
## Notes
- `Microsoft.Extensions.Http.Resilience` version `9.6.0` — verify/update during `dotnet restore` if a newer version is available for .NET 10
- `IntegrityCheckIntervalMinutes = 0` in tests forces immediate PeriodicTimer ticks (valid for test scenarios only)
- Slave-side endpoints (`/api/v1/master/register`, `/api/v1/master/status`, `/api/v1/master/registered-url`) are implemented in Unit 2 (slave-availability-extension)
@@ -0,0 +1,191 @@
# Business Logic Model — Unit 1: master-backend
## Flow 1 — AddAsync (Add Slave CMS)
**Trigger**: `POST /api/v1/CmsInstances` (Owner only)
```mermaid
sequenceDiagram
box rgba(99,179,237,0.3) API Layer
participant Ctrl as CmsInstanceController
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
participant Ctx as IHttpContextAccessor
participant Opts as MasterModuleOptions
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Ctrl->>Svc: AddAsync(request)
Note over Svc: Validate Name, Url, ApiKey not empty
Note over Svc: Validate Url starts with http or https
Svc->>DP: Protect(request.ApiKey)
DP-->>Svc: encryptedApiKey
Svc->>Repo: AddAsync(new CmsInstance)
Note over Svc,Repo: Status=Available, LastContactedAt=null
Svc->>Repo: SaveChangesAsync()
Repo->>DB: INSERT CmsInstances
Note over Svc: Determine masterUrl
Svc->>Ctx: try get base URL from HttpContext
alt HttpContext available
Ctx-->>Svc: masterUrl from request
else HttpContext unavailable
Svc->>Opts: read MasterUrl
Opts-->>Svc: configured masterUrl
end
Svc->>DP: Unprotect(encryptedApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
alt Registration success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Registration failed
Client-->>Svc: false
Note over Svc: LastContactedAt stays null (owner can see)
end
Svc-->>Ctrl: CmsInstanceDto
Ctrl-->>Ctrl: return 201 Created
```
Text alternative: Controller calls service; service validates, encrypts ApiKey, persists entity, determines master URL from HttpContext or config, attempts slave registration, updates LastContactedAt on success; always returns DTO regardless of registration outcome.
---
## Flow 2 — UpdateStatusAsync (Set Slave Status)
**Trigger**: `PUT /api/v1/CmsInstances/{id}/status` (Owner only)
```mermaid
sequenceDiagram
box rgba(99,179,237,0.3) API Layer
participant Ctrl as CmsInstanceController
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Ctrl->>Svc: UpdateStatusAsync(id, status, disableMessage)
Svc->>Repo: GetByIdAsync(id)
Repo->>DB: SELECT CmsInstances WHERE Id
DB-->>Repo: CmsInstance or null
Repo-->>Svc: entity or null
alt Entity not found
Svc-->>Ctrl: throw NotFoundException
end
Note over Svc: Validate DisableMessage required if NotAvailable
alt Validation fails
Svc-->>Ctrl: throw ValidationException
end
alt newStatus = Inactive
Svc->>Repo: UpdateAsync (Status=Inactive, DisableMessage=null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
else newStatus = Available or NotAvailable
Svc->>Repo: UpdateAsync (Status, DisableMessage)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc->>DP: Unprotect(entity.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, status, disableMessage)
alt Push success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
else Push failed
Client-->>Svc: false
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=false)
end
end
Ctrl-->>Ctrl: return 200 OK with UpdateStatusResult
```
Text alternative: Controller calls service with id and new status; service loads entity, validates, updates DB, then for non-Inactive transitions decrypts ApiKey and pushes status to slave; returns SlaveContactSuccess=false if push fails but DB is always the authority.
---
## Flow 3 — VerifyIntegrityAsync (Background Integrity Check)
**Trigger**: `IntegrityCheckBackgroundService` periodic timer (every `IntegrityCheckIntervalMinutes`)
```mermaid
sequenceDiagram
box rgba(200,200,200,0.3) Background
participant Timer as PeriodicTimer
participant BgSvc as IntegrityCheckBackgroundService
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
participant Opts as MasterModuleOptions
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Timer->>BgSvc: Tick
BgSvc->>Svc: VerifyIntegrityAsync()
Svc->>Repo: GetActiveAsync()
Repo->>DB: SELECT WHERE Status != Inactive
DB-->>Repo: list of CmsInstance
Repo-->>Svc: instances
loop for each instance
Svc->>DP: Unprotect(instance.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Opts: read MasterUrl
Opts-->>Svc: masterUrl
Svc->>Client: GetRegisteredMasterUrlAsync(slaveUrl, plainApiKey)
alt Slave unreachable
Client-->>Svc: throws or returns null
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Slave reachable
Client-->>Svc: registeredMasterUrl
alt URLs match
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else URL mismatch
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
alt Re-registration success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Re-registration failed
Client-->>Svc: false
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
end
end
end
end
Svc-->>BgSvc: done
```
Text alternative: Background timer triggers integrity service; for each non-Inactive slave: decrypts key, retrieves registered master URL, clears failure flag on match, re-registers on mismatch, sets LastIntegrityCheckFailedAt when slave is unreachable or re-registration fails.
@@ -0,0 +1,148 @@
# 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\nNo HTTP push\nSlaveContactSuccess = true"]
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 --> 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 decision
class PersistStatus,DecryptKey,PushSlave,UpdatePushed,SetInactive action
class Start,Done terminal
class NotFound,ValidationErr error
```
Text alternative: Load entity (404 if missing) → validate DisableMessage required for NotAvailable → for Inactive skip push → for others persist, decrypt key, push to slave, set SlaveContactSuccess based on push result.
---
## 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"]
Next(["Next instance"])
Start --> GetUrl --> Reachable
Reachable -->|"no"| SetFailed --> Next
Reachable -->|"yes"| UrlMatch
UrlMatch -->|"match"| ClearOk --> Next
UrlMatch -->|"mismatch"| ReRegister --> RegOk
RegOk -->|"yes"| ClearAfterReg --> Next
RegOk -->|"no"| SetFailedReg --> 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 decision
class GetUrl,SetFailed,ClearOk,ReRegister,ClearAfterReg,SetFailedReg action
class Start,Next terminal
```
Text alternative: For each active slave — attempt to get its registered master URL; if unreachable set failure flag; if reachable and URL matches clear flag; if mismatch re-register; clear flag on success, set flag on failure.
---
## 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 | **No** | Master stops all contact |
| `Inactive` | `Available` | Clear to null | Yes | Reactivation |
| `Inactive` | `NotAvailable` | Required, non-empty | Yes | Reactivation with disable |
---
## 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 never contacted via HTTP |
| BR-CONTACT-02 | Status push is skipped when transitioning any status → `Inactive` |
| BR-CONTACT-03 | Integrity check 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 |
@@ -0,0 +1,132 @@
# Domain Entities — Unit 1: master-backend
## Entity Overview
```mermaid
graph TD
MasterDbCtx["MasterDbContext\n(per-module EF Core DbContext)"]
CmsInst["CmsInstance\n(aggregate root)"]
Status["CmsInstanceStatus\n(enum)"]
Opts["MasterModuleOptions\n(config POCO)"]
DP["IDataProtector\n(ApiKey encryption)"]
DTO["CmsInstanceDto\n(API response shape)"]
CreateReq["CreateCmsInstanceRequest\n(API input)"]
UpdateReq["UpdateStatusRequest\n(API input)"]
UpdateRes["UpdateStatusResult\n(API response for status update)"]
MasterDbCtx -->|"owns"| CmsInst
CmsInst -->|"has"| Status
CmsInst -->|"ApiKey encrypted via"| DP
CmsInst -->|"projected to"| DTO
CreateReq -->|"creates"| CmsInst
UpdateReq -->|"mutates status of"| CmsInst
UpdateRes -->|"returned from UpdateStatusAsync"| CmsInst
Opts -->|"IntegrityCheckIntervalMinutes"| MasterDbCtx
Opts -->|"MasterUrl fallback"| MasterDbCtx
classDef entity fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef infra fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef dto fill:#FFC107,stroke:#F57F17,stroke-width:1px,color:#000
classDef config fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
class CmsInst entity
class MasterDbCtx,DP infra
class Status,DTO,CreateReq,UpdateReq,UpdateRes dto
class Opts config
```
Text alternative: MasterDbContext owns CmsInstance; CmsInstance has a Status enum and its ApiKey is encrypted via IDataProtector; request/response shapes map to and from CmsInstance.
---
## CmsInstance
**Table**: `CmsInstances` (owned by `MasterDbContext`, migrations in `SlpModularCms.Modules.Master`)
| Field | Type | Nullable | Notes |
|-------|------|----------|-------|
| `Id` | `Guid` | No | Primary key |
| `Name` | `string` | No | Friendly display name; required |
| `Url` | `string` | No | Base URL of slave CMS API; must start with `http://` or `https://` |
| `ApiKey` | `string` | No | Encrypted via ASP.NET Core Data Protection before storage; decrypted before HTTP calls |
| `Status` | `CmsInstanceStatus` | No | Default: `Available` on creation |
| `DisableMessage` | `string?` | Yes | Required when `Status = NotAvailable`; null otherwise |
| `LastContactedAt` | `DateTimeOffset?` | Yes | Null = never successfully contacted; set on successful registration or integrity check |
| `LastStatusPushedAt` | `DateTimeOffset?` | Yes | Null = status never successfully pushed; set after successful `PushStatusAsync` |
| `LastIntegrityCheckFailedAt` | `DateTimeOffset?` | Yes | Null = no pending failure; set when integrity check cannot reach slave or re-registration fails; cleared on next successful contact |
---
## CmsInstanceStatus
```csharp
public enum CmsInstanceStatus
{
Available = 0,
NotAvailable = 1,
Inactive = 2,
}
```
| Value | Meaning | Master Contacts Slave? |
|-------|---------|----------------------|
| `Available` | Slave is enabled; normal operation | Yes (status push + integrity checks) |
| `NotAvailable` | Slave is disabled; `DisableMessage` served to end-users | Yes (status push + integrity checks) |
| `Inactive` | Soft-removed; greyed out in UI | **No** — all HTTP contact is halted |
---
## MasterModuleOptions
**Config section**: `"MasterModule"` in `appsettings.json`
| Property | Type | Default | Side | Notes |
|----------|------|---------|------|-------|
| `IntegrityCheckIntervalMinutes` | `int` | `60` | Master | Interval for `IntegrityCheckBackgroundService` |
| `MasterUrl` | `string?` | `null` | Master | Fallback public URL of this master CMS; used by background service when `HttpContext` is unavailable |
| `CacheMinutes` | `int` | `60` | Slave | Slave pull cache interval (used by Unit 2) |
| `ApiKey` | `string?` | `null` | Slave | Slave API key for validating incoming master requests (used by Unit 2) |
---
## CmsInstanceDto (API Response)
**Rule**: `ApiKey` is **never** included (NFR-MASTER-03).
| Property | Type | Notes |
|----------|------|-------|
| `Id` | `Guid` | |
| `Name` | `string` | |
| `Url` | `string` | |
| `Status` | `string` | Serialized as string (`"Available"` / `"NotAvailable"` / `"Inactive"`) |
| `DisableMessage` | `string?` | |
| `LastContactedAt` | `DateTimeOffset?` | |
| `LastStatusPushedAt` | `DateTimeOffset?` | |
| `LastIntegrityCheckFailedAt` | `DateTimeOffset?` | Visible in UI so owner knows which slaves have pending check failures |
---
## CreateCmsInstanceRequest (API Input)
| Property | Type | Validation |
|----------|------|-----------|
| `Name` | `string` | Required, non-empty |
| `Url` | `string` | Required; must start with `http://` or `https://` |
| `ApiKey` | `string` | Required, non-empty |
---
## UpdateStatusRequest (API Input)
| Property | Type | Validation |
|----------|------|-----------|
| `Status` | `CmsInstanceStatus` | Required; must be valid enum value |
| `DisableMessage` | `string?` | Required and non-empty when `Status = NotAvailable`; ignored otherwise |
---
## UpdateStatusResult (Service Return / API Response)
| Property | Type | Notes |
|----------|------|-------|
| `Success` | `bool` | Always `true` when status persisted to DB (DB is the authority) |
| `SlaveContactSuccess` | `bool` | `true` if HTTP push to slave succeeded; `false` if push failed (slave unreachable); not applicable for `Inactive` transitions (returns `true`) |
@@ -0,0 +1,128 @@
# Logical Components — Unit 1: master-backend
## Full Component Wiring Diagram
```mermaid
graph TD
subgraph ServiceLayer["Service Layer"]
CmsService["CmsInstanceService"]
Deps["MasterServiceDependencies\n(constructor record)"]
Repo["ICmsInstanceRepository"]
SlaveClientIface["ISlaveApiClient"]
ApiKeyProt["IApiKeyProtector"]
HttpCtxAcc["IHttpContextAccessor"]
Logger["ILogger"]
Opts["MasterModuleOptions\n(via IOptions)"]
end
subgraph SecurityLayer["Security Layer"]
ApiKeyProtImpl["ApiKeyProtector"]
DataProt["IDataProtectionProvider\n(ASP.NET Core)"]
Purpose["Purpose string\nSlpModularCms.Master.ApiKey"]
end
subgraph HttpLayer["HTTP + Resilience Layer"]
SlaveClientImpl["SlaveApiClient"]
PollyPipeline["Polly ResiliencePipeline\nRetry x2 + Timeout"]
HttpClientInst["HttpClient\n(IHttpClientFactory)"]
end
subgraph BackgroundLayer["Background Service"]
BgSvc["IntegrityCheckBackgroundService"]
ScopeFactory["IServiceScopeFactory"]
Scope["IServiceScope\n(per tick)"]
PeriodicT["PeriodicTimer\n(IntegrityCheckIntervalMinutes)"]
end
subgraph DataLayer["Data Layer"]
RepoImpl["CmsInstanceRepository"]
DbCtx["MasterDbContext"]
Table["CmsInstances table"]
end
CmsService --> Deps
Deps --> Repo
Deps --> SlaveClientIface
Deps --> ApiKeyProt
Deps --> HttpCtxAcc
Deps --> Logger
Deps --> Opts
ApiKeyProt --> ApiKeyProtImpl
ApiKeyProtImpl --> DataProt
DataProt --> Purpose
SlaveClientIface --> SlaveClientImpl
SlaveClientImpl --> PollyPipeline
PollyPipeline --> HttpClientInst
Repo --> RepoImpl
RepoImpl --> DbCtx
DbCtx --> Table
BgSvc --> PeriodicT
BgSvc --> ScopeFactory
ScopeFactory --> Scope
Scope --> CmsService
classDef service fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef security fill:#FC8181,stroke:#C53030,stroke-width:1px,color:#000
classDef http fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef background fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
classDef data fill:#FFC107,stroke:#F57F17,stroke-width:1px,color:#000
classDef record fill:#B0BEC5,stroke:#546E7A,stroke-width:1px,color:#000
class CmsService,Repo,SlaveClientIface,ApiKeyProt service
class Deps record
class ApiKeyProtImpl,DataProt,Purpose security
class SlaveClientImpl,PollyPipeline,HttpClientInst,HttpCtxAcc http
class BgSvc,ScopeFactory,Scope,PeriodicT background
class RepoImpl,DbCtx,Table,Logger,Opts data
```
Text alternative: CmsInstanceService receives all dependencies via MasterServiceDependencies record; IApiKeyProtector wraps Data Protection; ISlaveApiClient wraps SlaveApiClient backed by Polly pipeline; ICmsInstanceRepository wraps MasterDbContext; IntegrityCheckBackgroundService creates a new IServiceScope per PeriodicTimer tick to resolve CmsInstanceService.
---
## Component Responsibility Summary
| Component | Type | NFR Pattern |
|-----------|------|------------|
| `MasterServiceDependencies` | Record | Constructor aggregation (reduces constructor arity) |
| `IApiKeyProtector` / `ApiKeyProtector` | Interface + Singleton | Security — Data Protection wrapper; mock-friendly |
| `SlaveApiClient` | Typed HTTP client | Resilience — Polly retry + timeout applied via `AddResilienceHandler` |
| `IntegrityCheckBackgroundService` | Singleton `BackgroundService` | Reliability — per-tick `IServiceScope`; exception isolation per slave |
| `MasterDbContext` | EF Core DbContext | Maintainability — per-module migrations; own connection |
| `CmsInstanceService` | Scoped service | Orchestration — resolved via `IServiceScope` by background service |
---
## DI Registration Order (in `MasterModule.RegisterServices`)
```
1. services.AddDataProtection()
2. services.AddSingleton<IApiKeyProtector, ApiKeyProtector>()
3. services.Configure<MasterModuleOptions>(config.GetSection("MasterModule"))
4. services.AddDbContext<MasterDbContext>(...)
5. services.AddScoped<ICmsInstanceRepository, CmsInstanceRepository>()
6. services.AddScoped<MasterServiceDependencies>()
7. services.AddScoped<ICmsInstanceService, CmsInstanceService>()
8. services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-resilience", ...)
9. services.AddHostedService<IntegrityCheckBackgroundService>()
10. services.AddHttpContextAccessor() (if not already registered by host)
```
---
## NFR Coverage Traceability
| NFR | Pattern Applied | Component |
|-----|----------------|-----------|
| Fail-open (NFR-MASTER-01) | `SlaveApiClient` catches failures, returns `false`; service continues | `SlaveApiClient`, `CmsInstanceService` |
| API key security (NFR-MASTER-03) | `IApiKeyProtector` wraps Data Protection; never returns key in DTO | `ApiKeyProtector`, `CmsInstanceDto` mapping |
| Configurable interval (NFR-MASTER-04) | `PeriodicTimer` reads `MasterModuleOptions.IntegrityCheckIntervalMinutes` | `IntegrityCheckBackgroundService` |
| ≥80% test coverage (NFR-MASTER-05) | All service/repository/client classes have interfaces; `MasterServiceDependencies` simplifies test setup | All interfaces |
| Per-module migrations (NFR-MASTER-06) | `MasterDbContext` with own migration assembly; applied in `UseModule` | `MasterDbContext`, `MasterModule` |
| Retry resilience (Q2) | Polly exponential backoff on `IHttpClientBuilder` | `SlaveApiClient` registration |
| Timeout (Q1) | Polly `AddTimeout` per attempt, driven by `HttpTimeoutSeconds` | `SlaveApiClient` registration |
| Logging levels (Q5) | `Error` for status push failures; `Warning` for integrity check failures | `CmsInstanceService`, `IntegrityCheckBackgroundService` |
@@ -0,0 +1,208 @@
# NFR Design Patterns — Unit 1: master-backend
## Pattern 1 — Resilience: Polly Pipeline via `AddResilienceHandler`
**NFR**: Exponential backoff (3 attempts), per-attempt timeout (`HttpTimeoutSeconds`)
**Pattern**: Single shared resilience pipeline registered on the `IHttpClientBuilder` for `SlaveApiClient`. All HTTP calls from `SlaveApiClient` pass through the pipeline automatically — no per-method boilerplate.
**Pipeline composition** (outer → inner execution order):
1. **Retry**`AddRetry` with exponential backoff; max 2 retries (3 total attempts); base delay 1s → 2s with jitter; retries on `HttpRequestException` and non-2xx responses
2. **Timeout**`AddTimeout` with `TimeSpan.FromSeconds(MasterModuleOptions.HttpTimeoutSeconds)`; applied per attempt (not total)
**Registration** (in `MasterModule.RegisterServices`):
```csharp
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-resilience", (builder, ctx) =>
{
var opts = ctx.ServiceProvider
.GetRequiredService<IOptions<MasterModuleOptions>>().Value;
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Exception is HttpRequestException ||
(args.Outcome.Result?.IsSuccessStatusCode == false))
});
builder.AddTimeout(TimeSpan.FromSeconds(opts.HttpTimeoutSeconds));
});
```
**Retry flow**:
```mermaid
graph TD
Call["SlaveApiClient HTTP call"]
Attempt["Execute HTTP request\n(with per-attempt timeout)"]
Success{"Response\nsuccessful?"}
ReturnOk["Return result"]
MaxReached{"Max attempts\n(3) reached?"}
Backoff["Wait exponential delay\n1s or 2s plus jitter"]
ReturnFail["Return false\nor throw on final attempt"]
Call --> Attempt --> Success
Success -->|"yes"| ReturnOk
Success -->|"no"| MaxReached
MaxReached -->|"yes"| ReturnFail
MaxReached -->|"no"| Backoff --> Attempt
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 fail fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Success,MaxReached decision
class Call,Attempt,Backoff action
class ReturnOk terminal
class ReturnFail fail
```
Text alternative: HTTP call enters pipeline; per-attempt timeout applies; on failure checks if max attempts reached; if not waits exponential delay and retries; after 3 total failures returns false.
---
## Pattern 2 — Security: `IApiKeyProtector` Wrapper
**NFR**: ApiKey encrypted at rest; decrypted only for HTTP calls; never exposed in responses
**Pattern**: Thin wrapper interface over ASP.NET Core Data Protection. Keeps `CmsInstanceService` independent of Data Protection internals and makes unit tests trivially simple (mock returns plain strings).
**Interface**:
```csharp
public interface IApiKeyProtector
{
string Protect(string plainApiKey);
string Unprotect(string encryptedApiKey);
}
```
**Implementation**:
```csharp
public class ApiKeyProtector : IApiKeyProtector
{
private readonly IDataProtector _protector;
public ApiKeyProtector(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("SlpModularCms.Master.ApiKey");
}
public string Protect(string plainApiKey) => _protector.Protect(plainApiKey);
public string Unprotect(string encrypted) => _protector.Unprotect(encrypted);
}
```
**Registration** (in `MasterModule.RegisterServices`):
```csharp
services.AddDataProtection();
services.AddSingleton<IApiKeyProtector, ApiKeyProtector>();
```
**Usage in tests**:
```csharp
var protector = new Mock<IApiKeyProtector>();
protector.Setup(p => p.Protect(It.IsAny<string>())).Returns((string s) => $"enc:{s}");
protector.Setup(p => p.Unprotect(It.IsAny<string>())).Returns((string s) => s.Replace("enc:", ""));
```
---
## Pattern 3 — Constructor Aggregation: `MasterServiceDependencies`
**Rationale**: `CmsInstanceService` requires 6 dependencies. Wrapping them in a record removes constructor noise and groups related parameters semantically.
**Record definition**:
```csharp
public record MasterServiceDependencies(
ICmsInstanceRepository Repository,
ISlaveApiClient SlaveClient,
IApiKeyProtector ApiKeyProtector,
IOptions<MasterModuleOptions> Options,
IHttpContextAccessor HttpContextAccessor,
ILogger<CmsInstanceService> Logger
);
```
**Registration** (framework resolves all fields automatically):
```csharp
services.AddScoped<MasterServiceDependencies>();
services.AddScoped<ICmsInstanceService, CmsInstanceService>();
```
**`CmsInstanceService` constructor**:
```csharp
public CmsInstanceService(MasterServiceDependencies deps)
{
_deps = deps;
}
```
**Test construction** (explicit, no DI container needed):
```csharp
var deps = new MasterServiceDependencies(
mockRepo.Object,
mockSlaveClient.Object,
mockProtector.Object,
Options.Create(new MasterModuleOptions()),
mockHttpContextAccessor.Object,
NullLogger<CmsInstanceService>.Instance
);
var svc = new CmsInstanceService(deps);
```
---
## Pattern 4 — Background Service Scope Isolation
**NFR**: `ICmsInstanceService` is Scoped; `IntegrityCheckBackgroundService` is Singleton
**Pattern**: Create and dispose a dedicated `IServiceScope` per tick. No singleton scope leakage.
```csharp
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(_options.Value.IntegrityCheckIntervalMinutes));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await using var scope = _scopeFactory.CreateAsyncScope();
try
{
var svc = scope.ServiceProvider
.GetRequiredService<ICmsInstanceService>();
await svc.VerifyIntegrityAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during integrity check tick");
}
}
}
```
---
## Pattern 5 — Structured Logging
**Pattern**: Log with structured fields; never log the raw `ApiKey` value.
| Scenario | Level | Fields |
|----------|-------|--------|
| Status push failed | `Error` | `{InstanceId}`, `{SlaveUrl}`, `{Exception}` |
| Integrity check: slave unreachable | `Warning` | `{InstanceId}`, `{SlaveUrl}` |
| Integrity check: URL mismatch | `Warning` | `{InstanceId}`, `{SlaveUrl}`, `{ExpectedUrl}`, `{RegisteredUrl}` |
| Integrity check: re-registration ok | `Information` | `{InstanceId}`, `{SlaveUrl}` |
| Integrity check: re-registration failed | `Warning` | `{InstanceId}`, `{SlaveUrl}` |
| ApiKey decryption failure | `Error` | `{InstanceId}` — do NOT log key material |
| Tick started | `Debug` | `{ActiveInstanceCount}` |
**Example**:
```csharp
_logger.LogWarning(
"Slave {InstanceId} at {SlaveUrl} is unreachable during integrity check",
instance.Id, instance.Url);
```
@@ -0,0 +1,77 @@
# NFR Requirements — Unit 1: master-backend
## Performance
| Requirement | Specification | Source |
|-------------|--------------|--------|
| HTTP timeout for slave calls | Configurable via `MasterModuleOptions.HttpTimeoutSeconds`; default **10 seconds** | Q1 |
| HTTP retry budget | Maximum 3 attempts (initial + 2 retries) with exponential backoff (1s, 2s); per-attempt timeout applies | Q2 |
| Background service interval | Configurable via `MasterModuleOptions.IntegrityCheckIntervalMinutes`; default 60 minutes | NFR-MASTER-04 |
| Controller endpoint latency | No explicit SLA; bounded by HTTP timeout × retry attempts (worst case ~33s for a single unresponsive slave during Add/UpdateStatus) | derived |
---
## Security
| Requirement | Specification | Source |
|-------------|--------------|--------|
| ApiKey at-rest encryption | Encrypted with ASP.NET Core Data Protection before writing to DB; decrypted immediately before HTTP calls | Q3 (+ functional design) |
| Data Protection key storage | **Default file system** (platform default); machine-bound; acceptable for single-instance deployment | Q3 |
| ApiKey exposure | Never included in `CmsInstanceDto` or any API response; `[JsonIgnore]` or explicit DTO mapping | NFR-MASTER-03 |
| Endpoint authorization | All `CmsInstanceController` actions require `[Authorize(Policy = "OwnerOnly")]` | FR-MASTER-10 |
| Internal slave endpoint auth | `POST /api/internal/master/register` validated via `X-Master-Api-Key` header (Unit 2 concern) | FR-MASTER-03 |
---
## Reliability
| Requirement | Specification | Source |
|-------------|--------------|--------|
| Fail-open on slave unreachability | Status push failure returns `SlaveContactSuccess = false` but does not roll back DB change; integrity check sets `LastIntegrityCheckFailedAt` and continues | NFR-MASTER-01 |
| Retry policy | Exponential backoff: attempt 1 (immediate), attempt 2 (+1s delay), attempt 3 (+2s delay); implemented via Polly `ResiliencePipeline` | Q2 |
| Background service isolation | Exceptions per slave instance are caught, logged, and do not abort the full integrity check batch | BR-BG-03 |
| Background service scope | `IServiceScopeFactory` used per tick to resolve scoped `ICmsInstanceService`; scope disposed after each tick | BR-BG-01/02 |
---
## Testability
| Requirement | Specification | Source |
|-------------|--------------|--------|
| Minimum test coverage | ≥ 80% line/branch coverage for `SlpModularCms.Modules.Master` (excluding items below) | NFR-MASTER-05 |
| Coverage exclusions | Apply `[ExcludeFromCodeCoverage]` to: `MasterModule.cs` (IModule boilerplate), EF Core migration files, plain DTO/record classes with no logic | Q4 |
| Test project | `SlpModularCms.Modules.Master.Tests` — separate project; mirrors production project structure | Unit decomposition decision |
| Key test targets | `CmsInstanceService`, `SlaveApiClient`, `IntegrityCheckBackgroundService`, `CmsInstanceController` | NFR-MASTER-05 |
| Interface-driven design | `ICmsInstanceRepository`, `ICmsInstanceService`, `ISlaveApiClient` interfaces required to enable unit test mocking | derived |
---
## Maintainability
| Requirement | Specification | Source |
|-------------|--------------|--------|
| Log level — integrity check failures | **Warning** — slave unreachability during background checks is expected; does not require immediate attention | Q5 |
| Log level — status push failures | **Error** — owner-triggered action failed to reach slave; requires visibility | Q5 |
| Log level — re-registration on mismatch | **Information** — expected recovery action | derived |
| Log level — background service tick | **Debug** — high frequency; only visible when debugging | derived |
| Structured logging | Use `ILogger<T>` with structured message templates; include `slaveUrl` and `instanceId` in log scope | derived |
---
## Updated `MasterModuleOptions` Fields
The following field is added as a result of Q1:
| Property | Type | Default | Notes |
|----------|------|---------|-------|
| `HttpTimeoutSeconds` | `int` | `10` | Timeout applied to each individual HTTP attempt in `SlaveApiClient` |
Full updated options shape:
| Property | Type | Default | Side |
|----------|------|---------|------|
| `IntegrityCheckIntervalMinutes` | `int` | `60` | Master |
| `HttpTimeoutSeconds` | `int` | `10` | Master |
| `MasterUrl` | `string?` | `null` | Master |
| `CacheMinutes` | `int` | `60` | Slave |
| `ApiKey` | `string?` | `null` | Slave |
@@ -0,0 +1,112 @@
# Tech Stack Decisions — Unit 1: master-backend
## HTTP Client & Resilience
### Decision: Typed HTTP Client via `IHttpClientFactory` + Polly
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| HTTP client abstraction | `ISlaveApiClient` / `SlaveApiClient` typed client | Testable via mock injection; clean contract boundary |
| Client registration | `services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()` | Framework manages `HttpClient` lifetime and connection pooling |
| Retry policy | **Polly** `ResiliencePipelineBuilder` with `AddRetry` | Industry standard .NET resilience library; integrates natively with `IHttpClientFactory` via `AddResilienceHandler` |
| Retry configuration | 3 total attempts; delays: 1s → 2s (exponential); jitter optional | Bounded worst-case latency; exponential reduces thundering herd on widespread slave outages |
| Per-attempt timeout | `MasterModuleOptions.HttpTimeoutSeconds` (default 10s) | Configurable per-environment; keeps controller responses bounded |
**NuGet package required**: `Microsoft.Extensions.Http.Resilience` (includes Polly integration)
**Registration pattern**:
```csharp
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-retry", builder =>
{
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
});
builder.AddTimeout(TimeSpan.FromSeconds(options.HttpTimeoutSeconds));
});
```
---
## Data Protection
### Decision: Default ASP.NET Core Data Protection (file system)
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Key storage | Default file system (no explicit `PersistKeysTo*` call) | Zero configuration; acceptable for single-instance; owner manages production key persistence |
| Purpose string | `"SlpModularCms.Master.ApiKey"` | Scoped protection; prevents cross-purpose decryption |
| Registration | `services.AddDataProtection()` (already called by framework if not explicitly called) | No extra setup needed beyond injecting `IDataProtectionProvider` |
**Production note** (to be included in Unit 4 README update): For containerized or multi-instance deployments, configure a persistent key ring (e.g., `PersistKeysToDbContext`, `PersistKeysToAzureBlobStorage`). Without it, restarting the container causes all encrypted `ApiKey` values to become unreadable.
---
## Background Service
### Decision: .NET `BackgroundService` + `PeriodicTimer`
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Base class | `BackgroundService` | Built-in .NET hosted service; lifecycle managed by `IHostApplicationLifetime` |
| Timer mechanism | `PeriodicTimer` | Allocates less than `Timer`; await-friendly; cancellation-aware |
| Scope management | `IServiceScopeFactory.CreateScope()` per tick | Required because `ICmsInstanceService` is Scoped; prevents captive dependency |
| Exception handling | `try/catch` around entire tick body; log `Error` and continue | Prevents background service crash on unexpected errors |
---
## Logging
### Decision: `ILogger<T>` structured logging
| Scenario | Log Level | Structured Fields |
|----------|-----------|------------------|
| Status push failed (slave unreachable) | `Error` | `instanceId`, `slaveUrl`, `exception` |
| Integrity check: slave unreachable | `Warning` | `instanceId`, `slaveUrl` |
| Integrity check: URL mismatch detected | `Warning` | `instanceId`, `slaveUrl`, `expectedUrl`, `registeredUrl` |
| Integrity check: re-registration succeeded | `Information` | `instanceId`, `slaveUrl` |
| Integrity check: re-registration failed | `Warning` | `instanceId`, `slaveUrl` |
| Background service tick started | `Debug` | `instanceCount` |
| ApiKey decryption failed | `Error` | `instanceId` (do NOT log the key itself) |
---
## EF Core / Database
### Decision: Per-module `MasterDbContext` with own migrations
| Aspect | Decision |
|--------|----------|
| DbContext class | `MasterDbContext : DbContext` in `SlpModularCms.Modules.Master` |
| Migration assembly | `SlpModularCms.Modules.Master` (same project) |
| Migration application | `app.ApplicationServices.CreateScope()``MasterDbContext.Database.MigrateAsync()` in `MasterModule.UseModule(IApplicationBuilder)` |
| Tables owned | `CmsInstances`, `DataProtectionKeys` (if needed in future) |
| Connection string | Reuses the same connection string as `ApplicationDbContext` (from `ConnectionStrings:DefaultConnection`) |
---
## Test Framework
### Decision: xUnit + Moq (matching existing test projects)
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Test framework | xUnit | Matches existing `Availability.Tests` project |
| Mocking | Moq | Matches existing test projects |
| Coverage tool | coverlet (via `.runsettings` or `dotnet test --collect`) | Already in use in existing test projects |
| `[ExcludeFromCodeCoverage]` targets | `MasterModule`, EF Core migration files, DTO records | Q4 decision |
| HTTP testing | Mock `ISlaveApiClient` via Moq | Typed client interface enables clean mocking without `HttpMessageHandler` fakes |
---
## Summary of New Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `Microsoft.Extensions.Http.Resilience` | Latest stable | Polly integration for `IHttpClientFactory` retry policies |
All other dependencies (EF Core, ASP.NET Core, xUnit, Moq) are already present in the solution.
@@ -0,0 +1,60 @@
# Functional Design Questions — Unit 3: frontend-cms-page
Please answer each question by filling in the letter after the `[Answer]:` tag.
If none of the options match, choose the last option (Other) and describe your preference.
---
## Question 1
Which columns should the `CmsInstanceList` table display?
A) Name, URL, Status
B) Name, URL, Status, Last Contact (`lastContactedAt`)
C) Name, URL, Status, DisableMessage
D) Other (please describe after [Answer]: tag below)
[Answer]: B + C
---
## Question 2
"Inactive rows greyed out" — what exactly?
A) The entire `<TableRow>` gets `opacity-50` (everything fades)
B) All `<TableCell>` text gets `text-muted-foreground` class (subtler dimming)
C) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Question 3
When the status is updated, the backend pushes to the slave and reports whether it was reachable (`UpdateStatusResult`). What does the frontend show?
A) Always a generic success toast on HTTP 200, regardless of slave details
B) Toast differs: "Status updated — slave confirmed" vs "Status saved, slave unreachable"
C) Other (please describe after [Answer]: tag below)
[Answer]: B, but don't call it slave. I'd rather see the term "cliënt"
---
## Question 4
The backend returns HTTP 400 for invalid data in `AddCmsInstanceDialog`. What does the UI show?
A) Banner error at the top of the dialog (`FormBannerError` pattern, as in LoginPage)
B) Toast on failed submit
C) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Question 5
When there are no CMS instances yet (empty state):
A) Centered placeholder with icon + "Add your first CMS" message and an Add button
B) Empty table (the Add button in the page header is sufficient)
C) Other (please describe after [Answer]: tag below)
[Answer]: A
@@ -0,0 +1,52 @@
# Functional Design Plan — Unit 3: frontend-cms-page
## Scope
**Modify**: `frontend/src/` (existing React SPA, extended)
**Questions file**: `frontend-cms-page-fd-questions.md`
---
## Steps
### Part A — TypeScript Types
- [ ] **Step 1**`src/api/types.ts` — add `CmsInstance` interface and `CmsInstanceStatus` type
### Part B — TanStack Query Hooks
- [ ] **Step 2**`src/api/useCmsInstances.ts``GET /api/v1/CmsInstances`
- [ ] **Step 3**`src/api/useAddCmsInstance.ts``POST /api/v1/CmsInstances`
- [ ] **Step 4**`src/api/useUpdateCmsInstanceStatus.ts``PUT /api/v1/CmsInstances/{id}/status`
### Part C — Components
- [ ] **Step 5**`src/components/cms/CmsInstanceList.tsx` — table with status badges; Inactive rows styled per Q2
- [ ] **Step 6**`src/components/cms/AddCmsInstanceDialog.tsx` — modal form: Name, URL, ApiKey; error handling per Q4
- [ ] **Step 7**`src/components/cms/SetStatusDialog.tsx` — status dropdown + conditional DisableMessage (mandatory when NotAvailable)
### Part D — Page
- [ ] **Step 8**`src/pages/CmsPage.tsx` — replace placeholder; header + Add button; empty state per Q5; columns per Q1; UpdateStatusResult per Q3
### Part E — i18n
- [ ] **Step 9**`src/i18n/locales/nl/translation.json` — add `cms.*` keys
- [ ] **Step 10**`src/i18n/locales/en/translation.json` — add `cms.*` keys
### Part F — MSW Mocks
- [ ] **Step 11**`src/mocks/cms/handlers.ts` — GET, POST, PUT handlers with in-memory state
- [ ] **Step 12**`src/mocks/browser.ts` + `src/mocks/server.ts` — register cms handlers
### Part G — Tests
- [ ] **Step 13**`src/api/useCmsInstances.test.ts` — GET hook
- [ ] **Step 14**`src/api/useAddCmsInstance.test.ts` — POST mutation + cache invalidation
- [ ] **Step 15**`src/api/useUpdateCmsInstanceStatus.test.ts` — PUT mutation + cache invalidation
- [ ] **Step 16**`src/pages/CmsPage.test.tsx` — renders list, Add button, empty state, Owner-only guard
---
*Answers to design questions: see `frontend-cms-page-fd-questions.md`*
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-functional-design-plan.md`
@@ -0,0 +1,51 @@
# NFR Requirements Questions — Unit 3: frontend-cms-page
Please answer each question by filling in the letter after the `[Answer]:` tag.
If none of the options match, choose the last option (Other) and describe your preference.
---
## Question 1
How strict should the URL validation be for the `url` field in `AddCmsInstanceDialog`?
Context: `z.string().url()` (Zod strict) rejects bare IPs without protocol (`192.168.1.5:8080`) and rejects `localhost:5000`. CMS slave instances may live on a local network or development machine.
A) **Strict**`z.string().url()`. Enforces a valid HTTP/HTTPS URL. User must enter `http://192.168.1.5:8080`. Backend already validates anyway; this prevents obvious typos.
B) **Lenient**`z.string().min(1)`. Any non-empty string is accepted. Backend is the authoritative validator; client only ensures the field is not empty.
C) **Custom** — Must start with `http://` or `https://`, but the rest is not validated (`z.string().regex(/^https?:\/\//)`). Prevents protocol-less entries without being as strict as full URL parsing.
D) Other (please describe after [Answer]: tag below)
[Answer]:
---
## Question 2
What test scope applies to the new components and hooks?
A) **Page integration only**`CmsPage.test.tsx` tests the main flows end-to-end (load list, open Add dialog, set status). Individual components are not tested separately. Keeps the test suite lean.
B) **Hooks + page integration** — Dedicated tests for `useCmsInstances`, `useAddCmsInstance`, `useUpdateCmsInstanceStatus` using `renderHook` + MSW. Page integration test covers the UI flows. Consistent with existing `useUsers` pattern.
C) **Hooks + page + component tests** — In addition to B, dedicated tests for `AddCmsInstanceDialog` and `SetStatusDialog` (error state, DisableMessage conditional, etc.). Matches the `InviteUserDialog.test.tsx` precedent in this project.
D) Other (please describe after [Answer]: tag below)
[Answer]:
---
## Question 3
How should the `ApiKey` input field behave in `AddCmsInstanceDialog`?
The user copies the API key from the slave CMS admin panel and pastes it here. It is an infrastructure credential, not a user password.
A) **`type="password"`** — Hidden by default. Safe against shoulder surfing. The existing `PasswordField` component with show/hide toggle can be reused.
B) **`type="text"`** — Visible. Easier to verify the pasted value is correct. Acceptable since this is a one-time setup action performed by an Owner in a secure context.
C) Other (please describe after [Answer]: tag below)
[Answer]:
@@ -0,0 +1,22 @@
# NFR Requirements Plan — Unit 3: frontend-cms-page
## Unit Context
**Unit**: `frontend-cms-page`
**Inputs**: domain-entities.md, business-logic-model.md, business-rules.md, frontend-components.md
**Key NFR concerns**: form validation strategy, test scope, URL validation
---
## Execution Steps
- [ ] **Step 1** — Analyze answers; flag ambiguities
- [ ] **Step 2** — Generate `nfr-requirements.md`
- [ ] **Step 3** — Generate `tech-stack-decisions.md`
- [ ] **Step 4** — Update `aidlc-state.md`
- [ ] **Step 5** — Present completion message for approval
---
*Questions file*: `frontend-cms-page-nfr-questions.md`
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-requirements-plan.md`
@@ -0,0 +1,207 @@
# Code Generation Plan — Unit 1: master-backend
## Unit Context
**Unit**: `master-backend`
**Workspace root**: `K:\Development\Projects\SlpModularCms`
**Project type**: Brownfield — new project added to existing solution
**New projects**:
- `src/SlpModularCms.Modules.Master/` (application code)
- `src/SlpModularCms.Modules.Master.Tests/` (test code)
**Modified files**:
- `src/SlpModularCms.Api/SlpModularCms.Api.csproj` — add project reference to Master module
- `SlpModularCms.sln` — add both new projects
**Test stack** (matching existing projects): xUnit, NSubstitute, FluentAssertions, EF Core InMemory
**New NuGet dependency**: `Microsoft.Extensions.Http.Resilience` (Polly integration)
**Requirements covered**: FR-MASTER-01/02/03/04/05/10/12/13/14; NFR-MASTER-03/04/05/06
---
## Generation Steps
### STEP 1 — Project files & solution wiring
- [x] 1a. Create `src/SlpModularCms.Modules.Master/SlpModularCms.Modules.Master.csproj`
- Target: `net10.0`; Nullable + ImplicitUsings enabled
- ProjectReference: `SlpModularCms.Core`
- PackageReference: `Microsoft.Extensions.Http.Resilience` (latest stable)
- [x] 1b. Create `src/SlpModularCms.Modules.Master.Tests/SlpModularCms.Modules.Master.Tests.csproj`
- Target: `net10.0`; `IsPackable = false`
- PackageReference: `xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`, `coverlet.collector`, `NSubstitute`, `FluentAssertions`, `Microsoft.EntityFrameworkCore.InMemory`, `Microsoft.Extensions.Logging.Abstractions`
- ProjectReference: `SlpModularCms.Modules.Master`
- Global using: `Xunit`
- [x] 1c. Modify `src/SlpModularCms.Api/SlpModularCms.Api.csproj` — add `<ProjectReference Include="..\SlpModularCms.Modules.Master\SlpModularCms.Modules.Master.csproj" />`
- [x] 1d. Modify `SlpModularCms.sln` — add both new projects with correct GUIDs and folder placement
### STEP 2 — Configuration options
- [x] 2a. Create `src/SlpModularCms.Modules.Master/Options/MasterModuleOptions.cs`
- Properties: `IntegrityCheckIntervalMinutes` (int, 60), `HttpTimeoutSeconds` (int, 10), `MasterUrl` (string?), `CacheMinutes` (int, 60), `ApiKey` (string?)
### STEP 3 — Domain entities
- [x] 3a. Create `src/SlpModularCms.Modules.Master/Data/Entities/CmsInstanceStatus.cs`
- Enum: `Available = 0`, `NotAvailable = 1`, `Inactive = 2`
- [x] 3b. Create `src/SlpModularCms.Modules.Master/Data/Entities/CmsInstance.cs`
- All fields per domain-entities.md (including `LastIntegrityCheckFailedAt`)
- `[ExcludeFromCodeCoverage]` NOT applied — entity has no logic; covered by service tests
### STEP 4 — DTOs and request/response models
- [x] 4a. Create `src/SlpModularCms.Modules.Master/Models/CmsInstanceDto.cs``[ExcludeFromCodeCoverage]`
- [x] 4b. Create `src/SlpModularCms.Modules.Master/Models/CreateCmsInstanceRequest.cs``[ExcludeFromCodeCoverage]`
- [x] 4c. Create `src/SlpModularCms.Modules.Master/Models/UpdateStatusRequest.cs``[ExcludeFromCodeCoverage]`
- [x] 4d. Create `src/SlpModularCms.Modules.Master/Models/UpdateStatusResult.cs``[ExcludeFromCodeCoverage]`
### STEP 5 — EF Core DbContext
- [x] 5a. Create `src/SlpModularCms.Modules.Master/Data/MasterDbContext.cs`
- `DbSet<CmsInstance> CmsInstances`
- `OnModelCreating`: configure entity (table name, required fields, max lengths)
- Constructor: accepts `DbContextOptions<MasterDbContext>`
### STEP 6 — Repository
- [x] 6a. Create `src/SlpModularCms.Modules.Master/Repositories/ICmsInstanceRepository.cs`
- `GetAllAsync`, `GetActiveAsync`, `GetByIdAsync`, `AddAsync`, `UpdateAsync`, `SaveChangesAsync`
- [x] 6b. Create `src/SlpModularCms.Modules.Master/Repositories/CmsInstanceRepository.cs`
- Inject `MasterDbContext`; implement all methods
- `GetActiveAsync` filters `Status != CmsInstanceStatus.Inactive`
### STEP 7 — Security: ApiKeyProtector
- [x] 7a. Create `src/SlpModularCms.Modules.Master/Services/IApiKeyProtector.cs`
- `string Protect(string plainApiKey)`
- `string Unprotect(string encryptedApiKey)`
- [x] 7b. Create `src/SlpModularCms.Modules.Master/Services/ApiKeyProtector.cs`
- Inject `IDataProtectionProvider`; purpose string `"SlpModularCms.Master.ApiKey"`
- `[ExcludeFromCodeCoverage]` NOT applied — testable via unit test with real `EphemeralDataProtectionProvider`
### STEP 8 — HTTP client: SlaveApiClient
- [x] 8a. Create `src/SlpModularCms.Modules.Master/Services/ISlaveApiClient.cs`
- `RegisterMasterAsync`, `PushStatusAsync`, `GetRegisteredMasterUrlAsync`
- [x] 8b. Create `src/SlpModularCms.Modules.Master/Services/SlaveApiClient.cs`
- Inject `HttpClient` (typed client)
- Each method: build request with `X-Master-Api-Key` header; handle non-success responses; return `false` / `null` on failure
- JSON serialization: `System.Text.Json` (consistent with project)
### STEP 9 — Service dependencies record
- [x] 9a. Create `src/SlpModularCms.Modules.Master/Services/MasterServiceDependencies.cs`
- Record with 6 properties: `ICmsInstanceRepository`, `ISlaveApiClient`, `IApiKeyProtector`, `IOptions<MasterModuleOptions>`, `IHttpContextAccessor`, `ILogger<CmsInstanceService>`
- `[ExcludeFromCodeCoverage]`
### STEP 10 — CmsInstanceService
- [x] 10a. Create `src/SlpModularCms.Modules.Master/Services/ICmsInstanceService.cs`
- `GetAllAsync`, `AddAsync`, `UpdateStatusAsync`, `VerifyIntegrityAsync`
- [x] 10b. Create `src/SlpModularCms.Modules.Master/Services/CmsInstanceService.cs`
- Inject `MasterServiceDependencies`
- `GetAllAsync`: return all as `CmsInstanceDto` (no ApiKey)
- `AddAsync`: validate → encrypt → persist → determine masterUrl (HttpContext → config fallback) → register → update `LastContactedAt` if success
- `UpdateStatusAsync`: validate → load → validate DisableMessage → persist → for non-Inactive: decrypt → push → update `LastStatusPushedAt` if success → return `UpdateStatusResult`
- `VerifyIntegrityAsync`: get active → per slave: decrypt → get registered URL → compare → re-register if mismatch → update `LastIntegrityCheckFailedAt` / `LastContactedAt`
- Logging per business-rules.md (Error/Warning/Information)
### STEP 11 — Background service
- [x] 11a. Create `src/SlpModularCms.Modules.Master/BackgroundServices/IntegrityCheckBackgroundService.cs`
- Inject `IServiceScopeFactory`, `IOptions<MasterModuleOptions>`, `ILogger<IntegrityCheckBackgroundService>`
- `PeriodicTimer` with `IntegrityCheckIntervalMinutes`
- `CreateAsyncScope()` per tick; resolve `ICmsInstanceService`; call `VerifyIntegrityAsync()`
- Outer `try/catch` logs `Error` and continues
- `[ExcludeFromCodeCoverage]` NOT applied — test with mocked `IServiceScopeFactory`
### STEP 12 — Controller
- [x] 12a. Create `src/SlpModularCms.Modules.Master/Controllers/CmsInstanceController.cs`
- `[ApiController]`, `[Route("[controller]")]`, `[Authorize(Policy = "OwnerOnly")]`
- `GetAll()`: GET → `ICmsInstanceService.GetAllAsync()` → 200 OK
- `Add([FromBody] CreateCmsInstanceRequest)`: POST → `ICmsInstanceService.AddAsync()` → 201 Created
- `UpdateStatus(Guid id, [FromBody] UpdateStatusRequest)`: PUT `/{id}/status``ICmsInstanceService.UpdateStatusAsync()` → 200 OK with `UpdateStatusResult`
- Error handling: catch `KeyNotFoundException` → 404; catch `ArgumentException` (validation) → 400
### STEP 13 — Module registration
- [x] 13a. Create `src/SlpModularCms.Modules.Master/MasterModule.cs``[ExcludeFromCodeCoverage]`
- `Name = "Master"`, `Version = "1.0.0"`
- `RegisterServices`: AddDataProtection, AddSingleton IApiKeyProtector, Configure MasterModuleOptions, AddDbContext MasterDbContext, AddScoped repo + deps + service, AddHttpClient ISlaveApiClient + AddResilienceHandler, AddHostedService IntegrityCheckBackgroundService, AddHttpContextAccessor
- `UseModule`: migrate `MasterDbContext` at startup
### STEP 14 — EF Core migration
- [x] 14a. Create `src/SlpModularCms.Modules.Master/Migrations/` directory placeholder
- Document CLI command to generate initial migration:
```
dotnet ef migrations add InitialCreate --project src/SlpModularCms.Modules.Master --startup-project src/SlpModularCms.Api
```
- Note: Migration is created via CLI after Step 13 compile-succeeds; not hand-generated
### STEP 15 — Unit tests: Repository
- [x] 15a. Create `src/SlpModularCms.Modules.Master.Tests/Repositories/CmsInstanceRepositoryTests.cs`
- Use EF Core InMemory for `MasterDbContext`
- Test: `GetAllAsync`, `GetActiveAsync` (excludes Inactive), `GetByIdAsync` (found/not found), `AddAsync` + `SaveChangesAsync`, `UpdateAsync`
### STEP 16 — Unit tests: ApiKeyProtector
- [x] 16a. Create `src/SlpModularCms.Modules.Master.Tests/Services/ApiKeyProtectorTests.cs`
- Use `EphemeralDataProtectionProvider` (real provider, no mocks)
- Test: Protect returns non-plaintext; Unprotect(Protect(x)) == x; Unprotect with wrong key throws
### STEP 17 — Unit tests: SlaveApiClient
- [x] 17a. Create `src/SlpModularCms.Modules.Master.Tests/Services/SlaveApiClientTests.cs`
- Use `NSubstitute` `HttpMessageHandler` substitute or `MockHttpMessageHandler`
- Test: `RegisterMasterAsync` returns true on 200, false on 4xx/5xx/exception
- Test: `PushStatusAsync` returns true on 200, false on failure
- Test: `GetRegisteredMasterUrlAsync` returns URL from response body, null on failure
- Test: `X-Master-Api-Key` header is set on each request
### STEP 18 — Unit tests: CmsInstanceService
- [x] 18a. Create `src/SlpModularCms.Modules.Master.Tests/Services/CmsInstanceServiceTests.cs`
- Substitute all 6 dependencies via NSubstitute
- `AddAsync` tests: success path, registration failure (record still persisted), URL from HttpContext, URL from config fallback
- `UpdateStatusAsync` tests: not found → throws, missing DisableMessage → throws, Inactive → no push, Available → persists + pushes, push fails → SlaveContactSuccess=false
- `VerifyIntegrityAsync` tests: skip Inactive, URL match → clear flag, mismatch → re-register, unreachable → set LastIntegrityCheckFailedAt
- `GetAllAsync`: ApiKey not in DTO
### STEP 19 — Unit tests: IntegrityCheckBackgroundService
- [x] 19a. Create `src/SlpModularCms.Modules.Master.Tests/BackgroundServices/IntegrityCheckBackgroundServiceTests.cs`
- Test: service calls `VerifyIntegrityAsync` on tick
- Test: exceptions in `VerifyIntegrityAsync` are caught and logged (service does not crash)
- Use NSubstitute `IServiceScopeFactory` + `IServiceScope`
### STEP 20 — Unit tests: Controller
- [x] 20a. Create `src/SlpModularCms.Modules.Master.Tests/Controllers/CmsInstanceControllerTests.cs`
- Substitute `ICmsInstanceService` via NSubstitute
- `GetAll`: 200 with list
- `Add`: 201 Created with dto; 400 on validation exception; 400 on argument exception
- `UpdateStatus`: 200 with result; 404 on KeyNotFoundException; 400 on ArgumentException
### STEP 21 — Code documentation summary
- [x] 21a. Create `aidlc-docs/features/master-cms-module/construction/master-backend/code/code-summary.md`
- List all created/modified files with paths
- Note the EF Core migration CLI command
- Note NuGet package added
---
## File Summary
| File | Action | ExcludeFromCoverage |
|------|--------|-------------------|
| `SlpModularCms.Modules.Master.csproj` | Create | N/A |
| `SlpModularCms.Modules.Master.Tests.csproj` | Create | N/A |
| `SlpModularCms.Api.csproj` | Modify | N/A |
| `SlpModularCms.sln` | Modify | N/A |
| `Options/MasterModuleOptions.cs` | Create | No |
| `Data/Entities/CmsInstanceStatus.cs` | Create | No |
| `Data/Entities/CmsInstance.cs` | Create | No |
| `Models/CmsInstanceDto.cs` | Create | Yes |
| `Models/CreateCmsInstanceRequest.cs` | Create | Yes |
| `Models/UpdateStatusRequest.cs` | Create | Yes |
| `Models/UpdateStatusResult.cs` | Create | Yes |
| `Data/MasterDbContext.cs` | Create | No |
| `Repositories/ICmsInstanceRepository.cs` | Create | No |
| `Repositories/CmsInstanceRepository.cs` | Create | No |
| `Services/IApiKeyProtector.cs` | Create | No |
| `Services/ApiKeyProtector.cs` | Create | No |
| `Services/ISlaveApiClient.cs` | Create | No |
| `Services/SlaveApiClient.cs` | Create | No |
| `Services/MasterServiceDependencies.cs` | Create | Yes |
| `Services/ICmsInstanceService.cs` | Create | No |
| `Services/CmsInstanceService.cs` | Create | No |
| `BackgroundServices/IntegrityCheckBackgroundService.cs` | Create | No |
| `Controllers/CmsInstanceController.cs` | Create | No |
| `MasterModule.cs` | Create | Yes |
| Tests (6 files) | Create | N/A |
**Total**: 25 application files (24 new, 2 modified) + 6 test files + 1 code summary
@@ -0,0 +1,122 @@
# Functional Design Plan — Unit 1: master-backend
## Unit Context
**Unit**: `master-backend`
**Project**: `SlpModularCms.Modules.Master` (new)
**Test Project**: `SlpModularCms.Modules.Master.Tests` (new)
**Requirements**: FR-MASTER-01 t/m FR-MASTER-05, FR-MASTER-10, FR-MASTER-12/13/14; NFR-MASTER-03/04/05/06
---
## Functional Design Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — Auto-Registration Failure Behavior
When the Master calls `RegisterMasterAsync` on a new slave during `AddAsync`, and the slave is unreachable or returns an error, what should happen?
A) **Persist anyway, LastContactedAt = null** — save the `CmsInstance` record regardless; `LastContactedAt` stays null to signal no successful contact; the owner can see the slave is uncontacted and resolve manually.
B) **Rollback — do not persist** — if registration fails, the entire `AddAsync` operation fails; return an error to the owner; no partial record is created.
C) **Persist with Status = Inactive** — save the record but set status to `Inactive` automatically; the owner must manually reactivate once the slave is reachable.
D) Other
[Answer]: A
---
### Q2 — Status Push When Setting to Inactive
When the owner sets a slave's status to `Inactive`, should the Master attempt to push this status change to the slave via HTTP?
A) **No push for Inactive**`Inactive` means the Master stops contacting the slave entirely; no status push is sent; the slave keeps its last received state until the owner reactivates it.
B) **Push Inactive status** — send a final status update to the slave before going silent, so the slave is aware it has been deactivated.
C) Other
[Answer]: A
---
### Q3 — Integrity Check: Unreachable Slave
During `VerifyIntegrityAsync`, if a slave is unreachable (HTTP timeout or error), what should happen?
A) **Log and skip** — log the failure at Warning level, skip this slave, continue with the remaining slaves in the batch; retry on the next scheduled cycle.
B) **Mark as needs-check** — update a `LastIntegrityCheckFailedAt` timestamp on the entity; retry more aggressively on next cycle for flagged slaves.
C) Other
[Answer]: B
---
### Q4 — Master URL Discovery
When the Master calls `RegisterMasterAsync`, it needs to send its own public base URL to the slave. How should the Master know its own URL?
A) **Configured in `appsettings.json`** — owner sets `MasterModule:MasterUrl` (e.g., `https://master.myapp.com`). Explicit, reliable in all environments; simple to implement; follows existing `appsettings` pattern.
B) **Derived from `HttpContext`** — inject `IHttpContextAccessor` and derive the base URL from the current request in the controller; pass it down to the service. No config needed; but only works when called from an HTTP request (not from background service integrity checks).
C) **Two-value approach** — use `IHttpContextAccessor` when available (in controller context), fall back to `MasterModule:MasterUrl` config (for background service context).
D) Other
[Answer]: C
---
### Q5 — ApiKey Storage
The `ApiKey` in `CmsInstance` is the secret the Master sends to the slave in `X-Master-Api-Key`. How should it be stored in the `MasterDbContext`?
A) **Plaintext** — stored as-is in the database. The master must read the actual value to send in HTTP calls, so it must be stored in recoverable form. Access to the database is already protected by the deployment environment.
B) **Encrypted via ASP.NET Core Data Protection** — encrypt at write, decrypt at read using `IDataProtector`. More secure at rest; adds complexity; requires Data Protection key management.
C) Other
[Answer]: B
---
### Q6 — CmsInstanceController: Return 503 or 200 on Failed Slave Push
When `UpdateStatusAsync` successfully persists the new status but the subsequent HTTP push to the slave fails, what should the controller return to the frontend?
A) **200 OK with a warning field** — the status change is persisted (source of truth is the master DB); return 200 with an additional `slaveContactSuccess: false` field so the UI can display a warning.
B) **200 OK, no warning** — the master DB is the authority; whether the slave received the push is an implementation detail; the background integrity check ensures eventual consistency.
C) **207 Multi-Status** — partial success response indicating the DB write succeeded but the slave push failed.
D) Other
[Answer]: A
---
## Execution Steps
After all questions above are answered, the following artifacts will be generated:
- [x] **Step 1** — Analyze all answers; flag any ambiguities
- [x] **Step 2** — Generate `domain-entities.md``CmsInstance` entity, `MasterModuleOptions`, enums, DTO shapes
- [x] **Step 3** — Generate `business-logic-model.md` — process flows: AddAsync, UpdateStatusAsync, VerifyIntegrityAsync
- [x] **Step 4** — Generate `business-rules.md` — validation rules, constraints, decision logic
- [x] **Step 5** — Validate all Mermaid diagrams (no hyphens in IDs, classDef colors, text alternatives)
- [x] **Step 6** — Update `aidlc-state.md`
- [x] **Step 7** — Present completion message for user approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/master-backend-functional-design-plan.md`
@@ -0,0 +1,72 @@
# NFR Design Plan — Unit 1: master-backend
## Unit Context
**Unit**: `master-backend`
**Input**: nfr-requirements.md, tech-stack-decisions.md, functional-design artifacts
**Patterns to design**: Polly resilience pipeline, Data Protection wiring, background service isolation
---
## NFR Design Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — Data Protection: Injection Pattern
`CmsInstanceService` needs to encrypt/decrypt `ApiKey` via `IDataProtector`. How should it be injected?
A) **Direct `IDataProtectionProvider` injection** — inject `IDataProtectionProvider` in `CmsInstanceService`; call `.CreateProtector("SlpModularCms.Master.ApiKey")` in the constructor. Simple; no extra type; easy to test by mocking `IDataProtectionProvider`.
B) **Thin `IApiKeyProtector` wrapper** — define a small `IApiKeyProtector` interface with `Protect(string)` and `Unprotect(string)` methods; inject the interface into `CmsInstanceService`. Purpose is explicit; makes mocking in tests trivially simple (no need to mock ASP.NET Core Data Protection internals).
C) Other
[Answer]: B
---
### Q2 — Polly Pipeline: Registration Scope
The Polly resilience pipeline (exponential backoff + timeout) applies to all `SlaveApiClient` calls. How should it be registered?
A) **Single pipeline on `AddResilienceHandler`** — configure one pipeline on the `IHttpClientBuilder` for `SlaveApiClient`; applies automatically to all HTTP calls made by this client. Zero per-call setup; consistent behavior across all slave endpoints.
B) **Inline `ResiliencePipeline` per method** — build and execute a `ResiliencePipeline` explicitly inside each `SlaveApiClient` method. More control per call (e.g., different timeout for integrity check vs. status push); more boilerplate.
C) Other
[Answer]: A
---
### Q3 — `CmsInstanceService` Constructor Complexity
`CmsInstanceService` will inject: `ICmsInstanceRepository`, `ISlaveApiClient`, `IDataProtectionProvider` (or `IApiKeyProtector`), `IOptions<MasterModuleOptions>`, `IHttpContextAccessor`, `ILogger<CmsInstanceService>` — 6 dependencies. Is this acceptable?
A) **Accept 6 dependencies** — follows existing project pattern (`PersistentAvailabilityService` has similar dependencies); no need to introduce aggregation objects.
B) **Introduce `MasterServiceDependencies` context record** — wrap the 6 dependencies in a single record type to simplify the constructor signature. Cleaner constructor; slightly more indirection.
C) Other
[Answer]: B
---
## Execution Steps
After all questions above are answered, the following artifacts will be generated:
- [x] **Step 1** — Analyze all answers; flag any ambiguities
- [x] **Step 2** — Generate `nfr-design-patterns.md` — Polly pipeline design, Data Protection pattern, logging pattern, test isolation pattern
- [x] **Step 3** — Generate `logical-components.md` — infrastructure wiring diagram, component interaction for NFR patterns
- [x] **Step 4** — Validate all Mermaid diagrams
- [x] **Step 5** — Update `aidlc-state.md`
- [x] **Step 6** — Present completion message for user approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/master-backend-nfr-design-plan.md`
@@ -0,0 +1,110 @@
# NFR Requirements Plan — Unit 1: master-backend
## Unit Context
**Unit**: `master-backend`
**Applicable NFRs**: NFR-MASTER-01 (fail-open), NFR-MASTER-03 (API key security), NFR-MASTER-04 (background service interval), NFR-MASTER-05 (≥80% test coverage), NFR-MASTER-06 (per-module migrations)
---
## NFR Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — HTTP Timeout for Slave API Calls
The `SlaveApiClient` calls slave endpoints for registration, status push, and integrity checks. What HTTP timeout should be configured?
A) **5 seconds** — short timeout; registration and status push are synchronous user-triggered actions; a hanging slave should not block the owner long.
B) **15 seconds** — moderate timeout; balances responsiveness with tolerance for temporarily slow slaves.
C) **30 seconds** — generous timeout; maximizes chance of successful contact before giving up.
D) **Configurable via `MasterModuleOptions.HttpTimeoutSeconds` (default 10)** — owner can tune per-environment; sensible default.
E) Other
[Answer]: D
---
### Q2 — HTTP Retry Policy for Slave API Calls
Should `SlaveApiClient` retry failed HTTP calls automatically, or fail immediately and rely on the next integrity check cycle for recovery?
A) **No retry — fail immediately** — if a slave is unreachable, return `false` at once; the background integrity check handles eventual re-registration; keeps user-visible latency predictable.
B) **1 retry with short delay (e.g., 2s)** — single retry on transient failures (network blip); still bounded latency; reduces false negatives on flaky connections.
C) **Exponential backoff (3 attempts)** — standard resilience pattern; handles transient errors well; may add up to ~7s latency in worst case.
D) Other
[Answer]: C
---
### Q3 — ASP.NET Core Data Protection Key Storage
The `ApiKey` values are encrypted with `IDataProtector`. Where should Data Protection keys be stored?
A) **Default file system** (`%APPDATA%\Microsoft\UserSecrets` / platform default) — zero configuration; keys are machine-bound; acceptable for single-instance deployments.
B) **SQL Server via EF Core** (`services.AddDataProtection().PersistKeysToDbContext<MasterDbContext>()`) — keys survive container restarts and work across deployable instances; requires a `DataProtectionKeys` table in `MasterDbContext`.
C) **Default for now, documented as a production concern** — use the default in code; add a note in documentation that production deployments should configure persistent key storage.
D) Other
[Answer]: A
---
### Q4 — Test Coverage: Exclusions
NFR-MASTER-05 requires ≥80% coverage on new backend code. Which classes should be excluded from coverage measurement for `SlpModularCms.Modules.Master`?
A) **Module registration + migrations only** — exclude `MasterModule.cs` (module registration boilerplate) and EF Core migration files; cover everything else including controllers, services, and repository.
B) **Module registration, migrations, and DTOs/records** — additionally exclude plain record/DTO classes (no logic to test); cover all classes with business logic.
C) **No exclusions** — aim for ≥80% including all files; let natural coverage determine what's tested.
D) Other
[Answer]: B
---
### Q5 — Logging for Slave Contact Failures
When a slave is unreachable during a status push or integrity check, at what log level should the failure be recorded?
A) **Warning** — slave unreachability is expected during network issues; `Warning` signals the issue without triggering on-call alerts; appropriate for a fail-open system.
B) **Error** — slave contact failures represent a degraded state; `Error` ensures visibility in monitoring dashboards and may trigger alerts.
C) **Warning for integrity checks, Error for status push failures** — integrity checks are background maintenance; status push failures have direct owner impact.
D) Other
[Answer]: C
---
## Execution Steps
After all questions above are answered, the following artifacts will be generated:
- [x] **Step 1** — Analyze all answers; flag any ambiguities
- [x] **Step 2** — Generate `nfr-requirements.md` — performance, security, reliability, testability, maintainability requirements
- [x] **Step 3** — Generate `tech-stack-decisions.md` — HTTP client config, Data Protection, retry policy, logging
- [x] **Step 4** — Update `aidlc-state.md`
- [x] **Step 5** — Present completion message for user approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/master-backend-nfr-requirements-plan.md`
@@ -0,0 +1,62 @@
# Code Generation Plan — Unit 2: slave-availability-extension
## Scope
**Modify**: `src/SlpModularCms.Modules.Availability/` (existing project)
**Create**: `src/SlpModularCms.Modules.Availability.Master.Tests/` (new test project)
**Update**: `SlpModularCms.sln` (add new test project)
**Update**: existing `AvailabilityMiddlewareTests.cs` in `Availability.Tests` (new 3-param signature)
---
## Steps
### Part A — Production Code (Availability module)
- [x] **Step 1**`SlpModularCms.Modules.Availability.csproj` — add `InternalsVisibleTo` for new test project
- [x] **Step 2**`Data/Entities/MasterRegistration.cs` — entity with singleton Id constant
- [x] **Step 3**`Data/AvailabilityDbContext.cs` — DbContext with MasterRegistrations DbSet + OnModelCreating
- [x] **Step 4**`Repositories/IMasterRegistrationRepository.cs``GetAsync`, `AddAsync`, `Update`, `SaveChangesAsync`
- [x] **Step 5**`Repositories/MasterRegistrationRepository.cs` — EF Core implementation
- [x] **Step 6**`Services/IMasterApiKeyProtector.cs``Protect` + `Unprotect` (null on crypto failure)
- [x] **Step 7**`Services/MasterApiKeyProtector.cs` — wraps `IDataProtectionProvider`; purpose `"SlpModularCms.Availability.MasterApiKey"`
- [x] **Step 8**`Services/MasterGateStatus.cs``record MasterGateStatus(bool IsAvailable, string? DisableMessage)`
- [x] **Step 9**`Services/MasterAvailabilityServiceDependencies.cs` — record with repo, protector, logger
- [x] **Step 10**`Services/IMasterAvailabilityService.cs``RegisterAsync(bool)`, `PushStatusAsync(bool)`, `GetRegisteredUrlAsync(string?)`, `GetMasterStatus()`
- [x] **Step 11**`Services/MasterAvailabilityService.cs` — volatile static fields + implementation of all 4 methods
- [x] **Step 12**`Models/RegisterMasterRequest.cs` + `Models/PushStatusRequest.cs``[ExcludeFromCodeCoverage]` records
- [x] **Step 13**`Controllers/MasterController.cs` — 3 endpoints; header extraction; delegate to service; Unauthorized() on false/null
- [x] **Step 14**`Middleware/AvailabilityMiddleware.cs` — extend: add `/api/v1/master/` bypass; move admin check before local gate; add master gate between admin check and local gate
- [x] **Step 15**`AvailabilityModule.cs` — add DbContext, protector, repo, deps, service registrations; add `MigrateAsync` in UseModule
### Part B — Test Project (new)
- [x] **Step 16**`SlpModularCms.Modules.Availability.Master.Tests.csproj` — new project; copy packages from Master.Tests; add `Microsoft.IdentityModel.Tokens` for JWT tests
- [x] **Step 17**`Repositories/MasterRegistrationRepositoryTests.cs` — EF InMemory; GetAsync (null + found); AddAsync; Update; SaveChanges
- [x] **Step 18**`Services/MasterApiKeyProtectorTests.cs``EphemeralDataProtectionProvider`; round-trip; invalid ciphertext returns null
- [x] **Step 19**`Services/MasterAvailabilityServiceTests.cs` — NSubstitute; all 4 public methods; key mismatch; first registration; re-registration; static cache state
- [x] **Step 20**`Controllers/MasterControllerTests.cs` — NSubstitute; missing header → 401; success → 200; service returns false/null → 401
- [x] **Step 21**`Middleware/AvailabilityMiddlewareMasterGateTests.cs` — NSubstitute; master gate blocks → 503; master gate passes → local gate evaluated; `/api/v1/master/` bypasses; master gate + admin JWT → pass through
### Part C — Cross-cutting
- [x] **Step 22**`SlpModularCms.sln` — add `Availability.Master.Tests` project with new GUID
- [x] **Step 23**`src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs` — update all `InvokeAsync(context, _service)` calls to `InvokeAsync(context, _service, masterSvc)` (masterSvc stub returning `IsAvailable=true`)
---
## Key Design Notes
| Concern | Decision |
|---------|----------|
| Singleton Id | `public static readonly Guid SingletonId = new("00000000-0000-0000-0000-000000000001")` |
| IMasterAvailabilityService.RegisterAsync | Returns `bool` (true=ok, false=key mismatch) |
| IMasterAvailabilityService.PushStatusAsync | Returns `bool` (true=ok, false=key mismatch) |
| IMasterAvailabilityService.GetRegisteredUrlAsync | Returns `string?` (null=unauthorized) |
| Unprotect null guard | `storedKey is null \|\| storedKey != incoming` → treat as mismatch |
| Middleware InvokeAsync order | bypass paths → admin JWT → master gate → local gate |
| Existing AvailabilityMiddlewareTests | Add stub `IMasterAvailabilityService` returning `IsAvailable=true` as 3rd param |
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/slave-availability-extension-code-generation-plan.md`
@@ -0,0 +1,119 @@
# Functional Design Plan — Unit 2: slave-availability-extension
## Unit Context
**Unit**: `slave-availability-extension`
**Project**: `src/SlpModularCms.Modules.Availability/` (EXTENDED — existing project)
**Test Project**: `src/SlpModularCms.Modules.Availability.Master.Tests/` (NEW)
**Construction Cycle**: Functional Design → NFR Requirements → NFR Design → Code Generation
**What this unit does**: Extends the slave CMS so it can:
1. Accept registrations from a master CMS (store master URL)
2. Receive status pushes from master (cache available/unavailable)
3. Return the registered master URL (for integrity checks from master)
4. Block requests via a two-phase middleware gate (master gate outer + local gate inner)
**Endpoint contract (fixed by Unit 1 SlaveApiClient)**:
- `POST /api/v1/master/register` — receive master registration
- `POST /api/v1/master/status` — receive status push from master
- `GET /api/v1/master/registered-url` — return currently registered master URL
---
## Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — API Key Validation: How does the slave authenticate incoming master requests?
The master sends an `X-Master-Api-Key` header on every call. The slave must validate this. Where does the slave get the expected key to compare against?
A) **Configuration-based** — slave admin sets the expected key in `appsettings.json` under `Availability:MasterApiKey`. The key is shared out-of-band when setting up the master/slave relationship. Simple and explicit.
B) **Stored at first registration** — slave accepts the first registration request unconditionally and stores the API key from the header. Subsequent calls (status push, registered-url) validate against this stored key. No pre-configuration needed.
C) **No validation** — slave trusts all requests to master endpoints (relies on network security). Simpler but less secure.
D) Other
[Answer]: B
---
### Q2 — No Master Registered: What happens when a slave has never been registered with a master?
If no registration exists in the `MasterRegistrations` table, what should the master gate do?
A) **Fail-open** — no registration means master gate passes (slave is Available from master's perspective). This is safe: a fresh slave not yet connected to a master is fully accessible. Consistent with the fail-open philosophy from the requirements.
B) **Fail-closed** — no registration means master gate blocks (slave is unavailable until a master registers it). More secure but breaks fresh deployments.
C) Other
[Answer]: A
---
### Q3 — Master Gate: Which paths bypass the master gate?
The master gate blocks incoming requests when master says slave is unavailable. Which paths should always bypass it?
A) **Same as local gate + master endpoints** — bypass the same paths already in `_bypassPrefixes` (Auth, Setup, Availability/status) AND add `/api/v1/master/` so master can always push status or re-register even when gate is closed.
B) **All internal API paths** — bypass everything under `/api/v1/master/` and `/api/v1/Availability/` (broader bypass for any "system" paths).
C) **Master endpoints only** — only `/api/v1/master/` bypasses the master gate; keep Auth/Setup/Availability bypass only in the local gate where it already lives.
D) Other
[Answer]: A
---
### Q4 — Master Status Cache: When does the cached master status expire?
When the master pushes a status to the slave, the slave stores it in a static field. How long is it valid?
A) **No expiry** — cache never expires; only updated when master pushes again. If master goes offline permanently, last known status is used forever. Simplest implementation; consistent with fail-open (default = Available).
B) **Configurable expiry** — add `MasterCacheMinutes` to `AvailabilityOptions`; after expiry, status reverts to Available (fail-open). Allows fresh slaves to auto-recover if master disappears.
C) **Timestamp-based expiry (same as existing circuit breaker)** — static field with `_lastMasterUpdateTime`; if older than `CacheMinutes`, revert to Available.
D) Other
[Answer]: A
---
### Q5 — MasterRegistration Entity: What data does it store?
The `MasterRegistrations` table on the slave stores data about the registered master. What fields are needed?
A) **Minimal**`Id` (Guid PK), `MasterUrl` (string). Just the URL needed for integrity check response. The API key (Q1) is stored in config, not DB.
B) **Extended**`Id` (Guid PK), `MasterUrl` (string), `RegisteredAt` (DateTimeOffset), `LastContactedAt` (DateTimeOffset?). More diagnostic info; useful for monitoring.
C) Other
[Answer]: B
---
## Execution Steps
After all questions above are answered, the following artifacts will be generated:
- [x] **Step 1** — Analyze answers; flag ambiguities
- [x] **Step 2** — Generate `domain-entities.md``MasterRegistration` entity + relationship to `AvailabilityDbContext`
- [x] **Step 3** — Generate `business-logic-model.md` — sequence diagrams for: register, status push, get-registered-url, middleware gate evaluation
- [x] **Step 4** — Generate `business-rules.md` — validation rules, gate bypass logic, cache behavior, API key validation
- [x] **Step 5** — Validate all Mermaid diagrams
- [ ] **Step 6** — Update `aidlc-state.md`
- [ ] **Step 7** — Present completion message for approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/slave-availability-extension-functional-design-plan.md`
@@ -0,0 +1,72 @@
# NFR Design Plan — Unit 2: slave-availability-extension
## Unit Context
**Unit**: `slave-availability-extension`
**Inputs**: nfr-requirements.md, tech-stack-decisions.md (Unit 2)
**Already decided**: `IMasterApiKeyProtector` wrapper (mirrors Unit 1 `IApiKeyProtector`); `volatile` static cache fields; structured logging table; `AvailabilityDbContext` + `IMasterRegistrationRepository`
**Key design decisions remaining**: middleware extension pattern, `IMasterAvailabilityService` interface shape, service constructor
---
## Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — Middleware: How does `AvailabilityMiddleware` read the master gate status?
The master gate needs to read `_masterIsAvailable` (volatile bool) on every request. Two approaches:
A) **InvokeAsync injection** — add `IMasterAvailabilityService` as a third parameter to `InvokeAsync`. The service exposes a synchronous `GetMasterStatus()` method that returns the volatile fields. Pattern:
```csharp
public async Task InvokeAsync(HttpContext context,
IAvailabilityService localSvc,
IMasterAvailabilityService masterSvc)
{
if (!masterSvc.GetMasterStatus().IsAvailable) { /* 503 */ }
...
}
```
Fully testable via NSubstitute substitute on `IMasterAvailabilityService`. Consistent with how `IAvailabilityService` is already injected.
B) **Direct static read**`AvailabilityMiddleware` reads `MasterAvailabilityService._masterIsAvailable` directly via `internal static volatile` fields (with `InternalsVisibleTo` for the test project). No extra injection; no interface method for status read; slightly faster on hot path.
C) Other
[Answer]: A
---
### Q2 — `MasterAvailabilityService` constructor: Direct injection or deps record?
`MasterAvailabilityService` needs 3 dependencies: `IMasterRegistrationRepository`, `IMasterApiKeyProtector`, `ILogger<MasterAvailabilityService>`.
A) **Direct injection** — pass all 3 as constructor parameters. Simple; idiomatic for a small number of deps. No wrapper record needed.
```csharp
public MasterAvailabilityService(
IMasterRegistrationRepository repository,
IMasterApiKeyProtector keyProtector,
ILogger<MasterAvailabilityService> logger)
```
B) **Dependencies record** — wrap in `MasterAvailabilityServiceDependencies` record (consistent with Unit 1's `MasterServiceDependencies`). Useful if deps may grow or for visual consistency.
C) Other
[Answer]: B
---
## Execution Steps
- [x] **Step 1** — Analyze answers; flag ambiguities
- [x] **Step 2** — Generate `nfr-design-patterns.md`
- [x] **Step 3** — Generate `logical-components.md`
- [ ] **Step 4** — Update `aidlc-state.md`
- [ ] **Step 5** — Present completion message for approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/slave-availability-extension-nfr-design-plan.md`
@@ -0,0 +1,88 @@
# NFR Requirements Plan — Unit 2: slave-availability-extension
## Unit Context
**Unit**: `slave-availability-extension`
**Inputs**: domain-entities.md, business-logic-model.md, business-rules.md (Unit 2 FD)
**Key NFR concerns**: ApiKey storage security, static cache thread safety, test coverage, logging
---
## Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — ApiKey Storage: How should the slave store the master's ApiKey?
The `MasterRegistration.ApiKey` is used to validate subsequent master calls (BR-SLAVE-02/04/06). How should it be stored in the DB?
A) **Plain text** — store as-is from the `X-Master-Api-Key` header. Simple; compare directly on each request. Acceptable given the key is an infrastructure credential (not user password) and the DB should be secured.
B) **SHA-256 hash** — store `SHA256(apiKey)` and compare `SHA256(incoming)` on each request. No plain-text at rest; constant-time comparison prevents timing attacks.
C) **ASP.NET Core Data Protection** — encrypt using `IDataProtectionProvider` (same pattern as `ApiKeyProtector` in Unit 1). Reversible; consistent with master-side pattern.
D) Other
[Answer]: C
---
### Q2 — Static Cache Thread Safety: How should `_masterIsAvailable` and `_masterDisableMessage` be protected?
These static fields are read on every request (high frequency) and written only when the master pushes status (rare). What thread safety approach is appropriate?
A) **`volatile` fields** — `private static volatile bool _masterIsAvailable = true` and `private static volatile string? _masterDisableMessage`. Sufficient for atomic single-field reads/writes in .NET; no lock overhead per request. Consistent with `PersistentAvailabilityService`'s existing circuit breaker pattern.
B) **`lock` statement** — lock a `static readonly object _lock` around both reads and writes. Explicit and safe; slight overhead on every request read.
C) Other
[Answer]: A
---
### Q3 — Test Coverage: What should be excluded from coverage in this unit?
Which components in Unit 2 should receive `[ExcludeFromCodeCoverage]`?
A) **Module/wiring only** — only the `AvailabilityModule` changes (service registrations, migration call) and EF migration files. All service, controller, and middleware logic is covered.
B) **Module + DTOs**`AvailabilityModule` changes + DTO/request/response records + EF migration files. Consistent with Unit 1 pattern.
C) Other
[Answer]: B
---
### Q4 — Logging: What log levels apply to the new slave-side logic?
A) **Minimal** — only Error for unexpected exceptions. Keep logs quiet since master calls are frequent background operations.
B) **Structured per scenario** — follow the same table approach as Unit 1:
- `Warning` — API key mismatch on any endpoint
- `Warning` — master gate blocked a request (log path + disable message)
- `Information` — master registered successfully (first registration)
- `Information` — status update received (isAvailable value)
- `Debug` — get-registered-url called
C) Other
[Answer]: B
---
## Execution Steps
- [x] **Step 1** — Analyze answers; flag ambiguities
- [x] **Step 2** — Generate `nfr-requirements.md`
- [x] **Step 3** — Generate `tech-stack-decisions.md`
- [ ] **Step 4** — Update `aidlc-state.md`
- [ ] **Step 5** — Present completion message for approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/slave-availability-extension-nfr-requirements-plan.md`
@@ -0,0 +1,152 @@
# Business Logic Model — Unit 2: slave-availability-extension
## Flow 1: Register Master (POST /api/v1/master/register)
```mermaid
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)
```mermaid
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)
```mermaid
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)
```mermaid
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.
@@ -0,0 +1,103 @@
# 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 |
**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 | Cache has no expiry (Q4=A); only updated on `POST /status` with valid API key |
| 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) |
---
## 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.
@@ -0,0 +1,72 @@
# Domain Entities — Unit 2: slave-availability-extension
## Entity Relationship Diagram
```mermaid
graph TD
subgraph AvailabilityModule["Availability Module (slave side)"]
DbCtx["AvailabilityDbContext"]
MR["MasterRegistration\n(singleton row)"]
Cache["MasterStatusCache\n(static fields)"]
MAS["IMasterAvailabilityService\n/MasterAvailabilityService"]
end
DbCtx -->|owns| MR
MAS -->|reads/writes| DbCtx
MAS -->|updates| Cache
MW["AvailabilityMiddleware\n(extended)"] -->|reads synchronously| Cache
MC["MasterController\n(new)"] -->|delegates to| MAS
classDef entity fill:#FFC107,stroke:#F57F17,color:#000
classDef service fill:#9ae6b4,stroke:#2f855a,color:#000
classDef infra fill:#63b3ed,stroke:#2b6cb0,color:#000
classDef cache fill:#CE93D8,stroke:#6A1B9A,color:#000
class MR entity
class MAS service
class DbCtx,MC infra
class Cache,MW cache
```
Text alternative: `AvailabilityDbContext` owns the singleton `MasterRegistration` row. `MasterAvailabilityService` reads/writes the DbContext and updates the in-process `MasterStatusCache` (static fields). `AvailabilityMiddleware` reads the cache synchronously. `MasterController` delegates all logic to `MasterAvailabilityService`.
---
## Entity: MasterRegistration
Singleton row — at most one record exists per slave instance. Upserted on each successful registration.
| Field | Type | Constraints | Notes |
|-------|------|-------------|-------|
| `Id` | `Guid` | PK | Fixed value (e.g. `Guid.Empty`) enforces singleton |
| `MasterUrl` | `string` | Required, max 500 | URL of the master CMS that registered this slave |
| `ApiKey` | `string` | Required, max 1000 | Plain-text API key sent in first registration; used for subsequent validation |
| `RegisteredAt` | `DateTimeOffset` | Required | Timestamp of first registration |
| `LastContactedAt` | `DateTimeOffset?` | Optional | Updated on every successful master call (register, status push, get-url) |
> **Singleton enforcement**: The `Id` is a fixed known value (`Guid.Parse("00000000-0000-0000-0000-000000000001")`). On first `POST /api/v1/master/register` the row is created; on re-registration the same row is updated in-place. This avoids a composite unique constraint and makes EF upsert trivial.
---
## In-Process Cache: MasterStatusCache (static fields)
Not a DB entity — lives in memory on the slave process.
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `_masterIsAvailable` | `bool` | `true` | Set by status push; `true` = pass gate |
| `_masterDisableMessage` | `string?` | `null` | Message forwarded from master to 503 response |
**No expiry** (Q4=A): cache is valid indefinitely until the master pushes again. If the master goes offline permanently, the last known status is used. Default = `true` (Available) = fail-open.
---
## DbContext: AvailabilityDbContext
New per-module DbContext in `SlpModularCms.Modules.Availability`. Separate from `ApplicationDbContext` (Core).
| DbSet | Entity | Table Name |
|-------|--------|-----------|
| `MasterRegistrations` | `MasterRegistration` | `AvailabilityMasterRegistrations` |
Migration assembly: `SlpModularCms.Modules.Availability`. Applied at startup in `AvailabilityModule.UseModule`.
@@ -0,0 +1,302 @@
# Logical Components — Unit 2: slave-availability-extension
## Component Overview
```mermaid
graph TD
subgraph Controllers
MC["MasterController\n(new)"]
end
subgraph Services
IMAS["IMasterAvailabilityService"]
MAS["MasterAvailabilityService\n(volatile static cache)"]
IMAKP["IMasterApiKeyProtector"]
MAKP["MasterApiKeyProtector"]
MASD["MasterAvailabilityServiceDependencies\n(record)"]
end
subgraph Repositories
IMRR["IMasterRegistrationRepository"]
MRR["MasterRegistrationRepository"]
end
subgraph Data
AVDBCTX["AvailabilityDbContext\n(new)"]
MR["MasterRegistration\n(entity)"]
end
subgraph Middleware
AVMW["AvailabilityMiddleware\n(extended)"]
end
subgraph External
DP["IDataProtectionProvider\n(ASP.NET Core)"]
end
MC -->|delegates| IMAS
MAS -.->|implements| IMAS
MAS -->|uses| MASD
MASD -->|contains| IMRR
MASD -->|contains| IMAKP
MRR -.->|implements| IMRR
MAKP -.->|implements| IMAKP
MRR -->|reads/writes| AVDBCTX
AVDBCTX -->|owns| MR
MAKP -->|wraps| DP
AVMW -->|InvokeAsync param| IMAS
classDef interface fill:#fff,stroke:#63b3ed,stroke-width:2px,color:#2b6cb0
classDef impl fill:#63b3ed,stroke:#2b6cb0,color:#fff
classDef entity fill:#FFC107,stroke:#F57F17,color:#000
classDef infra fill:#9ae6b4,stroke:#2f855a,color:#000
classDef middleware fill:#CE93D8,stroke:#6A1B9A,color:#000
classDef external fill:#eee,stroke:#999,color:#333
class IMAS,IMRR,IMAKP interface
class MAS,MRR,MAKP,MASD impl
class MR,AVDBCTX entity
class MC infra
class AVMW middleware
class DP external
```
Text alternative: `MasterController` delegates to `IMasterAvailabilityService`. `MasterAvailabilityService` uses `MasterAvailabilityServiceDependencies` which holds `IMasterRegistrationRepository` and `IMasterApiKeyProtector`. Repository uses `AvailabilityDbContext`. `MasterApiKeyProtector` wraps `IDataProtectionProvider`. `AvailabilityMiddleware` receives `IMasterAvailabilityService` as third `InvokeAsync` parameter.
---
## Component Specifications
### 1. `MasterRegistration` (Entity)
**Namespace**: `SlpModularCms.Modules.Availability.Data.Entities`
| Property | Type | Notes |
|----------|------|-------|
| `Id` | `Guid` | PK; always `new Guid("00000000-0000-0000-0000-000000000001")` |
| `MasterUrl` | `string` | Required; max 500 |
| `ApiKey` | `string` | Required; max 2000 (encrypted via Data Protection) |
| `RegisteredAt` | `DateTimeOffset` | Set once on creation |
| `LastContactedAt` | `DateTimeOffset?` | Updated on every valid master call |
---
### 2. `AvailabilityDbContext`
**Namespace**: `SlpModularCms.Modules.Availability.Data`
```csharp
public class AvailabilityDbContext : DbContext
{
public DbSet<MasterRegistration> MasterRegistrations => Set<MasterRegistration>();
}
```
| Aspect | Decision |
|--------|----------|
| Migration assembly | `SlpModularCms.Modules.Availability` |
| Table | `AvailabilityMasterRegistrations` |
| Applied at | `AvailabilityModule.UseModule``MigrateAsync()` |
| Connection string | `ConnectionStrings:DefaultConnection` (same as other DbContexts) |
---
### 3. `IMasterRegistrationRepository` / `MasterRegistrationRepository`
**Namespace**: `SlpModularCms.Modules.Availability.Repositories`
```csharp
public interface IMasterRegistrationRepository
{
Task<MasterRegistration?> GetAsync();
Task UpsertAsync(MasterRegistration registration);
Task SaveChangesAsync();
}
```
| Method | Notes |
|--------|-------|
| `GetAsync()` | Loads singleton by fixed Id; returns `null` if row does not exist |
| `UpsertAsync(registration)` | `Add` if not tracked; `Update` if tracked or found by Id |
| `SaveChangesAsync()` | Explicit save; service controls transaction boundary |
**Registration**: `services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>()`
---
### 4. `IMasterApiKeyProtector` / `MasterApiKeyProtector`
**Namespace**: `SlpModularCms.Modules.Availability.Services`
```csharp
public interface IMasterApiKeyProtector
{
string Protect(string plainApiKey);
string? Unprotect(string encryptedApiKey); // null on CryptographicException
}
```
**Registration**: `services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>()`
---
### 5. `MasterAvailabilityServiceDependencies` (Record)
**Namespace**: `SlpModularCms.Modules.Availability.Services`
```csharp
[ExcludeFromCodeCoverage]
public record MasterAvailabilityServiceDependencies(
IMasterRegistrationRepository Repository,
IMasterApiKeyProtector KeyProtector,
ILogger<MasterAvailabilityService> Logger
);
```
**Registration**: `services.AddScoped<MasterAvailabilityServiceDependencies>()`
---
### 6. `IMasterAvailabilityService` / `MasterAvailabilityService`
**Namespace**: `SlpModularCms.Modules.Availability.Services`
```csharp
public interface IMasterAvailabilityService
{
Task RegisterAsync(string masterUrl, string apiKey);
Task PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
Task<string?> GetRegisteredUrlAsync(string apiKey);
MasterGateStatus GetMasterStatus();
}
```
**Static fields** (in implementation):
```csharp
private static volatile bool _masterIsAvailable = true;
private static volatile string? _masterDisableMessage = null;
```
**Key validation helper** (private, reused across all 3 write-path methods):
```csharp
private async Task<MasterRegistration?> ValidateApiKeyAsync(string apiKey)
{
var registration = await _deps.Repository.GetAsync();
if (registration is null) return null;
var stored = _deps.KeyProtector.Unprotect(registration.ApiKey);
return stored == apiKey ? registration : null;
}
```
**Registration**: `services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>()`
---
### 7. `MasterGateStatus` (Record)
**Namespace**: `SlpModularCms.Modules.Availability.Services`
```csharp
[ExcludeFromCodeCoverage]
public record MasterGateStatus(bool IsAvailable, string? DisableMessage);
```
---
### 8. `MasterController`
**Namespace**: `SlpModularCms.Modules.Availability.Controllers`
```csharp
[ApiController]
[Route("[controller]")] // → /api/v1/master via ApiPrefixConvention
public class MasterController : ControllerBase
{
[HttpPost("register")] // POST /api/v1/master/register
[HttpPost("status")] // POST /api/v1/master/status
[HttpGet("registered-url")] // GET /api/v1/master/registered-url
}
```
**Constructor**: `MasterController(IMasterAvailabilityService svc)` — single dependency, no record wrapper needed.
**Auth**: No `[Authorize]` attribute — API key validated in `MasterAvailabilityService`.
**Response on 401**: `Unauthorized()` — no body to avoid leaking registration state.
---
### 9. `AvailabilityMiddleware` (Extended)
**Extended fields** (added to existing class):
```csharp
// Updated bypass prefix list
private static readonly string[] _bypassPrefixes =
[
"/api/v1/Availability/status",
"/api/v1/Auth/",
"/api/v1/Setup/status",
"/api/v1/master/" // NEW
];
```
**Updated `InvokeAsync` signature**:
```csharp
public async Task InvokeAsync(
HttpContext context,
IAvailabilityService localSvc,
IMasterAvailabilityService masterSvc)
```
---
## Dependency Registration Summary
All new registrations added to `AvailabilityModule.RegisterServices`:
```csharp
// Data
services.AddDbContext<AvailabilityDbContext>((sp, options) =>
options.UseSqlServer(sp.GetRequiredService<IConfiguration>()
.GetConnectionString("DefaultConnection")));
// Security
services.AddDataProtection();
services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>();
// Repositories
services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>();
// Services
services.AddScoped<MasterAvailabilityServiceDependencies>();
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
```
And in `AvailabilityModule.UseModule`:
```csharp
using var scope = app.ApplicationServices.CreateScope();
await scope.ServiceProvider
.GetRequiredService<AvailabilityDbContext>()
.Database.MigrateAsync();
```
---
## New Files Summary
| File | Project | Type |
|------|---------|------|
| `Data/Entities/MasterRegistration.cs` | Availability | Entity |
| `Data/AvailabilityDbContext.cs` | Availability | DbContext |
| `Repositories/IMasterRegistrationRepository.cs` | Availability | Interface |
| `Repositories/MasterRegistrationRepository.cs` | Availability | Implementation |
| `Services/IMasterApiKeyProtector.cs` | Availability | Interface |
| `Services/MasterApiKeyProtector.cs` | Availability | Implementation |
| `Services/MasterGateStatus.cs` | Availability | Record |
| `Services/MasterAvailabilityServiceDependencies.cs` | Availability | Record |
| `Services/IMasterAvailabilityService.cs` | Availability | Interface |
| `Services/MasterAvailabilityService.cs` | Availability | Implementation |
| `Controllers/MasterController.cs` | Availability | Controller |
| `Middleware/AvailabilityMiddleware.cs` | Availability | Modified (extended) |
| `AvailabilityModule.cs` | Availability | Modified (registration + migration) |
| `Data/Migrations/*` | Availability | EF Core auto-generated |
@@ -0,0 +1,232 @@
# NFR Design Patterns — Unit 2: slave-availability-extension
## Pattern 1 — Security: `IMasterApiKeyProtector` Wrapper
**NFR**: ApiKey encrypted at rest (Q1=C NFR Requirements); decrypted only for comparison; never logged
**Pattern**: Thin wrapper interface over ASP.NET Core Data Protection. Mirrors Unit 1's `IApiKeyProtector` pattern but with a distinct purpose string to prevent cross-module decryption.
**Interface**:
```csharp
public interface IMasterApiKeyProtector
{
string Protect(string plainApiKey);
string? Unprotect(string encryptedApiKey); // null on CryptographicException
}
```
**Implementation**:
```csharp
public class MasterApiKeyProtector : IMasterApiKeyProtector
{
private readonly IDataProtector _protector;
public MasterApiKeyProtector(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("SlpModularCms.Availability.MasterApiKey");
}
public string Protect(string plainApiKey) => _protector.Protect(plainApiKey);
public string? Unprotect(string encryptedApiKey)
{
try { return _protector.Unprotect(encryptedApiKey); }
catch (CryptographicException) { return null; }
}
}
```
**Registration** (in `AvailabilityModule.RegisterServices`):
```csharp
services.AddDataProtection();
services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>();
```
**Usage in tests**:
```csharp
var protector = Substitute.For<IMasterApiKeyProtector>();
protector.Protect(Arg.Any<string>()).Returns(s => $"enc:{s.ArgAt<string>(0)}");
protector.Unprotect(Arg.Any<string>()).Returns(s => s.ArgAt<string>(0).Replace("enc:", ""));
```
---
## Pattern 2 — Performance: `volatile` Static Cache + Sync `GetMasterStatus()`
**NFR**: Master gate check is synchronous; zero DB call per request (PERF-01/02)
**Pattern**: Volatile static fields in `MasterAvailabilityService` updated only on status push. Exposed via a synchronous interface method so `AvailabilityMiddleware` can mock it in tests without accessing static state directly.
**Static fields**:
```csharp
private static volatile bool _masterIsAvailable = true;
private static volatile string? _masterDisableMessage = null;
```
**Return type**:
```csharp
public record MasterGateStatus(bool IsAvailable, string? DisableMessage);
```
**Interface method** (sync — no `Task`):
```csharp
public interface IMasterAvailabilityService
{
Task RegisterAsync(string masterUrl, string apiKey);
Task PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
Task<string?> GetRegisteredUrlAsync(string apiKey);
MasterGateStatus GetMasterStatus(); // sync; reads volatile fields
}
```
**Implementation**:
```csharp
public MasterGateStatus GetMasterStatus()
=> new(_masterIsAvailable, _masterDisableMessage);
```
**Write point** (only in `PushStatusAsync`):
```csharp
_masterIsAvailable = isAvailable;
_masterDisableMessage = disableMessage;
```
**Why `volatile`**: `bool` and `string` reference assignments are already atomic in .NET. `volatile` adds the memory barrier to ensure other threads see the updated value without a `lock`. Consistent with `PersistentAvailabilityService`'s `_lastErrorTime` pattern.
---
## Pattern 3 — Constructor Aggregation: `MasterAvailabilityServiceDependencies`
**NFR**: Consistent with Unit 1's `MasterServiceDependencies` pattern (Q2=B)
**Record definition**:
```csharp
[ExcludeFromCodeCoverage]
public record MasterAvailabilityServiceDependencies(
IMasterRegistrationRepository Repository,
IMasterApiKeyProtector KeyProtector,
ILogger<MasterAvailabilityService> Logger
);
```
**Registration**:
```csharp
services.AddScoped<MasterAvailabilityServiceDependencies>();
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
```
**`MasterAvailabilityService` constructor**:
```csharp
public MasterAvailabilityService(MasterAvailabilityServiceDependencies deps)
{
_deps = deps;
}
```
**Test construction** (no DI container):
```csharp
var deps = new MasterAvailabilityServiceDependencies(
Substitute.For<IMasterRegistrationRepository>(),
Substitute.For<IMasterApiKeyProtector>(),
NullLogger<MasterAvailabilityService>.Instance
);
var svc = new MasterAvailabilityService(deps);
```
---
## Pattern 4 — Middleware Extension: `InvokeAsync` Third Parameter (Q1=A)
**NFR**: Master gate is outer gate; evaluated before local gate; admin bypass applies to both
**Pattern**: `AvailabilityMiddleware.InvokeAsync` receives `IMasterAvailabilityService` as a third DI-resolved parameter. ASP.NET Core middleware supports per-request parameter injection on `InvokeAsync`.
**Extended `InvokeAsync` signature**:
```csharp
public async Task InvokeAsync(
HttpContext context,
IAvailabilityService localSvc,
IMasterAvailabilityService masterSvc)
```
**Evaluation order**:
```mermaid
graph TD
A[Request] --> B{Bypass path?}
B -->|Yes| Z[Pass through]
B -->|No| C{Admin JWT?}
C -->|Yes| Z
C -->|No| D{masterSvc.GetMasterStatus\n.IsAvailable?}
D -->|true| E{localSvc\n.IsAvailableAsync?}
D -->|false| F[503 with master\nDisableMessage]
E -->|Available| Z
E -->|Unavailable| G[503 with local\ndisable 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 B,C,D,E decision
class Z pass
class F,G fail
```
Text alternative: Request hits bypass path check, then admin JWT check. If both fail, master gate evaluates (sync). If master blocks: 503 with master message. If master passes: local gate evaluates (async). If local blocks: 503 with local message. Otherwise pass through.
**Extended bypass prefix** (added to `_bypassPrefixes` array):
```csharp
private static readonly string[] _bypassPrefixes =
[
"/api/v1/Availability/status",
"/api/v1/Auth/",
"/api/v1/Setup/status",
"/api/v1/master/" // NEW — master can always reach slave
];
```
**503 response for master gate** (uses `ProblemDetails`):
```csharp
var masterStatus = masterSvc.GetMasterStatus();
if (!masterStatus.IsAvailable)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 503,
Title = "Service Unavailable",
Detail = masterStatus.DisableMessage
});
return;
}
```
**Test pattern** (mock `IMasterAvailabilityService`):
```csharp
var masterSvc = Substitute.For<IMasterAvailabilityService>();
masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, "Maintenance"));
// invoke middleware — assert 503
```
---
## Pattern 5 — Structured Logging
**Pattern**: Structured fields per event; never log key material.
| Scenario | Level | Structured Fields |
|----------|-------|------------------|
| First master registration | `Information` | `{MasterUrl}` |
| Re-registration (key match) | `Information` | `{MasterUrl}` |
| API key mismatch | `Warning` | `{Endpoint}` — do NOT log key |
| Missing header | `Warning` | `{Endpoint}` |
| Status update received | `Information` | `{IsAvailable}`, `{DisableMessage}` |
| Master gate blocked request | `Warning` | `{Path}`, `{DisableMessage}` |
| Get-registered-url called | `Debug` | — |
| Key decryption failure | `Error` | — (no key material) |
**Example**:
```csharp
_logger.LogWarning(
"Master API key mismatch on {Endpoint} — returning 401",
"POST /api/v1/master/status");
```
@@ -0,0 +1,99 @@
# NFR Requirements — Unit 2: slave-availability-extension
## Security
### SEC-01: ApiKey encrypted at rest (Q1=C)
The `MasterRegistration.ApiKey` field is **not** stored in plain text. The slave encrypts the key using ASP.NET Core Data Protection before writing to the DB, and decrypts it before comparison on each incoming request.
| Aspect | Decision |
|--------|----------|
| Mechanism | ASP.NET Core Data Protection (`IDataProtectionProvider`) |
| Wrapper type | `IMasterApiKeyProtector` / `MasterApiKeyProtector` |
| Purpose string | `"SlpModularCms.Availability.MasterApiKey"` |
| Comparison | Decrypt stored key → compare with incoming header value (plain-text equality) |
| On decryption failure | Return `null` (key is unreadable) → treat as mismatch → 401 |
**Rationale**: Consistent with Unit 1's `IApiKeyProtector` pattern. Protects the key if the database is accessed directly (e.g., backup file, DB dump). The purpose string isolates it from Unit 1's protector scope.
### SEC-02: Missing or empty header → 401
An absent or empty `X-Master-Api-Key` header on any master endpoint (register, status, registered-url) immediately returns 401 without touching the database. No information leaked about whether a registration exists.
### SEC-03: Unauthenticated controller, API key validated in service
The three master endpoints carry no `[Authorize]` attribute — authentication is done via the custom header in `MasterAvailabilityService`. The endpoints are in the `/api/v1/master/` bypass prefix so the availability gate cannot block them.
---
## Performance
### PERF-01: Master gate check is synchronous
The `AvailabilityMiddleware` reads `_masterIsAvailable` from a static volatile field — zero async overhead, zero DB call per request. Only status pushes touch the DB.
### PERF-02: No DB call on gate evaluation
The master gate evaluates solely from `volatile` static fields. The local gate still calls `IAvailabilityService.IsAvailableAsync()` (one DB read with 1s cache per `AvailabilityOptions.StatusCacheSeconds` — unchanged from existing behaviour).
---
## Reliability
### REL-01: Fail-open on process startup (Q2=A, Q4=A from FD)
`_masterIsAvailable` defaults to `true` at process startup. A slave that restarts before the master pushes status again is immediately accessible. The master's `IntegrityCheckBackgroundService` will push status again within `IntegrityCheckIntervalMinutes`.
### REL-02: No expiry on cached status (Q4=A from FD)
The static cache has no TTL. If the master goes offline permanently, the last pushed status is used indefinitely. For a slave last told "unavailable", it remains unavailable until either:
- The master recovers and pushes "available" again, or
- An operator manually calls `POST /api/v1/Availability/status` locally (existing `AvailabilityController` endpoint, Owner-only).
### REL-03: Static field thread safety via `volatile` (Q2=A)
`private static volatile bool _masterIsAvailable` and `private static volatile string? _masterDisableMessage`. Writes to reference types and bools are atomic in .NET; `volatile` ensures cross-thread visibility without a lock on every request. Consistent with `PersistentAvailabilityService`'s `_lastErrorTime` pattern.
---
## Maintainability
### MAINT-01: Test coverage ≥ 80% (Q3=B)
**Excluded** from coverage:
- `AvailabilityModule` (service registration changes + `Database.MigrateAsync()` call)
- EF Core migration files (auto-generated)
- DTO / request / response record classes (`[ExcludeFromCodeCoverage]`)
**Included** (must reach ≥ 80%):
- `MasterController`
- `MasterAvailabilityService` (all 3 public methods)
- `MasterApiKeyProtector`
- `AvailabilityMiddleware` (extended paths — master gate logic)
- `IMasterRegistrationRepository` / `MasterRegistrationRepository`
### MAINT-02: Structured logging (Q4=B)
| Scenario | Level | Structured Fields |
|----------|-------|------------------|
| First master registration | `Information` | `masterUrl` |
| Re-registration (existing key match) | `Information` | `masterUrl` |
| API key mismatch on any endpoint | `Warning` | `endpoint` (do NOT log the key itself) |
| Missing X-Master-Api-Key header | `Warning` | `endpoint` |
| Status update received | `Information` | `isAvailable`, `disableMessage` |
| Master gate blocked a request | `Warning` | `path`, `disableMessage` |
| Get-registered-url called | `Debug` | — |
| Decryption failure on stored key | `Error` | (no key value) |
---
## Test Framework (unchanged from Unit 1)
| Aspect | Decision |
|--------|----------|
| Framework | xUnit |
| Mocking | NSubstitute 5.x |
| Assertions | FluentAssertions 8.x |
| EF Core testing | EF Core InMemory provider |
| Data Protection testing | `EphemeralDataProtectionProvider` |
| Project name | `SlpModularCms.Modules.Availability.Master.Tests` |
@@ -0,0 +1,88 @@
# Tech Stack Decisions — Unit 2: slave-availability-extension
## Data Protection
### Decision: `IMasterApiKeyProtector` wrapping ASP.NET Core Data Protection
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Interface | `IMasterApiKeyProtector` with `Protect(string)` / `Unprotect(string)` | Testable; mirrors Unit 1's `IApiKeyProtector` pattern |
| Implementation | `MasterApiKeyProtector : IMasterApiKeyProtector` | Wraps `IDataProtectionProvider`; purpose-scoped |
| Purpose string | `"SlpModularCms.Availability.MasterApiKey"` | Prevents cross-module decryption with Unit 1's scope |
| On `Unprotect` failure | Catch `CryptographicException`, return `null` | Service treats null as key mismatch → 401; logs Error |
| Key ring | Default file system (inherits app-level `AddDataProtection()` setup) | No extra configuration needed; same caveat as Unit 1 regarding containerized deployments |
**Production note**: Same as Unit 1 — for multi-instance or containerized deployments, configure a shared key ring (`PersistKeysToDbContext`, `PersistKeysToAzureBlobStorage`, etc.). Without it, a restarted container cannot decrypt keys stored by the previous instance.
---
## Static Cache
### Decision: `volatile` static fields in `MasterAvailabilityService`
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| `_masterIsAvailable` | `private static volatile bool` | Atomic read/write for bool; `volatile` ensures CPU cache flush visibility |
| `_masterDisableMessage` | `private static volatile string?` | Reference assignment is atomic in .NET; `volatile` ensures visibility |
| Default | `_masterIsAvailable = true`, `_masterDisableMessage = null` | Fail-open: process startup = Available |
| Write location | `MasterAvailabilityService.PushStatusAsync` only | Single write point; no other code modifies cache |
| Read location | `AvailabilityMiddleware.InvokeAsync` only | Single read point; no async overhead |
**Why not `lock`**: No multi-field invariant to protect (fields are read/written independently). `volatile` matches the existing pattern in `PersistentAvailabilityService` (`_lastErrorTime`).
---
## EF Core / Database
### Decision: New `AvailabilityDbContext` with own migrations
| Aspect | Decision |
|--------|----------|
| DbContext class | `AvailabilityDbContext : DbContext` in `SlpModularCms.Modules.Availability` |
| Migration assembly | `SlpModularCms.Modules.Availability` (same project) |
| Migration application | `app.ApplicationServices.CreateScope()``AvailabilityDbContext.Database.MigrateAsync()` in `AvailabilityModule.UseModule(IApplicationBuilder)` |
| DbSet | `DbSet<MasterRegistration> MasterRegistrations` |
| Table name | `AvailabilityMasterRegistrations` |
| Connection string | Reuses `ConnectionStrings:DefaultConnection` (same as `ApplicationDbContext` and `MasterDbContext`) |
| Registration | `services.AddDbContext<AvailabilityDbContext>((sp, options) => ...)` using `IConfiguration` from service provider |
**Singleton enforcement**: `MasterRegistration.Id` is always `new Guid("00000000-0000-0000-0000-000000000001")`. EF `AddOrUpdate` via `ExecuteUpdateAsync` / find-by-id pattern.
---
## Repository
### Decision: `IMasterRegistrationRepository` / `MasterRegistrationRepository`
| Method | Signature | Notes |
|--------|-----------|-------|
| `GetAsync` | `Task<MasterRegistration?>` | Loads singleton by fixed Id; returns null if not exists |
| `UpsertAsync` | `Task UpsertAsync(MasterRegistration registration)` | Add or Update based on whether row exists |
| `SaveChangesAsync` | `Task SaveChangesAsync()` | Explicit save; keeps service in control of transaction boundary |
---
## Controller
### Decision: New `MasterController` in Availability module
| Aspect | Decision |
|--------|----------|
| Class | `MasterController : ControllerBase` in `SlpModularCms.Modules.Availability.Controllers` |
| Route | `[Route("[controller]")]``/api/v1/master` via `ApiPrefixConvention("api/v1")` |
| Auth | No `[Authorize]` — API key validated in service layer |
| Response on 401 | `Unauthorized()` (HTTP 401) — no `ProblemDetails` body to avoid leaking info |
| Response on success | `Ok()` for register/status; `Ok(new { MasterUrl })` for registered-url |
---
## New Dependencies
| Package | Already present? | Notes |
|---------|-----------------|-------|
| `Microsoft.AspNetCore.DataProtection` | Yes (shared framework) | No NuGet addition needed |
| EF Core SqlServer | Yes (via Core project) | No addition needed |
| xUnit / NSubstitute / FluentAssertions | Yes (existing test projects) | Reference same versions as `Availability.Tests` |
| EF Core InMemory | Likely yes | Confirm in `Availability.Tests.csproj` |
**Net new NuGet packages required**: None.
@@ -0,0 +1,164 @@
# Application Design — Master CMS Module
## Design Decisions Summary
| Question | Decision |
|----------|----------|
| Q1 — HTTP client (Master → Slave) | **A) Typed client**`ISlaveApiClient` / `SlaveApiClient` via `AddHttpClient<>` |
| Q2 — Two-phase gate middleware | **B) Extend `AvailabilityMiddleware`** — Master gate added at top of `InvokeAsync` |
| Q3 — Slave-side status caching | **A) Static field + timestamp** — consistent with existing circuit breaker pattern |
| Q4 — Service responsibility split | **B) `CmsInstanceRepository` + `CmsInstanceService`** — data and orchestration separated |
| Q5 — Master controller granularity | **A) Single `CmsInstanceController`** — all actions in one controller |
| Q6 — Slave internal endpoint placement | **B) Extended `AvailabilityController`** — registration endpoint added to existing controller |
| Q7 — Frontend hooks organization | **B) Separate hook files** — one file per hook: `useCmsInstances.ts`, `useAddCmsInstance.ts`, `useUpdateCmsInstanceStatus.ts` |
---
## Architecture Overview
```mermaid
graph TD
subgraph MasterCms["Master CMS Instance"]
MasterModule["MasterModule\n(IModule)"]
CmsCtrl["CmsInstanceController\n/api/v1/CmsInstances"]
CmsService["CmsInstanceService\n(orchestration)"]
CmsRepo["CmsInstanceRepository\n(data access)"]
MasterDb["MasterDbContext\nCmsInstances table"]
SlaveClient["SlaveApiClient\n(typed HTTP client)"]
BgService["IntegrityCheckBackgroundService\n(PeriodicTimer)"]
MasterOpts["MasterModuleOptions"]
end
subgraph SlaveCms["Slave CMS Instance"]
ExtAvailMw["AvailabilityMiddleware\n(EXTENDED — two-phase gate)"]
MasterAvailSvc["MasterAvailabilityService\n(pull + cache + fallback)"]
LocalAvailSvc["IAvailabilityService\n(existing local gate)"]
AvailDb["AvailabilityDbContext\nMasterRegistrations table"]
ExtAvailCtrl["AvailabilityController\n(EXTENDED + RegisterMaster)"]
SlaveOpts["MasterModuleOptions\n(ApiKey, CacheMinutes)"]
end
subgraph FrontendApp["Frontend (Master UI)"]
CmsPage["CmsPage\n(/cms route)"]
Hooks["TanStack Query Hooks\n(useCmsInstances, useAddCmsInstance,\nuseUpdateCmsInstanceStatus)"]
Components["Components\n(CmsInstanceList,\nAddCmsInstanceDialog,\nSetStatusDialog)"]
end
FrontendApp -->|"REST /api/v1/CmsInstances"| MasterCms
MasterCms -->|"HTTP slave API"| SlaveCms
SlaveCms -->|"HTTP pull status"| MasterCms
CmsPage --> Components
Components --> Hooks
Hooks -->|"GET/POST/PUT"| CmsCtrl
CmsCtrl --> CmsService
CmsService --> CmsRepo
CmsService --> SlaveClient
CmsRepo --> MasterDb
BgService --> CmsService
CmsService --> MasterOpts
BgService --> MasterOpts
ExtAvailMw --> MasterAvailSvc
ExtAvailMw --> LocalAvailSvc
MasterAvailSvc --> AvailDb
MasterAvailSvc --> SlaveOpts
ExtAvailCtrl --> AvailDb
ExtAvailCtrl --> SlaveOpts
classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef controller fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef service fill:#FFC107,stroke:#F57F17,stroke-width:1px,color:#000
classDef data fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
classDef frontend fill:#FC8181,stroke:#C53030,stroke-width:1px,color:#000
classDef config fill:#B0BEC5,stroke:#546E7A,stroke-width:1px,color:#000
class MasterModule module
class CmsCtrl,ExtAvailCtrl controller
class CmsService,MasterAvailSvc,SlaveClient,LocalAvailSvc,BgService service
class CmsRepo,MasterDb,AvailDb data
class CmsPage,Hooks,Components frontend
class MasterOpts,SlaveOpts config
```
Text alternative: Master CMS has new module with controller, service, repository, typed HTTP client, and background service; Slave CMS has extended middleware with two-phase gate, new MasterAvailabilityService, new AvailabilityDbContext, and extended controller; Frontend has CmsPage with hooks and components calling Master REST API.
---
## Component Inventory
### Unit 1 — master-backend (`SlpModularCms.Modules.Master`)
| Component | Type | New/Modified |
|-----------|------|--------------|
| `MasterModule` | `IModule` | New |
| `MasterDbContext` | EF Core `DbContext` | New |
| `CmsInstance` | Entity | New |
| `CmsInstanceStatus` | Enum | New |
| `ICmsInstanceRepository` / `CmsInstanceRepository` | Repository | New |
| `ICmsInstanceService` / `CmsInstanceService` | Service | New |
| `ISlaveApiClient` / `SlaveApiClient` | Typed HTTP client | New |
| `CmsInstanceController` | Controller | New |
| `IntegrityCheckBackgroundService` | `BackgroundService` | New |
| `MasterModuleOptions` | Config POCO | New |
| `CmsInstanceDto` | DTO | New |
| `CreateCmsInstanceRequest` | Request model | New |
| `UpdateStatusRequest` | Request model | New |
### Unit 2 — slave-availability-extension (`SlpModularCms.Modules.Availability`)
| Component | Type | New/Modified |
|-----------|------|--------------|
| `MasterRegistration` | Entity | New |
| `AvailabilityDbContext` | EF Core `DbContext` | New |
| `IMasterAvailabilityService` / `MasterAvailabilityService` | Service | New |
| `AvailabilityMiddleware` | Middleware | Modified |
| `AvailabilityController` | Controller | Modified |
| `MasterGateResult` | Result record | New |
| `RegisterMasterRequest` | Request model | New |
### Unit 3 — frontend-cms-page (`frontend/`)
| Component | Type | New/Modified |
|-----------|------|--------------|
| `CmsPage` | React page | New |
| `CmsInstanceList` | React component | New |
| `AddCmsInstanceDialog` | React component | New |
| `SetStatusDialog` | React component | New |
| `useCmsInstances` | TanStack Query hook | New |
| `useAddCmsInstance` | TanStack Query hook | New |
| `useUpdateCmsInstanceStatus` | TanStack Query hook | New |
| `CmsInstance` | TypeScript type | New |
| `CmsInstanceStatus` | TypeScript enum | New |
### Unit 4 — documentation
| Artifact | Type | New/Modified |
|----------|------|--------------|
| `README.md` — Migrations section | Documentation | Modified |
| `README.md` — Module guide section | Documentation | Modified |
| `README.md` — Production env vars | Documentation | Modified |
| `frontend/README.md` | Documentation | Modified |
---
## Key Design Constraints
| Constraint | Source | Impact |
|-----------|--------|--------|
| `ApiKey` never returned in API responses | NFR-MASTER-03 | `CmsInstanceDto` excludes `ApiKey`; only accepted in `CreateCmsInstanceRequest` |
| Fail-open on Master unreachable | NFR-MASTER-01 | `MasterAvailabilityService` returns last cached status (default Available) on HTTP failure |
| Per-module DbContext + migrations | NFR-MASTER-06 | New `MasterDbContext` in `Modules.Master`; new `AvailabilityDbContext` in `Modules.Availability` |
| Master exemption from own gate | FR-MASTER-09 | Handled naturally: no `MasterRegistration` record exists on Master instance → gate skipped |
| Disable message required for NotAvailable | FR-MASTER-14 | Validated in `CmsInstanceService.UpdateStatusAsync` before persistence |
| Inactive slaves: no HTTP contact | FR-MASTER-13 | `GetActiveAsync()` filters out Inactive before integrity checks and status pushes |
| Owner role only | FR-MASTER-10 | `[Authorize(Policy = "OwnerOnly")]` on all `CmsInstanceController` actions |
---
## Artifact References
| Artifact | Path |
|----------|------|
| Component definitions | `aidlc-docs/features/master-cms-module/inception/application-design/components.md` |
| Method signatures | `aidlc-docs/features/master-cms-module/inception/application-design/component-methods.md` |
| Service orchestration + flows | `aidlc-docs/features/master-cms-module/inception/application-design/services.md` |
| Dependency diagrams + matrix | `aidlc-docs/features/master-cms-module/inception/application-design/component-dependency.md` |
@@ -0,0 +1,187 @@
# Component Dependencies — Master CMS Module
## Package-Level Dependency Diagram
```mermaid
graph TD
Api["SlpModularCms.Api\n(shell)"]
Master["SlpModularCms.Modules.Master\n(NEW)"]
Availability["SlpModularCms.Modules.Availability\n(EXTENDED)"]
Core["SlpModularCms.Core\n(unchanged)"]
Frontend["Frontend\n(EXTENDED)"]
SlaveCms["Slave CMS\n(another SlpModularCms instance)"]
Api --> Master
Api --> Availability
Api --> Core
Master --> Core
Availability --> Core
Frontend -->|"REST /api/v1/*"| Api
Master -->|"HTTP slave API calls"| SlaveCms
SlaveCms -->|"HTTP pull master status"| Master
classDef new fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef extended fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef unchanged fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef external fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Master new
class Availability,Frontend extended
class Api,Core unchanged
class SlaveCms external
```
Text alternative: Master and Availability modules both depend on Core; Api depends on all three; Frontend calls Api REST; Master calls Slave CMS via HTTP and Slave pulls back.
---
## Master-Backend Component Dependencies
```mermaid
graph TD
Ctrl["CmsInstanceController"]
Svc["CmsInstanceService"]
Repo["CmsInstanceRepository"]
DbCtx["MasterDbContext"]
SlaveClient["SlaveApiClient"]
BgSvc["IntegrityCheckBackgroundService"]
Opts["MasterModuleOptions\n(IOptions)"]
CmsEntity["CmsInstance\n(entity)"]
Ctrl --> Svc
Svc --> Repo
Svc --> SlaveClient
Svc --> Opts
Repo --> DbCtx
Repo --> CmsEntity
DbCtx --> CmsEntity
BgSvc --> Svc
BgSvc --> Opts
classDef controller fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef service fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef data fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef infra fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
class Ctrl controller
class Svc,SlaveClient service
class Repo,DbCtx,CmsEntity data
class BgSvc,Opts infra
```
Text alternative: Controller depends on Service; Service depends on Repository, SlaveApiClient, and Options; Repository owns DbContext and entity; BackgroundService depends on Service and Options.
---
## Slave-Availability-Extension Component Dependencies
```mermaid
graph TD
Middleware["AvailabilityMiddleware\n(EXTENDED)"]
LocalSvc["IAvailabilityService\n(existing)"]
MasterSvc["MasterAvailabilityService\n(NEW)"]
AvailDb["AvailabilityDbContext\n(NEW)"]
MasterRegEntity["MasterRegistration\n(entity)"]
HttpFactory["IHttpClientFactory"]
Opts["MasterModuleOptions\n(IOptions)"]
AvailCtrl["AvailabilityController\n(EXTENDED)"]
MasterCms["Master CMS\n(HTTP endpoint)"]
Middleware --> LocalSvc
Middleware --> MasterSvc
MasterSvc --> AvailDb
MasterSvc --> HttpFactory
MasterSvc --> Opts
AvailDb --> MasterRegEntity
AvailCtrl --> AvailDb
AvailCtrl --> Opts
HttpFactory -->|"HTTP GET status"| MasterCms
classDef middleware fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef service fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef data fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef infra fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
classDef external fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Middleware middleware
class MasterSvc,LocalSvc service
class AvailDb,MasterRegEntity,AvailCtrl data
class HttpFactory,Opts infra
class MasterCms external
```
Text alternative: Extended middleware calls both MasterAvailabilityService and existing IAvailabilityService; MasterAvailabilityService reads DB for registration and pulls from master via HTTP; AvailabilityController handles registration writes.
---
## Frontend Component Dependencies
```mermaid
graph TD
CmsPage["CmsPage\n(/cms route)"]
List["CmsInstanceList"]
AddDialog["AddCmsInstanceDialog"]
StatusDialog["SetStatusDialog"]
HookList["useCmsInstances"]
HookAdd["useAddCmsInstance"]
HookStatus["useUpdateCmsInstanceStatus"]
ApiTypes["CmsInstance types\nCmsInstanceStatus enum"]
MasterApi["Master CMS REST API\n/api/v1/CmsInstances"]
CmsPage --> List
CmsPage --> AddDialog
CmsPage --> StatusDialog
List --> HookList
AddDialog --> HookAdd
StatusDialog --> HookStatus
HookList --> ApiTypes
HookAdd --> ApiTypes
HookStatus --> ApiTypes
HookList -->|"GET"| MasterApi
HookAdd -->|"POST"| MasterApi
HookStatus -->|"PUT"| MasterApi
classDef page fill:#63b3ed,stroke:#2b6cb0,stroke-width:2px,color:#000
classDef component fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000
classDef hook fill:#FFC107,stroke:#F57F17,stroke-width:1px,color:#000
classDef types fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
classDef api fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class CmsPage page
class List,AddDialog,StatusDialog component
class HookList,HookAdd,HookStatus hook
class ApiTypes types
class MasterApi api
```
Text alternative: CmsPage renders List + dialogs; List uses useCmsInstances; dialogs use mutation hooks; all hooks use shared API types and call Master REST API.
---
## Dependency Matrix
| Component | Depends On | Used By |
|-----------|-----------|---------|
| `CmsInstanceController` | `ICmsInstanceService` | `SlpModularCms.Api` (module registration) |
| `CmsInstanceService` | `ICmsInstanceRepository`, `ISlaveApiClient`, `IOptions<MasterModuleOptions>` | `CmsInstanceController`, `IntegrityCheckBackgroundService` |
| `CmsInstanceRepository` | `MasterDbContext` | `CmsInstanceService` |
| `MasterDbContext` | EF Core, `CmsInstance` entity | `CmsInstanceRepository` |
| `SlaveApiClient` | `HttpClient` (via `IHttpClientFactory`) | `CmsInstanceService` |
| `IntegrityCheckBackgroundService` | `IServiceScopeFactory`, `ICmsInstanceService`, `IOptions<MasterModuleOptions>` | `IHostedService` (framework) |
| `AvailabilityMiddleware` (ext.) | `IAvailabilityService`, `IMasterAvailabilityService` | ASP.NET Core pipeline |
| `MasterAvailabilityService` | `AvailabilityDbContext`, `IHttpClientFactory`, `IOptions<MasterModuleOptions>` | `AvailabilityMiddleware` |
| `AvailabilityDbContext` | EF Core, `MasterRegistration` entity | `MasterAvailabilityService`, `AvailabilityController` |
| `AvailabilityController` (ext.) | `IAvailabilityService`, `AvailabilityDbContext`, `IOptions<MasterModuleOptions>` | `SlpModularCms.Api` |
| `CmsPage` | `CmsInstanceList`, `AddCmsInstanceDialog`, `SetStatusDialog` | React Router |
| `useCmsInstances` | TanStack Query, API types | `CmsInstanceList` |
| `useAddCmsInstance` | TanStack Query, API types | `AddCmsInstanceDialog` |
| `useUpdateCmsInstanceStatus` | TanStack Query, API types | `SetStatusDialog` |
---
## Cross-Cutting Concerns
| Concern | Mechanism |
|---------|-----------|
| Authentication (API) | JWT Bearer on all master endpoints; `[Authorize(Policy = "OwnerOnly")]` on `CmsInstanceController` |
| Authentication (Slave internal) | `X-Master-Api-Key` header validated against `MasterModuleOptions.ApiKey` |
| API key secrecy | `ApiKey` never included in `CmsInstanceDto`; only in `CreateCmsInstanceRequest` input |
| Fail-open | `MasterAvailabilityService` returns cached status (default `Available`) on HTTP failure |
| Per-module migrations | `MasterDbContext` in `Modules.Master`; `AvailabilityDbContext` in `Modules.Availability`; applied at startup in `UseModule` |
| Configuration | `MasterModuleOptions` via `IOptions<MasterModuleOptions>`; section `"MasterModule"` in `appsettings.json` |
@@ -0,0 +1,142 @@
# Component Methods — Master CMS Module
> Method signatures at the interface level. Detailed business rules and implementation logic are deferred to Functional Design (CONSTRUCTION phase).
---
## Unit 1 — master-backend
### ICmsInstanceRepository
| Method | Signature | Purpose |
|--------|-----------|---------|
| `GetAllAsync` | `Task<IReadOnlyList<CmsInstance>> GetAllAsync()` | Returns all registered slave CMS instances |
| `GetActiveAsync` | `Task<IReadOnlyList<CmsInstance>> GetActiveAsync()` | Returns all non-Inactive instances (used by integrity check) |
| `GetByIdAsync` | `Task<CmsInstance?> GetByIdAsync(Guid id)` | Returns instance by primary key; null if not found |
| `AddAsync` | `Task AddAsync(CmsInstance instance)` | Adds new entity to the change tracker |
| `UpdateAsync` | `Task UpdateAsync(CmsInstance instance)` | Marks entity as modified in the change tracker |
| `SaveChangesAsync` | `Task<int> SaveChangesAsync()` | Persists pending changes via `MasterDbContext` |
### ICmsInstanceService
| Method | Signature | Purpose |
|--------|-----------|---------|
| `GetAllAsync` | `Task<IReadOnlyList<CmsInstanceDto>> GetAllAsync()` | Returns all instances as DTOs; `ApiKey` excluded (NFR-MASTER-03) |
| `AddAsync` | `Task<CmsInstanceDto> AddAsync(CreateCmsInstanceRequest request)` | Creates entity, persists, triggers auto-registration with slave (FR-MASTER-03); returns DTO |
| `UpdateStatusAsync` | `Task UpdateStatusAsync(Guid id, CmsInstanceStatus status, string? disableMessage)` | Updates entity status, persists, pushes status to slave via HTTP (FR-MASTER-05); `disableMessage` required when `status = NotAvailable` (FR-MASTER-14) |
| `VerifyIntegrityAsync` | `Task VerifyIntegrityAsync()` | Called by `IntegrityCheckBackgroundService`; checks all active slaves have correct master URL; re-registers if mismatch (FR-MASTER-04) |
### ISlaveApiClient
| Method | Signature | Purpose |
|--------|-----------|---------|
| `RegisterMasterAsync` | `Task<bool> RegisterMasterAsync(string slaveUrl, string apiKey, string masterUrl)` | POST `/api/internal/master/register` on slave; returns `true` on success (FR-MASTER-03) |
| `PushStatusAsync` | `Task<bool> PushStatusAsync(string slaveUrl, string apiKey, CmsInstanceStatus status, string? disableMessage)` | Pushes new status to slave's availability endpoint; returns `true` on success (FR-MASTER-05) |
| `GetRegisteredMasterUrlAsync` | `Task<string?> GetRegisteredMasterUrlAsync(string slaveUrl, string apiKey)` | GET slave's currently registered master URL; used for integrity check (FR-MASTER-04); null if no master registered |
### IntegrityCheckBackgroundService
| Method | Signature | Purpose |
|--------|-----------|---------|
| `ExecuteAsync` | `override Task ExecuteAsync(CancellationToken stoppingToken)` | Main background loop; uses `PeriodicTimer` with interval from `MasterModuleOptions.IntegrityCheckIntervalMinutes`; creates `IServiceScope` per tick to resolve scoped services |
### CmsInstanceController
| Method | HTTP | Route | Purpose |
|--------|------|-------|---------|
| `GetAll` | GET | `/api/v1/CmsInstances` | Returns `IReadOnlyList<CmsInstanceDto>` |
| `Add` | POST | `/api/v1/CmsInstances` | Body: `CreateCmsInstanceRequest`; returns created `CmsInstanceDto` (201) |
| `UpdateStatus` | PUT | `/api/v1/CmsInstances/{id}/status` | Body: `UpdateStatusRequest`; returns 200 OK or 404 if not found |
---
## Unit 2 — slave-availability-extension
### IMasterAvailabilityService
| Method | Signature | Purpose |
|--------|-----------|---------|
| `GetMasterStatusAsync` | `Task<MasterGateResult> GetMasterStatusAsync()` | Checks DB for `MasterRegistration`; if no registration → returns `HasMaster = false`; if registration exists → returns cached or freshly-pulled status with fail-open fallback |
### MasterGateResult
| Property | Type | Purpose |
|----------|------|---------|
| `HasMaster` | `bool` | Whether a master URL is registered on this slave |
| `Status` | `CmsInstanceStatus?` | Master-controlled status (null when `HasMaster = false`) |
| `DisableMessage` | `string?` | Message to include in 503 when `Status = NotAvailable` |
### AvailabilityController (new method)
| Method | HTTP | Route | Purpose |
|--------|------|-------|---------|
| `RegisterMaster` | POST | `/api/internal/master/register` | Header: `X-Master-Api-Key`; Body: `RegisterMasterRequest`; validates key, upserts `MasterRegistration`; returns 200 OK or 401 Unauthorized |
### RegisterMasterRequest
| Property | Type | Notes |
|----------|------|-------|
| `MasterUrl` | `string` | Base URL of the Master CMS |
### AvailabilityMiddleware.InvokeAsync (extended signature)
```csharp
public async Task InvokeAsync(
HttpContext context,
IAvailabilityService availabilityService,
IMasterAvailabilityService masterAvailabilityService)
```
---
## Unit 3 — frontend-cms-page
### useCmsInstances
```typescript
function useCmsInstances(): UseQueryResult<CmsInstance[], Error>
```
### useAddCmsInstance
```typescript
interface CreateCmsInstancePayload {
name: string;
url: string;
apiKey: string;
}
function useAddCmsInstance(): UseMutationResult<CmsInstance, Error, CreateCmsInstancePayload>
```
### useUpdateCmsInstanceStatus
```typescript
interface UpdateStatusPayload {
id: string;
status: CmsInstanceStatus;
disableMessage?: string;
}
function useUpdateCmsInstanceStatus(): UseMutationResult<void, Error, UpdateStatusPayload>
```
### CmsInstance (TypeScript)
```typescript
interface CmsInstance {
id: string;
name: string;
url: string;
status: CmsInstanceStatus;
disableMessage?: string;
lastContactedAt?: string; // ISO 8601
lastStatusPushedAt?: string; // ISO 8601
}
enum CmsInstanceStatus {
Available = 'Available',
NotAvailable = 'NotAvailable',
Inactive = 'Inactive',
}
```
@@ -0,0 +1,167 @@
# Components — Master CMS Module
## Unit 1 — master-backend (`SlpModularCms.Modules.Master`)
### MasterModule
- **Type**: Module registration (`IModule`)
- **Responsibilities**: Registers all master-side services (repository, service, typed HTTP client, background service, options); applies `MasterDbContext` migrations at startup via `UseModule`; does NOT register middleware (master instance has no availability gate)
- **Interface**: `IModule` (`RegisterServices`, `UseModule`)
### MasterDbContext
- **Type**: EF Core `DbContext`
- **Responsibilities**: Per-module DbContext; owns the `CmsInstances` table and its migrations; migrations live in `SlpModularCms.Modules.Master` (NFR-MASTER-06)
- **Entities owned**: `CmsInstance`
### CmsInstance
- **Type**: Domain Entity
- **Responsibilities**: Represents a registered slave CMS instance
- **Fields**:
- `Id``Guid`, primary key
- `Name``string`, friendly display name
- `Url``string`, base URL of slave CMS API
- `ApiKey``string`, secret used by Master to authenticate against slave; never returned in API responses (NFR-MASTER-03)
- `Status``CmsInstanceStatus` enum (`Available` / `NotAvailable` / `Inactive`)
- `DisableMessage``string?`, required when `Status = NotAvailable`
- `LastContactedAt``DateTimeOffset?`
- `LastStatusPushedAt``DateTimeOffset?`
### CmsInstanceStatus
- **Type**: Enum
- **Values**: `Available`, `NotAvailable`, `Inactive`
### ICmsInstanceRepository / CmsInstanceRepository
- **Type**: Repository (data access only)
- **Responsibilities**: CRUD operations on `CmsInstance` via `MasterDbContext`; no business logic
- **Lifetime**: Scoped
### ICmsInstanceService / CmsInstanceService
- **Type**: Service (orchestration)
- **Responsibilities**: Business orchestration — calls repository for data access; calls `ISlaveApiClient` for HTTP side-effects (auto-registration, status push, integrity verification); enforces business rules (e.g., `DisableMessage` required when `NotAvailable`)
- **Lifetime**: Scoped
### ISlaveApiClient / SlaveApiClient
- **Type**: Typed HTTP client
- **Responsibilities**: All Master → Slave HTTP communication (registration, status push, integrity check); adds `X-Master-Api-Key` header; handles HTTP errors and returns success flags
- **Registration**: `services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()`
- **Lifetime**: Transient (managed by `IHttpClientFactory`)
### CmsInstanceController
- **Type**: ASP.NET Core `ControllerBase`
- **Responsibilities**: REST API for slave CMS management; `[Authorize(Policy = "OwnerOnly")]`; delegates to `ICmsInstanceService`
- **Route**: `/api/v1/CmsInstances`
- **Actions**: GET list, POST add, PUT update status
### IntegrityCheckBackgroundService
- **Type**: `BackgroundService`
- **Responsibilities**: Periodic background loop; verifies each non-Inactive slave still has the correct master URL registered; re-registers if mismatch found; interval configurable via `MasterModuleOptions.IntegrityCheckIntervalMinutes` (default 60)
- **Pattern**: Uses `PeriodicTimer`; injects `IServiceScopeFactory` to resolve scoped `ICmsInstanceService` per tick
- **Lifetime**: Singleton (as required by `BackgroundService`)
### MasterModuleOptions
- **Type**: Configuration POCO
- **Fields**:
- `IntegrityCheckIntervalMinutes``int`, default 60 (master-side)
- `CacheMinutes``int`, default 60 (slave-side)
- `ApiKey``string` (slave-side; key the slave uses to validate incoming master requests)
- **Registration**: `services.Configure<MasterModuleOptions>(configuration.GetSection("MasterModule"))`
### DTOs and Request Models
| Type | Fields | Notes |
|------|--------|-------|
| `CmsInstanceDto` | `Id`, `Name`, `Url`, `Status`, `DisableMessage`, `LastContactedAt`, `LastStatusPushedAt` | No `ApiKey` (NFR-MASTER-03) |
| `CreateCmsInstanceRequest` | `Name`, `Url`, `ApiKey` | API key stored securely, never returned |
| `UpdateStatusRequest` | `Status`, `DisableMessage?` | `DisableMessage` required when `Status = NotAvailable` |
---
## Unit 2 — slave-availability-extension (`SlpModularCms.Modules.Availability`)
### MasterRegistration
- **Type**: Domain Entity
- **Responsibilities**: Stores the registered Master CMS URL on the slave side; zero or one records per slave (the slave knows at most one master)
- **Fields**:
- `Id``Guid`, primary key
- `MasterUrl``string`, base URL of the Master CMS
- `RegisteredAt``DateTimeOffset`
### AvailabilityDbContext
- **Type**: EF Core `DbContext` (new, per-module)
- **Responsibilities**: Per-module DbContext introduced in the Availability module for the slave-side entity; owns the `MasterRegistrations` table; migrations live in `SlpModularCms.Modules.Availability`
- **Entities owned**: `MasterRegistration`
### IMasterAvailabilityService / MasterAvailabilityService
- **Type**: Service
- **Responsibilities**: Checks whether a master URL is registered (DB lookup); pulls master-controlled availability status via HTTP GET; caches last known status using static fields + timestamp (same pattern as `PersistentAvailabilityService`); implements fail-open fallback when master is unreachable; respects `MasterModuleOptions.CacheMinutes`
- **Cache pattern**: Static fields `_cachedStatus` (default `Available`) + `_lastFetchedAt`; stale check based on `CacheMinutes`
- **Exemption (FR-MASTER-09)**: No special exemption logic needed — if no `MasterRegistration` record exists in DB (which is the case on a Master instance that never registered itself), the gate is skipped automatically
- **Lifetime**: Scoped (static fields provide cross-request caching)
### AvailabilityMiddleware (extended)
- **Type**: ASP.NET Core Middleware
- **Responsibilities**: Extended with Master gate logic at the **top** of `InvokeAsync`; two-phase check:
1. **Master gate** — calls `IMasterAvailabilityService.GetMasterStatusAsync()`; if no master registered → skip to local gate; if master says `NotAvailable` → 503 with `DisableMessage`; if unreachable → use cached/fallback value (fail-open)
2. **Local gate** — existing `IAvailabilityService` check, unchanged
- **Bypass prefixes**: Extended to also bypass internal master endpoints (`/api/internal/master/`) so registration calls are never blocked
### AvailabilityController (extended)
- **Type**: ASP.NET Core `ControllerBase` (existing class extended)
- **Responsibilities**: New action `RegisterMaster` added; validates `X-Master-Api-Key` header against configured `MasterModuleOptions.ApiKey`; upserts `MasterRegistration` in `AvailabilityDbContext`
- **New route**: `POST /api/internal/master/register`
- **Authentication**: API key validation (no JWT; the registration endpoint is called machine-to-machine)
### RegisterMasterRequest
- **Type**: Request model
- **Fields**: `MasterUrl``string`
### MasterGateResult
- **Type**: Result record
- **Fields**: `HasMaster` (`bool`), `Status` (`CmsInstanceStatus?`), `DisableMessage` (`string?`)
---
## Unit 3 — frontend-cms-page (`frontend/`)
### CmsPage
- **Type**: React page component
- **Route**: `/cms`
- **Responsibilities**: Owner-only route guard; fetches slave list via `useCmsInstances`; renders `CmsInstanceList`; manages dialog open state for Add and Set Status actions
### CmsInstanceList
- **Type**: React component
- **Responsibilities**: Renders a table of `CmsInstance` items; shows Name, URL, Status badge, LastContactedAt, DisableMessage; `Inactive` rows are visually greyed out; provides action triggers (Add button, Set Status button per row)
### AddCmsInstanceDialog
- **Type**: React component (modal dialog using shadcn/ui `Dialog`)
- **Responsibilities**: Form with fields Name, URL, ApiKey (all required); validates before submission; calls `useAddCmsInstance` mutation; closes on success
### SetStatusDialog
- **Type**: React component (modal dialog using shadcn/ui `Dialog`)
- **Responsibilities**: Status dropdown (`Available`, `NotAvailable`, `Inactive`); `DisableMessage` text field rendered and required when status is `NotAvailable`; calls `useUpdateCmsInstanceStatus` mutation; closes on success
### CmsInstanceStatus (TypeScript enum)
- **Values**: `Available`, `NotAvailable`, `Inactive`
### CmsInstance (TypeScript type)
- **Fields**: `id`, `name`, `url`, `status`, `disableMessage`, `lastContactedAt`, `lastStatusPushedAt`
### useCmsInstances
- **Type**: TanStack Query `useQuery` hook
- **File**: `hooks/useCmsInstances.ts`
- **Responsibilities**: GET `/api/v1/CmsInstances`; returns list of `CmsInstance`
### useAddCmsInstance
- **Type**: TanStack Query `useMutation` hook
- **File**: `hooks/useAddCmsInstance.ts`
- **Responsibilities**: POST `/api/v1/CmsInstances`; invalidates `useCmsInstances` query on success
### useUpdateCmsInstanceStatus
- **Type**: TanStack Query `useMutation` hook
- **File**: `hooks/useUpdateCmsInstanceStatus.ts`
- **Responsibilities**: PUT `/api/v1/CmsInstances/{id}/status`; invalidates `useCmsInstances` query on success
---
## Unit 4 — documentation
No new components. Covers README updates only (FR-MASTER-15). See requirements for scope.
@@ -0,0 +1,233 @@
# Services — Master CMS Module
## Service Definitions
### Master-Side Services
#### CmsInstanceService
- **Interface**: `ICmsInstanceService`
- **Lifetime**: Scoped
- **Injected Dependencies**: `ICmsInstanceRepository`, `ISlaveApiClient`, `IOptions<MasterModuleOptions>`, `ILogger<CmsInstanceService>`
- **Responsibilities**: Business orchestration — coordinates repository (data) and SlaveApiClient (HTTP side-effects); enforces business rules (mandatory DisableMessage, Inactive cannot be pushed)
- **Key orchestration**: On `AddAsync` → persist entity then call `RegisterMasterAsync`; on `UpdateStatusAsync` → validate, persist, then call `PushStatusAsync`; on `VerifyIntegrityAsync` → query all active instances, call `GetRegisteredMasterUrlAsync` per instance, re-register on mismatch
#### SlaveApiClient
- **Interface**: `ISlaveApiClient`
- **Lifetime**: Transient (managed by `IHttpClientFactory`)
- **Registration**: `services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()`
- **Injected Dependencies**: `HttpClient` (injected by framework)
- **Responsibilities**: Typed HTTP client for all Master → Slave API calls; sets `X-Master-Api-Key` header per call; deserializes responses; returns success flags rather than throwing (callers decide error handling)
- **Endpoints called**:
- `POST {slaveUrl}/api/internal/master/register`
- `PUT {slaveUrl}/api/v1/Availability/admin/status` (reuses existing slave endpoint)
- `GET {slaveUrl}/api/internal/master/registration` (integrity check)
#### IntegrityCheckBackgroundService
- **Lifetime**: Singleton (registered via `services.AddHostedService<IntegrityCheckBackgroundService>()`)
- **Injected Dependencies**: `IServiceScopeFactory`, `IOptions<MasterModuleOptions>`, `ILogger<IntegrityCheckBackgroundService>`
- **Responsibilities**: Periodic background loop; resolves `ICmsInstanceService` via `IServiceScopeFactory` per tick (required because `ICmsInstanceService` is Scoped); runs `VerifyIntegrityAsync()`; interval from `MasterModuleOptions.IntegrityCheckIntervalMinutes`
---
### Slave-Side Services
#### MasterAvailabilityService
- **Interface**: `IMasterAvailabilityService`
- **Lifetime**: Scoped
- **Injected Dependencies**: `AvailabilityDbContext`, `IHttpClientFactory`, `IOptions<MasterModuleOptions>`, `ILogger<MasterAvailabilityService>`
- **Responsibilities**: Checks DB for `MasterRegistration`; if none exists returns `HasMaster = false`; if exists checks cache freshness against `MasterModuleOptions.CacheMinutes`; pulls from Master via HTTP on cache miss; returns cached value on HTTP failure (fail-open, NFR-MASTER-01)
- **Cache fields** (static): `_cachedStatus` (default `Available`), `_cachedDisableMessage`, `_lastFetchedAt`, `_cachedMasterUrl`
- **Exemption**: No special logic needed — a Master CMS instance never calls `RegisterMaster` on itself, so `MasterRegistrations` table is empty → `HasMaster = false` always on a Master instance (FR-MASTER-09)
---
## Orchestration Flows
### Flow 1 — Add Slave CMS
```mermaid
sequenceDiagram
box rgba(99,179,237,0.3) Frontend
participant UI as Browser
end
box rgba(154,230,180,0.3) Master CMS
participant Ctrl as CmsInstanceController
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Slave CMS
participant SlaveEndpoint as AvailabilityController
end
UI->>Ctrl: POST /api/v1/CmsInstances
Ctrl->>Svc: AddAsync(request)
Svc->>Repo: AddAsync(entity)
Repo-->>Svc: entity tracked
Svc->>Repo: SaveChangesAsync()
Svc->>Client: RegisterMasterAsync(slaveUrl, apiKey, masterUrl)
Client->>SlaveEndpoint: POST /api/internal/master/register
SlaveEndpoint-->>Client: 200 OK
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt)
Repo-->>Svc: updated
Svc->>Repo: SaveChangesAsync()
Svc-->>Ctrl: CmsInstanceDto
Ctrl-->>UI: 201 Created
```
Text alternative: Browser posts new slave to master controller → service persists → calls slave registration endpoint → updates LastContactedAt → returns DTO.
---
### Flow 2 — Set Slave Status
```mermaid
sequenceDiagram
box rgba(99,179,237,0.3) Frontend
participant UI as Browser
end
box rgba(154,230,180,0.3) Master CMS
participant Ctrl as CmsInstanceController
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Slave CMS
participant SlaveCtrl as AvailabilityController
end
UI->>Ctrl: PUT /api/v1/CmsInstances/{id}/status
Ctrl->>Svc: UpdateStatusAsync(id, status, disableMessage)
Svc->>Repo: GetByIdAsync(id)
Repo-->>Svc: CmsInstance
Note over Svc: Validate DisableMessage required if NotAvailable
Svc->>Repo: UpdateAsync (Status, DisableMessage)
Svc->>Repo: SaveChangesAsync()
Svc->>Client: PushStatusAsync(url, apiKey, status, disableMessage)
Client->>SlaveCtrl: PUT /api/v1/Availability/admin/status
SlaveCtrl-->>Client: 200 OK
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastStatusPushedAt)
Svc->>Repo: SaveChangesAsync()
Svc-->>Ctrl: void
Ctrl-->>UI: 200 OK
```
Text alternative: Browser sends status update → master validates, persists, pushes to slave endpoint → updates LastStatusPushedAt → returns 200.
---
### Flow 3 — Integrity Check (Background)
```mermaid
sequenceDiagram
box rgba(200,200,200,0.3) Background
participant Timer as PeriodicTimer
participant BgSvc as IntegrityCheckBackgroundService
end
box rgba(154,230,180,0.3) Master CMS
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Slave CMS
participant SlaveEndpoint as Slave API
end
Timer->>BgSvc: Tick (every IntegrityCheckIntervalMinutes)
BgSvc->>Svc: VerifyIntegrityAsync()
Svc->>Repo: GetActiveAsync()
Repo-->>Svc: list of active CmsInstances
loop for each active instance
Svc->>Client: GetRegisteredMasterUrlAsync(slaveUrl, apiKey)
Client->>SlaveEndpoint: GET /api/internal/master/registration
SlaveEndpoint-->>Client: registeredMasterUrl
Client-->>Svc: registeredMasterUrl
alt URL mismatch
Svc->>Client: RegisterMasterAsync(slaveUrl, apiKey, masterUrl)
Client->>SlaveEndpoint: POST /api/internal/master/register
SlaveEndpoint-->>Client: 200 OK
Client-->>Svc: true
end
end
Svc-->>BgSvc: done
```
Text alternative: Background timer triggers integrity service → per active slave checks registered URL → re-registers if mismatch.
---
### Flow 4 — Two-Phase Availability Gate (Slave Middleware)
```mermaid
flowchart TD
Start(["Incoming HTTP Request"])
Bypass{"Bypass prefix?"}
HasMaster{"MasterRegistration\nexists in DB?"}
CacheFresh{"Cache fresh?"}
PullMaster["Pull status from Master\n(HTTP GET)"]
PullOk{"Pull successful?"}
MasterStatus{"Master status?"}
LocalCheck["Existing local gate\nIAvailabilityService.IsAvailableAsync()"]
AdminBypass{"Admin bypass\n(Owner/Admin JWT)?"}
Block503Master["503 Service Unavailable\n+ DisableMessage"]
Block503Local["503 / Maintenance"]
Pass(["Pass request to next middleware"])
Start --> Bypass
Bypass -->|yes| Pass
Bypass -->|no| HasMaster
HasMaster -->|no| LocalCheck
HasMaster -->|yes| CacheFresh
CacheFresh -->|yes| MasterStatus
CacheFresh -->|no| PullMaster
PullMaster --> PullOk
PullOk -->|yes| MasterStatus
PullOk -->|no - use cached| MasterStatus
MasterStatus -->|NotAvailable| Block503Master
MasterStatus -->|Available| LocalCheck
LocalCheck -->|Available| Pass
LocalCheck -->|NotAvailable or Maintenance| AdminBypass
AdminBypass -->|yes| Pass
AdminBypass -->|no| Block503Local
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 block fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Bypass,HasMaster,CacheFresh,PullOk,MasterStatus,AdminBypass decision
class PullMaster,LocalCheck action
class Start terminal
class Pass terminal
class Block503Master,Block503Local block
```
Text alternative: Request enters middleware → check bypass → if master registered check cached/pulled status → if NotAvailable return 503 with DisableMessage → else proceed to existing local availability gate.
---
### Flow 5 — Slave Registration (Master Registers Itself)
```mermaid
sequenceDiagram
box rgba(154,230,180,0.3) Master CMS
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Slave CMS
participant Ctrl as AvailabilityController
participant DB as AvailabilityDbContext
end
Client->>Ctrl: POST /api/internal/master/register\nHeader: X-Master-Api-Key\nBody: {masterUrl}
Ctrl->>Ctrl: Validate API key vs MasterModuleOptions.ApiKey
alt Invalid key
Ctrl-->>Client: 401 Unauthorized
else Valid key
Ctrl->>DB: Upsert MasterRegistration (masterUrl)
DB-->>Ctrl: saved
Ctrl-->>Client: 200 OK
end
```
Text alternative: Master posts registration with API key header → slave validates key → upserts MasterRegistration record → returns 200.
@@ -0,0 +1,103 @@
# Unit of Work Dependencies — Master CMS Module
## Dependency Matrix
| Unit | Depends On | Reason |
|------|-----------|--------|
| Unit 1 — master-backend | `SlpModularCms.Core` only | No unit dependencies; builds the API contract that others consume |
| Unit 2 — slave-availability-extension | Unit 1 (API contract) | Slave endpoints must match what `ISlaveApiClient` calls; registration endpoint schema defined in Unit 1 |
| Unit 3 — frontend-cms-page | Unit 1 (REST API) | Frontend hooks call `/api/v1/CmsInstances`; endpoint shapes must be finalized |
| Unit 4 — documentation | Units 1, 2, 3 | Documents final patterns after all code is complete |
## Implementation Sequence
```mermaid
graph LR
U1["Unit 1\nmaster-backend"]
U2["Unit 2\nslave-availability-extension"]
U3["Unit 3\nfrontend-cms-page"]
U4["Unit 4\ndocumentation"]
U1 --> U2
U1 --> U3
U2 --> U4
U3 --> U4
classDef unit1 fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef unit2 fill:#63b3ed,stroke:#2b6cb0,stroke-width:2px,color:#000
classDef unit3 fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef unit4 fill:#CE93D8,stroke:#6A1B9A,stroke-width:2px,color:#000
class U1 unit1
class U2 unit2
class U3 unit3
class U4 unit4
```
Text alternative: Unit 1 (master-backend) must complete first; Unit 2 and Unit 3 both depend on Unit 1 and can be worked on in parallel after Unit 1 is done; Unit 4 (documentation) depends on all three.
## Package Change Sequence
```mermaid
graph TD
Core["SlpModularCms.Core\n(no changes)"]
MasterMod["SlpModularCms.Modules.Master\nUnit 1 — new project"]
AvailMod["SlpModularCms.Modules.Availability\nUnit 2 — extended"]
Api["SlpModularCms.Api\nregisters Master module"]
Frontend["frontend/\nUnit 3 — /cms page"]
Docs["README.md\nfrontend/README.md\nUnit 4"]
MasterTests["SlpModularCms.Modules.Master.Tests\nUnit 1 — new test project"]
AvailMasterTests["SlpModularCms.Modules.Availability.Master.Tests\nUnit 2 — new test project"]
Core --> MasterMod
Core --> AvailMod
MasterMod --> AvailMod
MasterMod --> Api
AvailMod --> Api
Api --> Frontend
Frontend --> Docs
MasterMod --> MasterTests
AvailMod --> AvailMasterTests
classDef unchanged fill:#B0BEC5,stroke:#546E7A,stroke-width:1px,color:#000
classDef unit1 fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef unit2 fill:#63b3ed,stroke:#2b6cb0,stroke-width:2px,color:#000
classDef unit3 fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef unit4 fill:#CE93D8,stroke:#6A1B9A,stroke-width:2px,color:#000
classDef tests fill:#FC8181,stroke:#C53030,stroke-width:1px,color:#000
class Core,Api unchanged
class MasterMod unit1
class AvailMod unit2
class Frontend unit3
class Docs unit4
class MasterTests,AvailMasterTests tests
```
Text alternative: Core unchanged; Unit 1 (Modules.Master) created first; Unit 2 (Availability extended) and Unit 3 (frontend) build on top; Api shell registers new module; Unit 4 docs come last; two new test projects created.
## Inter-Unit API Contracts
The following interfaces form the boundary between units. These must be finalized during Unit 1 before Unit 2 and Unit 3 can proceed.
| Contract | Defined In | Consumed By |
|----------|-----------|-------------|
| `POST /api/internal/master/register` | Unit 2 (slave exposes it) | Unit 1 (`SlaveApiClient` calls it) |
| `GET /api/internal/master/registration` | Unit 2 (slave exposes it) | Unit 1 (`SlaveApiClient` calls it for integrity check) |
| `PUT /api/v1/Availability/admin/status` | Unit 2 (existing, unchanged) | Unit 1 (`SlaveApiClient` reuses it for status push) |
| `GET /api/v1/CmsInstances` | Unit 1 (master exposes it) | Unit 3 (`useCmsInstances` hook) |
| `POST /api/v1/CmsInstances` | Unit 1 (master exposes it) | Unit 3 (`useAddCmsInstance` hook) |
| `PUT /api/v1/CmsInstances/{id}/status` | Unit 1 (master exposes it) | Unit 3 (`useUpdateCmsInstanceStatus` hook) |
## Parallel Development Opportunities
After Unit 1 is complete and its API contracts are finalized:
- **Unit 2 and Unit 3 can be developed in parallel** — they share no direct dependency on each other; both only depend on Unit 1's REST API contract
- **Unit 4** must wait for all three units to be complete
## Risk Notes
| Risk | Unit | Mitigation |
|------|------|-----------|
| Slave endpoint schema changes after Unit 2 starts | 2 | Finalize `ISlaveApiClient` method signatures in Unit 1 before Unit 2 construction begins |
| Frontend type drift from actual API response shape | 3 | Generate TypeScript types from Unit 1 `CmsInstanceDto` definition; keep in sync during code generation |
| Per-module migration pattern new to codebase | 1, 2 | Apply pattern in Unit 1 first; Unit 2 follows the same pattern |
@@ -0,0 +1,62 @@
# Unit of Work — Requirement Map — Master CMS Module
> No user stories were generated for this feature (skipped — owner-operated, technical feature with clear requirements). This document maps functional requirements and NFRs to units instead.
---
## Functional Requirements → Unit Mapping
| Requirement | Description | Unit |
|-------------|-------------|------|
| FR-MASTER-01 | New `SlpModularCms.Modules.Master` module | **Unit 1** |
| FR-MASTER-02 | `CmsInstance` entity | **Unit 1** |
| FR-MASTER-03 | Auto-registration: Master registers itself with slave | **Unit 1** (client call) + **Unit 2** (slave endpoint) |
| FR-MASTER-04 | Integrity Check background service | **Unit 1** |
| FR-MASTER-05 | Status push: Master sets slave availability | **Unit 1** (client call) + **Unit 2** (slave endpoint reused) |
| FR-MASTER-06 | Slave pull model: periodic master check + cache | **Unit 2** |
| FR-MASTER-07 | Slave fallback behavior (fail-open) | **Unit 2** |
| FR-MASTER-08 | Two-phase availability gate on slave | **Unit 2** |
| FR-MASTER-09 | Master CMS exemption from own gate | **Unit 2** (natural: no registration record on master) |
| FR-MASTER-10 | Owner role access control | **Unit 1** (controller auth) + **Unit 2** (API key auth) |
| FR-MASTER-11 | `/cms` page: slave list | **Unit 3** |
| FR-MASTER-12 | `/cms` page: add slave CMS | **Unit 1** (API) + **Unit 3** (UI) |
| FR-MASTER-13 | `/cms` page: set slave status | **Unit 1** (API) + **Unit 3** (UI) |
| FR-MASTER-14 | Mandatory disable message for NotAvailable | **Unit 1** (service validation) + **Unit 3** (UI validation) |
| FR-MASTER-15 | Project documentation updates | **Unit 4** |
---
## Non-Functional Requirements → Unit Mapping
| Requirement | Description | Unit |
|-------------|-------------|------|
| NFR-MASTER-01 | Fail-open safety | **Unit 2** (`MasterAvailabilityService` fallback) |
| NFR-MASTER-02 | Configurable cache interval (`MasterModule:CacheMinutes`) | **Unit 2** + **Unit 4** (documented in README) |
| NFR-MASTER-03 | API key security (never exposed in responses) | **Unit 1** (`CmsInstanceDto` excludes `ApiKey`) |
| NFR-MASTER-04 | Configurable integrity check interval | **Unit 1** (`IntegrityCheckBackgroundService`) + **Unit 4** (documented) |
| NFR-MASTER-05 | ≥ 80% test coverage on new backend code | **Unit 1** + **Unit 2** (test projects) |
| NFR-MASTER-06 | Per-module database migrations | **Unit 1** (`MasterDbContext`) + **Unit 2** (`AvailabilityDbContext`) |
---
## Unit Coverage Summary
| Unit | FR Coverage | NFR Coverage |
|------|-------------|--------------|
| Unit 1 — master-backend | FR-01, FR-02, FR-03 (client), FR-04, FR-05 (client), FR-10 (controller), FR-12 (API), FR-13 (API), FR-14 (validation) | NFR-03, NFR-04, NFR-05 (partial), NFR-06 (partial) |
| Unit 2 — slave-availability-extension | FR-03 (endpoint), FR-05 (endpoint), FR-06, FR-07, FR-08, FR-09, FR-10 (API key) | NFR-01, NFR-02 (partial), NFR-05 (partial), NFR-06 (partial) |
| Unit 3 — frontend-cms-page | FR-11, FR-12 (UI), FR-13 (UI), FR-14 (UI validation) | — |
| Unit 4 — documentation | FR-15 | NFR-02 (partial), NFR-04 (partial) |
---
## Cross-Unit Requirements
Requirements that span multiple units and require coordination:
| Requirement | Units Involved | Coordination Point |
|-------------|---------------|-------------------|
| FR-MASTER-03 (auto-registration) | 1 + 2 | `ISlaveApiClient.RegisterMasterAsync` contract must match `POST /api/internal/master/register` schema |
| FR-MASTER-05 (status push) | 1 + 2 | `ISlaveApiClient.PushStatusAsync` reuses existing `PUT /api/v1/Availability/admin/status`; no schema change needed |
| FR-MASTER-12/13/14 | 1 + 3 | Frontend DTOs must match `CmsInstanceDto` and `UpdateStatusRequest` from Unit 1 |
| NFR-MASTER-06 (per-module migrations) | 1 + 2 | Both units introduce a new `DbContext`; same pattern applied consistently |
@@ -0,0 +1,124 @@
# Unit of Work — Master CMS Module
## Unit Decomposition Overview
| # | Unit Slug | Project(s) | Test Project | Construction Cycle |
|---|-----------|-----------|--------------|-------------------|
| 1 | `master-backend` | `SlpModularCms.Modules.Master` (NEW) | `SlpModularCms.Modules.Master.Tests` (NEW) | FD → NFR Req → NFR Design → Code Gen |
| 2 | `slave-availability-extension` | `SlpModularCms.Modules.Availability` (EXTENDED) | `SlpModularCms.Modules.Availability.Master.Tests` (NEW) | FD → NFR Req → NFR Design → Code Gen |
| 3 | `frontend-cms-page` | `frontend/` (EXTENDED) | Existing frontend test setup | FD → NFR Req → NFR Design → Code Gen |
| 4 | `documentation` | `README.md`, `frontend/README.md` | N/A | Code Gen only |
---
## Unit 1 — master-backend
**Project**: `src/SlpModularCms.Modules.Master/` (new project added to solution)
**Test Project**: `src/SlpModularCms.Modules.Master.Tests/` (new; parallel to existing `Availability.Tests`)
**Construction Cycle**: Functional Design → NFR Requirements → NFR Design → Code Generation
**Scope**:
- New `SlpModularCms.Modules.Master` class library project
- `MasterModule : IModule` — module registration, service wiring, migration application
- `MasterDbContext` — per-module EF Core DbContext; owns `CmsInstances` table
- `CmsInstance` entity + `CmsInstanceStatus` enum
- `ICmsInstanceRepository` / `CmsInstanceRepository` — data access
- `ICmsInstanceService` / `CmsInstanceService` — business orchestration
- `ISlaveApiClient` / `SlaveApiClient` — typed HTTP client for master → slave calls
- `CmsInstanceController` — REST endpoints (`GET`, `POST`, `PUT /status`) with `[Authorize(Policy = "OwnerOnly")]`
- `IntegrityCheckBackgroundService` — periodic master URL integrity verification
- `MasterModuleOptions` — configuration POCO
- DTOs: `CmsInstanceDto`, `CreateCmsInstanceRequest`, `UpdateStatusRequest`
- EF Core migrations for `CmsInstances` table
**Dependencies**: `SlpModularCms.Core` (for `IModule`, shared types)
**Deliverables**:
- Functional, tested module registered in `SlpModularCms.Api`
- REST API endpoints accessible to Owner role
- Background service running on master CMS startup
- EF Core migration applied at startup
---
## Unit 2 — slave-availability-extension
**Project**: `src/SlpModularCms.Modules.Availability/` (existing project, extended)
**Test Project**: `src/SlpModularCms.Modules.Availability.Master.Tests/` (new; separate from existing `Availability.Tests` to isolate master-related slave changes)
**Construction Cycle**: Functional Design → NFR Requirements → NFR Design → Code Generation
**Scope**:
- `MasterRegistration` entity — stores master URL on slave side
- `AvailabilityDbContext` — new per-module EF Core DbContext in Availability module; owns `MasterRegistrations` table
- `IMasterAvailabilityService` / `MasterAvailabilityService` — pull/cache/fallback service; static field caching
- `AvailabilityMiddleware` (extended) — two-phase gate: Master gate (outer) + existing local gate (inner)
- `AvailabilityController` (extended) — `POST /api/internal/master/register` endpoint added
- `RegisterMasterRequest` request model
- `MasterGateResult` result record
- EF Core migrations for `MasterRegistrations` table
- `GET /api/internal/master/registration` endpoint (read registered master URL, used by integrity check)
**Dependencies**: Unit 1 API contract (endpoint schemas that slave exposes must match what `ISlaveApiClient` calls)
**Deliverables**:
- Two-phase availability gate active on slave CMS instances
- Slave accepts master registration calls with API key validation
- Slave pulls and caches master status with fail-open fallback
- EF Core migration applied at startup
---
## Unit 3 — frontend-cms-page
**Project**: `frontend/` (existing React SPA, extended)
**Test Project**: Existing frontend test setup (no separate test project added)
**Construction Cycle**: Functional Design → NFR Requirements → NFR Design → Code Generation
**Scope**:
- `CmsPage` — page component at route `/cms`; Owner-only guard
- `CmsInstanceList` — table component with status badges; Inactive rows greyed out
- `AddCmsInstanceDialog` — modal form (Name, URL, ApiKey)
- `SetStatusDialog` — modal with status dropdown and conditional DisableMessage field
- `useCmsInstances.ts` — TanStack Query hook: GET `/api/v1/CmsInstances`
- `useAddCmsInstance.ts` — TanStack Query mutation: POST `/api/v1/CmsInstances`
- `useUpdateCmsInstanceStatus.ts` — TanStack Query mutation: PUT `/api/v1/CmsInstances/{id}/status`
- TypeScript types: `CmsInstance`, `CmsInstanceStatus` enum
- Route registration in existing router
**Dependencies**: Unit 1 REST API (endpoint definitions must be finalized before frontend hooks)
**Deliverables**:
- `/cms` page renders list of slave CMSes
- Owner can add a slave and set its status
- Status badge display for all three states; Inactive greyed out
- Mandatory DisableMessage enforced in UI when NotAvailable selected
---
## Unit 4 — documentation
**Files**: `README.md` (root), `frontend/README.md`
**Test Project**: N/A
**Construction Cycle**: Code Generation only (Functional Design, NFR Requirements, NFR Design skipped — documentation-only unit)
**Scope** (per FR-MASTER-15):
- `README.md` — "Database Migraties" section: document per-module migration pattern
- `README.md` — "Nieuwe Module Toevoegen" section: document optional per-module DbContext pattern
- `README.md` — "Productie Setup" section: add `MasterModule__CacheMinutes` and `MasterModule__IntegrityCheckIntervalMinutes` env vars
- `frontend/README.md` — replace boilerplate with project-specific content
**Dependencies**: Units 13 (must be complete so final patterns are known before documenting)
**Deliverables**:
- README accurately reflects per-module migration workflow
- Module guide covers optional DbContext pattern
- Production env vars list is complete
- Frontend README is project-specific and useful
@@ -0,0 +1,154 @@
# Application Design Plan — Master CMS Module
## Overview
This plan covers the high-level component identification and service layer design for the Master CMS Module.
The feature spans four units: `master-backend`, `slave-availability-extension`, `frontend-cms-page`, and `documentation`.
Before generating design artifacts, a set of design questions must be answered below.
---
## Design Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — HTTP Client for Master → Slave Communication
The Master CMS needs to call slave CMS REST endpoints for:
- Auto-registration (FR-MASTER-03)
- Status push (FR-MASTER-05)
- Integrity verification (FR-MASTER-04)
How should the HTTP client be organized in `SlpModularCms.Modules.Master`?
A) **Typed client** — define `ISlaveApiClient` interface + `SlaveApiClient` implementation; registered via `services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()`. Clean, testable, injectable.
B) **Named client** — register a named `HttpClient` ("slave") via `IHttpClientFactory` and inject `IHttpClientFactory` into the service that makes calls. Less abstraction, but familiar .NET pattern.
C) **Direct `HttpClient` injection** — inject `IHttpClientFactory` directly in `CmsInstanceService` and create a client per call. Simplest approach; no separate client abstraction.
D) Other
[Answer]: A
---
### Q2 — Two-Phase Availability Gate: Middleware Strategy
The slave must implement a two-phase gate: Master gate (outer) → Local gate (inner) (FR-MASTER-08).
The existing `AvailabilityMiddleware` implements the local gate.
Which approach should be used to add the Master gate on the slave?
A) **New separate `MasterGateMiddleware`** — registered before the existing `AvailabilityMiddleware` in the pipeline. Clean separation; existing middleware is untouched; Master gate is skipped at registration if no Master URL is stored.
B) **Extend `AvailabilityMiddleware`** — add the Master gate logic at the top of the existing middleware class. Single file; simpler pipeline registration; slightly more coupling between Master and Availability module.
C) Other
[Answer]: B
---
### Q3 — Slave-Side Master Status Caching
The slave must cache the master-pulled availability status (FR-MASTER-06, FR-MASTER-07).
The existing codebase uses a simple `static` field + timestamp in `PersistentAvailabilityService` for circuit breaker caching.
Which caching mechanism should be used for the master status cache on the slave?
A) **Static field with timestamp** (same pattern as existing circuit breaker) — a `static` field in `MasterAvailabilityService` holding the last known status and last-fetched timestamp. Zero dependencies; consistent with existing code style.
B) **`IMemoryCache`** — inject `IMemoryCache` and use a keyed cache entry with a sliding/absolute expiry. Standard .NET caching abstraction; easier to test via mock; slightly more infrastructure.
C) Other
[Answer]: A
---
### Q4 — `CmsInstanceService` Responsibilities
The master-side service needs to handle: CRUD on `CmsInstance`, status push to slave (HTTP), and auto-registration (HTTP). How should these responsibilities be organized?
A) **Single unified `CmsInstanceService`** — one service handles CRUD (EF Core), HTTP status push, and auto-registration. Simple; consistent with the existing single-service pattern (e.g. `PersistentAvailabilityService`).
B) **Split: `CmsInstanceRepository` + `CmsInstanceService`** — repository handles EF Core data access; service handles business orchestration (status push, registration). Cleaner separation; slightly more files.
C) **Split: `CmsInstanceService` (CRUD) + `SlaveStatusService` (HTTP calls)** — data + business logic in one service; all HTTP slave interactions in a dedicated service. Best for unit testing HTTP logic separately.
D) Other
[Answer]: B
---
### Q5 — Master-Side Controller Granularity
The Master module needs REST endpoints for: listing slaves, adding a slave, and setting slave status.
Which controller structure is preferred?
A) **Single `CmsInstanceController`** — all actions in one controller: `GET /api/cms-instances`, `POST /api/cms-instances`, `PUT /api/cms-instances/{id}/status`. Consistent with how `AvailabilityController` works.
B) **Two controllers**`CmsInstanceController` for CRUD (`GET`, `POST`) and `CmsInstanceStatusController` for the `PUT /status` action. Clearer separation of read vs. write-with-side-effect.
C) Other
[Answer]: A
---
### Q6 — Slave-Side Internal Endpoint Placement
The slave needs an internal registration endpoint (`POST /api/internal/master/register`) (FR-MASTER-03).
Where should this endpoint be defined?
A) **New `MasterRegistrationController`** in `SlpModularCms.Modules.Availability` — a dedicated controller for internal master endpoints. Clean; extensible if more internal endpoints are needed.
B) **Added to the existing `AvailabilityController`** — keeps all availability-related endpoints in one file. Simpler; no extra controller class.
C) Other
[Answer]: B
---
### Q7 — Frontend: CMS Page API Hooks Organization
The frontend `/cms` page needs TanStack Query hooks for: listing CMS instances, adding an instance, and updating status.
How should the API hooks be organized?
A) **Single `useCmsInstances` hook file** — one file exports all hooks: `useCmsInstances()`, `useAddCmsInstance()`, `useUpdateCmsInstanceStatus()`. Consistent and simple.
B) **Separate hook files per concern**`useCmsInstances.ts`, `useAddCmsInstance.ts`, `useUpdateCmsInstanceStatus.ts`. More files, but each file is focused.
C) **Follow existing pattern** — check how existing hooks (e.g. availability hooks) are organized and mirror that pattern.
D) Other
[Answer]: B
---
## Execution Steps
After all questions above are answered, the following artifacts will be generated:
- [x] **Step 1** — Analyze all answers; flag any ambiguities for follow-up
- [x] **Step 2** — Generate `components.md` with component definitions and responsibilities
- [x] **Step 3** — Generate `component-methods.md` with method signatures and purpose
- [x] **Step 4** — Generate `services.md` with service definitions and orchestration patterns
- [x] **Step 5** — Generate `component-dependency.md` with dependency matrix and data flow diagrams
- [x] **Step 6** — Generate `application-design.md` consolidating all design artifacts
- [x] **Step 7** — Validate all content (Mermaid diagrams, no ASCII trees, color styles present)
- [x] **Step 8** — Update `aidlc-state.md` to mark Application Design as In Progress → Complete
- [x] **Step 9** — Present completion message for user approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/inception/plans/application-design-plan.md`
@@ -0,0 +1,160 @@
# Execution Plan — Master CMS Module
## Detailed Analysis Summary
### Transformation Scope
- **Transformation Type**: Multi-component addition — new module + slave-side middleware extension + frontend page + documentation
- **Primary Changes**: New `SlpModularCms.Modules.Master` project; extended `SlpModularCms.Modules.Availability`; updated `/cms` frontend page
- **Related Components**: Core (new entity), Api shell (module registration), Availability module (middleware extension), Frontend (CMS page)
### Change Impact Assessment
- **User-facing changes**: Yes — `/cms` page gets a full slave management UI; slave CMS users see a disable message on 503
- **Structural changes**: Yes — new module project, per-module DbContext pattern introduced
- **Data model changes**: Yes — new `CmsInstance` entity (master), new `MasterRegistration` entity (slave)
- **API changes**: Yes — new Master module endpoints; new internal slave registration endpoint; extended 503 response body
- **NFR impact**: Yes — API key security, availability caching strategy, background service, fail-open design
### Component Relationships
**Primary new component**: `SlpModularCms.Modules.Master`
- Depends on: `SlpModularCms.Core` (shared DbContext base, IModule), `SlpModularCms.Api` (module registration)
**Modified component**: `SlpModularCms.Modules.Availability`
- Extended with: master registration endpoint, two-phase availability check, `MasterAvailabilityService`, slave-side `MasterDbContext`
- Depends on: `SlpModularCms.Core`
**Modified component**: `SlpModularCms.Frontend`
- Extended with: CMS page slave management UI, new TanStack Query hooks, new API types
### Risk Assessment
- **Risk Level**: Medium-High
- **Rollback Complexity**: Moderate — new module can be unregistered from Api; slave-side changes are additive; frontend changes are isolated to one route
- **Testing Complexity**: Complex — involves network calls between Master and Slave, background service timing, cache behavior, fallback logic
---
## Workflow Visualization
```mermaid
flowchart TD
Start(["Master CMS Module Request"])
subgraph INCEPTION["🔵 INCEPTION PHASE"]
WD["Workspace Detection\nCOMPLETED"]
RE["Reverse Engineering\nSKIPPED (artifacts exist)"]
RA["Requirements Analysis\nCOMPLETED"]
US["User Stories\nSKIPPED"]
WP["Workflow Planning\nIN PROGRESS"]
AD["Application Design\nEXECUTE"]
UG["Units Generation\nEXECUTE"]
end
subgraph CONSTRUCTION["🟢 CONSTRUCTION PHASE — Per Unit"]
FD["Functional Design\nEXECUTE"]
NFRA["NFR Requirements\nEXECUTE"]
NFRD["NFR Design\nEXECUTE"]
ID["Infrastructure Design\nSKIPPED"]
CG["Code Generation\nEXECUTE"]
BT["Build and Test\nEXECUTE"]
end
subgraph OPERATIONS["🟡 OPERATIONS PHASE"]
OPS["Operations\nPLACEHOLDER"]
end
Start --> WD --> RA --> WP --> AD --> UG
UG --> FD --> NFRA --> NFRD --> CG
ID -.->|skipped| CG
CG -->|repeat per unit| FD
CG --> BT --> OPS --> End(["Complete"])
style WD fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style RA fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style WP fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style CG fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style BT fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
style RE fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray:5 5,color:#000
style US fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray:5 5,color:#000
style ID fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray:5 5,color:#000
style AD fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray:5 5,color:#000
style UG fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray:5 5,color:#000
style FD fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray:5 5,color:#000
style NFRA fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray:5 5,color:#000
style NFRD fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray:5 5,color:#000
style OPS fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray:5 5,color:#000
style Start fill:#CE93D8,stroke:#6A1B9A,stroke-width:3px,color:#000
style End fill:#CE93D8,stroke:#6A1B9A,stroke-width:3px,color:#000
style INCEPTION fill:#BBDEFB,stroke:#1565C0,stroke-width:3px,color:#000
style CONSTRUCTION fill:#C8E6C9,stroke:#2E7D32,stroke-width:3px,color:#000
style OPERATIONS fill:#FFF59D,stroke:#F57F17,stroke-width:3px,color:#000
linkStyle default stroke:#333,stroke-width:2px
```
Text alternative: Inception (WD→RA→WP→AD→UG completed/executing), Construction per-unit loop (FD→NFR Req→NFR Design→CodeGen, Infrastructure skipped), Build & Test, Operations placeholder.
---
## Phases to Execute
### 🔵 INCEPTION PHASE
- [x] Workspace Detection — COMPLETED
- [x] Reverse Engineering — SKIPPED (shared artifacts already exist in `aidlc-docs/_shared/`)
- [x] Requirements Analysis — COMPLETED
- [ ] User Stories — **SKIP**
- *Rationale*: Feature is owner-operated and technical in nature. Requirements are clear and detailed. No multiple personas or acceptance criteria gaps.
- [x] Workflow Planning — IN PROGRESS
- [ ] Application Design — **EXECUTE**
- *Rationale*: New module project, new services, new controller, background service, new frontend components — all need component definition and dependency mapping before code generation.
- [ ] Units Generation — **EXECUTE**
- *Rationale*: 4 distinct units spanning backend (master), backend (slave extension), frontend, and documentation. Sequencing and dependencies must be planned.
### 🟢 CONSTRUCTION PHASE (per unit)
- [ ] Functional Design — **EXECUTE**
- *Rationale*: Complex business logic per unit (registration handshake, two-phase middleware, background integrity check, cache + fallback)
- [ ] NFR Requirements — **EXECUTE**
- *Rationale*: New security concerns (API key handling), caching strategy, fail-open requirements, test coverage targets
- [ ] NFR Design — **EXECUTE**
- *Rationale*: Design patterns for background service, per-module DbContext, middleware extension, client-side caching
- [ ] Infrastructure Design — **SKIP**
- *Rationale*: No new cloud/infrastructure resources. Same deployment model (single .NET process + React SPA). Module registration is code-level, not infrastructure-level.
- [ ] Code Generation — **EXECUTE** (always)
- [ ] Build and Test — **EXECUTE** (always)
### 🟡 OPERATIONS PHASE
- [ ] Operations — PLACEHOLDER
---
## Unit Decomposition (Proposed)
| # | Unit Name | Scope | Depends On |
|---|-----------|-------|------------|
| 1 | master-backend | New `SlpModularCms.Modules.Master` project: `CmsInstance` entity, `MasterDbContext`, migrations, `CmsInstanceService`, `MasterController`, `IntegrityCheckBackgroundService`, `MasterModule : IModule`, test project | Core |
| 2 | slave-availability-extension | Extended `SlpModularCms.Modules.Availability`: `MasterRegistration` entity, slave `MasterDbContext`, migrations, `MasterAvailabilityService` (pull/cache/fallback), registration endpoint, two-phase `AvailabilityMiddleware` | Unit 1 (API contract) |
| 3 | frontend-cms-page | `/cms` page: `CmsInstanceList`, `AddCmsInstanceDialog`, `SetStatusDialog`, new TanStack Query hooks, API types | Unit 1 (REST API) |
| 4 | documentation | Update `README.md` (migrations section, module guide, prod env vars), replace `frontend/README.md` | Units 13 (documents final patterns) |
---
## Package Change Sequence
```
SlpModularCms.Core ← no changes (CmsInstance owned by Modules.Master)
SlpModularCms.Modules.Master [Unit 1] ← new project
SlpModularCms.Modules.Availability [Unit 2] ← extended
SlpModularCms.Api ← registers new Master module
frontend/ [Unit 3] ← CMS page updated
README.md / frontend/README.md [Unit 4] ← documentation
```
---
## Success Criteria
- **Primary Goal**: Owner on the Master CMS can register slave CMSes and toggle their availability; slaves enforce the master-controlled status with a two-phase check
- **Key Deliverables**: `SlpModularCms.Modules.Master` project, extended Availability module, updated `/cms` frontend page, updated documentation
- **Quality Gates**: ≥80% test coverage on new backend code; fail-open behavior verified; API key not exposed in list responses; two-phase middleware verified for all status combinations
@@ -0,0 +1,12 @@
# Language Preference
All documentation artifacts (requirements, designs, plans, code comments, etc.) will be written in **English** by default. Questions, prompts, and AI responses will be in your language.
Would you like to change this?
A) English for documentation, your language for conversation (default)
B) English for everything (documentation and conversation)
C) My language for everything (documentation and conversation)
D) Other (please describe after [Answer]: tag below)
[Answer]: A
@@ -0,0 +1,80 @@
# Unit of Work Plan — Master CMS Module
## Overview
The four units are pre-established from the execution plan and confirmed by the Application Design stage. This plan validates the decomposition and generates the formal unit artifacts.
**Pre-established units:**
| # | Unit | Scope | Depends On |
|---|------|-------|------------|
| 1 | `master-backend` | New `SlpModularCms.Modules.Master` project | Core |
| 2 | `slave-availability-extension` | Extended `SlpModularCms.Modules.Availability` | Unit 1 (API contract + ISlaveApiClient) |
| 3 | `frontend-cms-page` | `/cms` page in `frontend/` | Unit 1 (REST API endpoints) |
| 4 | `documentation` | README updates | Units 13 (documents final patterns) |
---
## Decomposition Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — Construction Cycle for Unit 4 (Documentation)
Unit 4 covers README.md updates — no new code, entities, or services. How should it be handled in the Construction phase?
A) **Full cycle** — run Functional Design, NFR Requirements, NFR Design, and Code Generation for Unit 4 as for the other units. Consistent process; documentation gets explicit design attention.
B) **Code Generation only** — skip Functional Design, NFR Requirements, and NFR Design for Unit 4; go straight to Code Generation (which in this case means drafting the README content). More efficient for a documentation-only unit.
C) Other
[Answer]: B
---
### Q2 — Test Project for Unit 1
Unit 1 adds `SlpModularCms.Modules.Master` — a new project. How should tests be organized?
A) **New `SlpModularCms.Modules.Master.Tests` project** — separate test project for the new module, parallel to the existing `SlpModularCms.Modules.Availability.Tests`. Clean isolation; follows existing pattern.
B) **Single shared test project** — add master module tests to an existing test project to avoid creating a new project.
C) Other
[Answer]: A
---
### Q3 — Test Project for Unit 2
Unit 2 extends `SlpModularCms.Modules.Availability`. How should new slave-side tests be organized?
A) **Extend existing `SlpModularCms.Modules.Availability.Tests`** — add new test files for `MasterAvailabilityService`, extended `AvailabilityMiddleware`, and `AvailabilityController` registration endpoint. Minimal new project overhead.
B) **New `SlpModularCms.Modules.Availability.Master.Tests`** — separate test project for the master-related slave-side extensions. Cleaner isolation for cross-unit changes.
C) Other
[Answer]: B
---
## Execution Steps
After all questions above are answered, the following artifacts will be generated:
- [x] **Step 1** — Analyze all answers; flag any ambiguities
- [x] **Step 2** — Generate `unit-of-work.md` with unit definitions, responsibilities, and construction cycle per unit
- [x] **Step 3** — Generate `unit-of-work-dependency.md` with dependency matrix and sequencing
- [x] **Step 4** — Generate `unit-of-work-story-map.md` (requirement-to-unit mapping; no user stories in this feature)
- [x] **Step 5** — Validate unit boundaries and completeness
- [x] **Step 6** — Update `aidlc-state.md` to mark Units Generation as complete
- [x] **Step 7** — Present completion message for user approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/inception/plans/unit-of-work-plan.md`
@@ -0,0 +1,71 @@
# Requirements Clarification Questions — Master CMS Module (Round 2)
Your answers were very clear on the overall model. A few follow-up questions to resolve remaining ambiguities before generating the requirements document.
---
## Clarification 1 — How does the slave CMS know the Master's URL?
Your Q4 answer says the client cannot configure this. But the slave still needs to know where to pull availability status from.
How is the Master URL configured on the slave CMS?
A) Developer sets it in the slave's `appsettings.json` at deployment time (e.g., `MasterModule:MasterUrl`) — clients see the config file but cannot change it via the UI
B) Environment variable only — completely invisible to the client in normal deployments
C) The Master registers itself with the slave at first connection — no manual config needed on slave side
D) Other (please describe after [Answer]: tag below)
[Answer]: C, but I want something to prevent the client from changing the url and this the slave being unable to contact the master. So the master should be able to check whether everything is still stored correctly. We might need an extra service that runs as a cronjob or something similar.
---
## Clarification 2 — Authentication between Master and Slave
When the Master calls the slave's `PUT /api/availability/status` to set its availability, how does it authenticate?
A) The API key stored in the Master's `CmsInstance` record is sent as a header — the slave validates it as a special "master key"
B) The Master uses a JWT token from the slave (Owner account credentials stored in the Master's DB)
C) The slave exposes a separate unauthenticated (but secret-URL-protected) internal endpoint for this
D) A shared API key configured in both Master and slave `appsettings.json`
E) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Clarification 3 — Slave Availability Cache Duration
Your Q3 answer mentions the slave caches the Master's availability status to reduce traffic. How long should this cache be valid?
A) Configurable — set in the slave's `appsettings.json` (e.g., `MasterModule:CacheMinutes: 60`)
B) Fixed at a reasonable default (e.g., 60 minutes) — no configuration needed
C) Session-based — re-check when the application restarts or a specific event occurs
D) Other (please describe after [Answer]: tag below)
[Answer]: A, but B as a fallback.
---
## Clarification 4 — Slave's Fallback Behavior When Master is Unreachable
Your Q3 answer says: if the Master is unavailable, the slave falls back to its own DB value. What should the default value in the slave DB be before the Master has ever contacted the slave?
A) `Available` — default to open; the Master will disable it if needed
B) `NotAvailable` — default to closed; the Master must explicitly enable it after registration
C) Configurable per slave registration in the Master's `CmsInstance` record
D) Other (please describe after [Answer]: tag below)
[Answer]: A, this whole feature is a safety measure, but it should not block the client from doing anything when something doesn't work correctly.
---
## Clarification 5 — Master UI: Adding / Removing Slave CMSes
On the `/cms` page the Owner manages slave CMS registrations. What actions should be available?
A) Add (name + URL + API key), view list with current status, toggle Available/NotAvailable, remove
B) Add (name + URL + API key), view list with status — no remove (registrations are permanent)
C) Full CRUD: add, edit (name/URL/key), view list with status, toggle, remove
D) Other (please describe after [Answer]: tag below)
[Answer]: B, but also option to set status Available/NotAvailable/Inactive where Inactive makes it greyed out meaning the CMS is no longer used. Also the option to set a message when the master disables a slave. That should be mandatory.
@@ -0,0 +1,125 @@
# Requirements Clarification Questions — Master CMS Module
Please answer each question by filling in the letter choice after the `[Answer]:` tag.
If none of the options match your needs, choose the last option (Other) and describe your preference.
---
## Question 1
The CMS page (`/cms`) currently exists in the frontend but shows no content. What should the Master Module display on this page?
A) A list of registered slave CMS instances with their current availability status and controls to enable/disable them
B) A dashboard combining both slave CMS management and module configuration (e.g., toggle Master Module on/off)
C) Only module configuration — the slave CMS list is managed elsewhere (e.g., a separate admin API)
D) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Question 2
What are "other CMSes" in this context? How should slave CMS instances be registered with the Master?
A) Other deployed instances of the same SlpModularCms application — registered via URL + API key in the Master's database
B) Abstract "tenants" or "sites" stored in the Master's database — not necessarily running SlpModularCms
C) The slave CMSes share the same database as the Master — just different data rows (multi-tenant, single DB)
D) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Question 3
When the Master Module sets a slave CMS as "unavailable", how does the slave CMS enforce this?
A) The slave CMS calls the Master's API on every request to check if it is still available (pull model)
B) The Master pushes availability status to the slave CMS (webhook / push model)
C) The slave CMS and Master share a database — the slave reads the Master-controlled status directly from a shared table
D) The Master sets availability via the slave's own availability API endpoint (the existing `PUT /api/availability/status`)
E) Other (please describe after [Answer]: tag below)
[Answer]: E, probably a combination of A and D. For context: As a developer I have clients using the slave CMSes. In the event the client doesn't pay or violate another agreement I want to have the possibility to disable the slave CMS. To prevent the client from circumventing this by editing a value in their own database I want it to pull it from the master. But in the event the master is unavailable it is also stored in their own DB. The slave CMS can also just check their own value to prevent excessive traffic to the master and just get the status only a few times or once per session within a few hours for example.
---
## Question 4
The existing availability middleware currently checks a local `IAvailabilityService`. How should the middleware on a slave CMS know to consult the Master instead of its local service?
A) A new configuration flag in `appsettings.json` (e.g., `MasterModule:MasterUrl`) — if set, the slave uses the Master; if not set, local check
B) The Master Module is installed on the slave CMS too but in "slave mode" — it overrides the local availability service
C) A separate middleware or service replaces the existing one when a Master URL is configured
D) Other (please describe after [Answer]: tag below)
[Answer]: D, The availability check should be hardcoded to check the master if the master module is enabled, but it should also keep sits current functionality. The master module should be enabled by the owner of the master CMS and not by the client. The client should not be able to configure this.
---
## Question 5
The CMS that has the Master Module enabled should be exempt from the external availability check. How should this exemption be implemented?
A) Configuration-based: a flag in `appsettings.json` (e.g., `MasterModule:IsMaster: true`) bypasses the external check entirely
B) Auto-detected: if the Master Module is registered and active, skip the external check automatically
C) The Master CMS still has a local availability check (its own `IAvailabilityService`) but ignores external Master checks
D) Other (please describe after [Answer]: tag below)
[Answer]: B + C
---
## Question 6
Which roles can use the Master Module features (viewing/managing slave CMSes)?
A) Owner only
B) Owner and Administrator
C) All authenticated users can view; only Owner can modify
D) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Question 7
Does the Master Module require changes to the existing availability middleware (`AvailabilityMiddleware`) in the `Availability` module, or should it be implemented as a new separate module?
A) Extend the existing Availability module — add Master Module logic there
B) Create a new separate module `SlpModularCms.Modules.Master` that works alongside the Availability module
C) Create a new module that REPLACES the Availability module on Master CMS instances
D) Other (please describe after [Answer]: tag below)
[Answer]: B, it should be a separate module, because only the master CMS should install the module. The slaves should not have the module installed and should still be able to use the current functionality.
---
## Question 8
Should the Master Module include a new database entity to store slave CMS registrations (name, URL, current availability status), or reuse an existing entity?
A) Yes — new `CmsInstance` entity in the database (name, URL, status, last updated)
B) New entity but only in-memory / configuration — no database persistence for slave CMSes
C) Reuse the existing `GlobalAvailabilityState` concept, extended with a multi-tenant key
D) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Question 9
For the frontend: the `/cms` route is currently restricted to the `Owner` role. Should this change?
A) No change — keep `/cms` Owner-only
B) Allow `Administrator` role as well (read-only or full access)
C) Allow any authenticated user to view, but restrict modifications to Owner
D) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Question 10
Should the Master Module be part of this same codebase (monorepo), or is it a separate deployment concern?
A) Same codebase — a new project `SlpModularCms.Modules.Master` within the existing solution
B) Same codebase AND the frontend changes to show the Master UI are included in this feature
C) Backend only — frontend changes are a separate follow-up feature
D) Other (please describe after [Answer]: tag below)
[Answer]: B
@@ -0,0 +1,169 @@
# Requirements — Master CMS Module
## Intent Analysis
- **User Request**: Add a new "Master" module that fills the `/cms` page, allows the owner to manage the availability of registered slave CMS instances, and integrates with the existing availability check mechanism — while exempting the Master CMS itself from external checks.
- **Request Type**: New Feature
- **Scope Estimate**: Multiple Components — new backend module, new frontend UI, modifications to the existing availability flow on slave CMS instances
- **Complexity Estimate**: Complex — involves a Master/Slave network topology, bi-directional registration, background integrity checks, caching, fallback logic, and API key authentication
---
## System Context
The existing system (`SlpModularCms`) is a modular ASP.NET Core CMS. The Availability module manages system status via `IAvailabilityService` and `AvailabilityMiddleware`. Slave CMSes are other deployed instances of the same SlpModularCms application. The Master Module adds a control plane layer on top of the existing availability system.
---
## Functional Requirements
### FR-MASTER-01 — New Module: `SlpModularCms.Modules.Master`
A new separate module `SlpModularCms.Modules.Master` is created within the existing solution. This module is installed **only on the Master CMS**. Slave CMS instances do **not** install this module. The module registers its own services and HTTP endpoints following the existing `IModule` pattern.
### FR-MASTER-02 — `CmsInstance` Entity
A new `CmsInstance` database entity is added to `SlpModularCms.Core` with the following fields:
- `Id` (Guid, PK)
- `Name` (string, required) — friendly name for the slave CMS
- `Url` (string, required) — base URL of the slave CMS API
- `ApiKey` (string, required) — secret key used by the Master to authenticate against the slave
- `Status` (enum: `Available` / `NotAvailable` / `Inactive`) — the master-controlled availability state
- `DisableMessage` (string, nullable) — required when `Status = NotAvailable`; shown to end-users of the slave
- `LastContactedAt` (DateTimeOffset, nullable) — timestamp of the last successful master → slave contact
- `LastStatusPushedAt` (DateTimeOffset, nullable) — timestamp of the last status push to the slave
### FR-MASTER-03 — Auto-Registration: Master Registers Itself with Slave
When a slave is added to the Master's `CmsInstance` registry, the Master automatically contacts the slave and registers itself (pushes its own URL). The slave stores the master's URL in its local database. No manual configuration is required on the slave side to know the Master URL.
The slave exposes a dedicated internal registration endpoint (`POST /api/internal/master/register`) that accepts the Master's URL and API key. After successful registration, the slave uses the stored Master URL for all future availability pulls.
### FR-MASTER-04 — Integrity Check Service (Background Service)
A background service (periodic, configurable interval) runs on the Master CMS and periodically re-verifies that each registered slave still has the correct Master URL stored. If a slave's registered master does not match, the Master re-pushes its registration to that slave. This prevents clients from circumventing the master by removing or altering the stored master URL.
### FR-MASTER-05 — Status Push: Master Sets Slave Availability
When the Master owner changes a slave's status (to `Available` or `NotAvailable`), the Master immediately calls the slave's existing `PUT /api/availability/status` endpoint (or a dedicated internal endpoint) to push the new status. Authentication uses the `ApiKey` from the `CmsInstance` record, sent as an `X-Master-Api-Key` header.
### FR-MASTER-06 — Slave Pull Model: Periodic Master Check
The slave CMS periodically pulls its availability status from the Master. The pull interval is configurable in the slave's `appsettings.json` (`MasterModule:CacheMinutes`). If this value is not configured, a default of **60 minutes** is used. The pulled status is cached locally in memory.
### FR-MASTER-07 — Slave Fallback Behavior
If the Master CMS is unreachable when the slave attempts a pull:
- The slave falls back to the value currently stored in its **local database** (`GlobalAvailabilityState`).
- The local database value defaults to `Available` before any Master contact has occurred.
- This ensures the feature is a **fail-open** safety measure — clients are not blocked if the Master is down.
### FR-MASTER-08 — Slave Availability Middleware Integration
On a slave CMS, the availability check becomes a **two-phase gate**. The existing `IAvailabilityService` behavior is preserved — the Master adds an additional outer gate, not a replacement.
**Check order:**
1. **Master gate** (outer): If a Master URL is registered in the slave's DB, check the Master-sourced cached status (refreshed per FR-MASTER-06).
- If Master status = `NotAvailable` → block all requests. Only the frontend dashboard route is accessible so that users can see the availability widget with the `DisableMessage`.
- If Master is unreachable → fall back to locally stored Master status (last known value; default `Available`).
- If no Master registered → skip Master gate entirely.
2. **Local gate** (inner): If the Master gate passes (status = `Available` or no Master registered), apply the existing `IAvailabilityService` check as it works today (local `Available` / `Maintenance` / `NotAvailable` logic, Owner/Admin bypass, etc.).
This means: when the Master sets a slave to `Available`, all existing local availability behavior continues unchanged. When the Master sets `NotAvailable`, the local gate is never reached.
The `DisableMessage` from the Master is included in the `503 Service Unavailable` response body and delivered to the slave's frontend availability widget.
### FR-MASTER-09 — Master CMS Exemption
When the Master Module is installed and active on a CMS instance, that CMS is **automatically exempt** from the external Master availability check. It does not pull status from any Master. The Master CMS retains and uses its own local `IAvailabilityService` check (existing behavior unchanged).
### FR-MASTER-10 — Role Access Control
All Master Module management features (viewing and modifying slave CMS registrations) are restricted to the **Owner** role only. This applies to both the backend API endpoints and the frontend `/cms` page.
### FR-MASTER-11 — `/cms` Page: Slave CMS List
The frontend `/cms` page displays a list of all registered slave CMSes. For each slave, the following is shown:
- Name
- URL
- Current status badge (`Available` / `NotAvailable` / `Inactive`)
- Last contacted timestamp
- Disable message (if status is `NotAvailable`)
`Inactive` entries are visually greyed out to indicate they are no longer in active use.
### FR-MASTER-12 — `/cms` Page: Add Slave CMS
The Owner can add a new slave CMS registration by providing:
- Name (required)
- URL (required)
- API key (required)
On save, the Master immediately attempts to register itself with the slave (FR-MASTER-03) and stores the result.
### FR-MASTER-13 — `/cms` Page: Set Slave Status
The Owner can change the status of a registered slave CMS to:
- `Available` — slave is enabled (normal operation)
- `NotAvailable` — slave is disabled; a **mandatory disable message** must be provided
- `Inactive` — slave is greyed out in the UI; no availability enforcement is applied (the Master does not contact the slave)
Slave registrations cannot be deleted; setting to `Inactive` is the "soft removal" mechanism.
### FR-MASTER-14 — Mandatory Disable Message
When the Owner sets a slave's status to `NotAvailable`, a non-empty `DisableMessage` is required. This message is pushed to the slave together with the status change and included in the slave's 503 error response to end-users.
### FR-MASTER-15 — Project Documentation
The root `README.md` already exists and covers project structure, dev setup, authentication, frontend development, database migrations, adding a module, and production setup. The following sections need to be updated or added to reflect this feature.
**Updated: `README.md` — "Database Migraties" section**
The current section only covers migrations in `SlpModularCms.Core`. It must be updated to document the per-module migration pattern (NFR-MASTER-06):
- How to add a migration for a specific module (using `--project src\SlpModularCms.Modules.<Name>`)
- How to apply module-specific migrations
- Note that each module owns its own tables and migrations
**Updated: `README.md` — "Nieuwe Module Toevoegen" section**
Step 3 (`IModule implementeren`) must be extended to document the optional per-module `DbContext` pattern introduced by the Master Module:
- How to add a module-specific `DbContext`
- How to register it at startup via `RegisterServices`
- How to apply its migrations in `UseModule`
**Updated: `README.md` — "Productie Setup" section**
The environment variables list must be extended with the new `MasterModule:` keys:
- `MasterModule__CacheMinutes` (slave instances only)
- `MasterModule__IntegrityCheckIntervalMinutes` (master instance only)
**Updated: `frontend/README.md`**
Currently contains Vite template boilerplate. Replace with project-specific frontend developer documentation (prerequisites, scripts, environment variables). The root README already covers most of this — the frontend README can be a brief pointer to the root README plus frontend-specific notes.
---
## Non-Functional Requirements
### NFR-MASTER-01 — Fail-Open Safety
The entire Master Module is designed as a safety measure, not a blocker. If any part of the Master → Slave communication fails (network error, timeout, misconfiguration), the slave must continue serving requests using its local fallback. End-users must never be blocked solely because the Master is unreachable.
### NFR-MASTER-02 — Configurable Cache Interval
The slave pull interval is configurable via `appsettings.json` (`MasterModule:CacheMinutes`). Default: 60 minutes. This follows the existing `dotnet-appsettings` pattern used in this project.
### NFR-MASTER-03 — API Key Security
The `ApiKey` stored in `CmsInstance` is a secret token used for Master → Slave authentication. It must not be exposed in API list responses. The slave validates the `X-Master-Api-Key` header on all Master-initiated requests.
### NFR-MASTER-04 — Background Service Interval
The Master's integrity check background service interval is configurable in Master CMS `appsettings.json` (`MasterModule:IntegrityCheckIntervalMinutes`). Default: 60 minutes.
### NFR-MASTER-05 — Test Coverage
New backend code must follow the existing test coverage standard (≥ 80%). New services and controllers in `SlpModularCms.Modules.Master` and any slave-side extensions require unit tests.
### NFR-MASTER-06 — Per-Module Database Migrations
Each module manages its own database schema through a module-specific EF Core `DbContext` and a dedicated migrations assembly within that module's project. This ensures that tables belonging to a module are only created on CMS instances where that module is installed:
- `SlpModularCms.Modules.Master` owns the `CmsInstances` table → migrations live in `Modules.Master`
- Slave-side tables (e.g., stored Master registration URL) belong to the module or service that introduces them → migrations live in the corresponding project
- `SlpModularCms.Core` retains only the shared/core entities (users, roles, tokens, `GlobalAvailabilityState`)
- Each module's `Configure(IApplicationBuilder)` method applies its own pending EF Core migrations at startup
- A migration is created for every discrete schema change (one migration per logical change, not batched)
---
## Scope Boundaries
| In Scope | Out of Scope |
|----------|-------------|
| New `SlpModularCms.Modules.Master` project | Slave module as a separate installable package |
| Frontend `/cms` page with slave management UI | Multi-level master hierarchy (master of masters) |
| Slave-side auto-registration endpoint | Real-time push notifications to slave (WebSockets/SignalR) |
| Slave-side availability pull + cache + fallback | Authentication delegation (SSO between master and slave) |
| Master integrity check background service | Slave removal / permanent delete |
| `CmsInstance` entity in `SlpModularCms.Modules.Master` (own DbContext + migrations) | |
| Frontend changes included in this feature | |
| Updated `README.md` (migrations + module guide + prod env vars) | Full README rewrite |
| Updated `frontend/README.md` (project-specific content) | |
@@ -0,0 +1,171 @@
# Gap Report: Functional Design Questions Placed in Chat Without [Answer] Tags
**Gap ID**: gap-006
**Reported**: 2026-06-29
**Reporter**: User (via master-cms-module Unit 3 Functional Design session)
**Skill affected**: `aidlc-workflow`
**Rule files affected**:
- `.aidlc-rule-details/construction/functional-design.md` — Step 3, Step 4, Step 5
- `.aidlc-rule-details/common/question-format-guide.md` — "Never Ask Content Questions in Chat"
---
## Problem Description
During Unit 3 (`frontend-cms-page`) Functional Design, the agent violated two mandatory rules simultaneously:
### Violation 1 — Content questions placed in chat
The agent repeated all 5 clarification questions **directly in the chat message** at the end of its response. This violates `question-format-guide.md`:
> **CRITICAL**: You must NEVER ask **content/domain questions** directly in the chat. These MUST be placed in dedicated question files using the `[Answer]:` tag format.
### Violation 2 — Plan file had no `[Answer]:` tags and no `Other` option
The agent created `frontend-cms-page-functional-design-plan.md` with questions in a `## Clarifying Questions` section, but:
- Every question was missing the mandatory `[Answer]:` tag
- Every question was missing the mandatory `Other` option as the last choice
- The file was labeled a "plan" but acted as a question file without meeting question file requirements
From `functional-design.md` Step 3 Definition of Done:
> - [ ] Every question has a `[Answer]:` tag
> - [ ] The final option is `Other`
From `question-format-guide.md`:
> `Other` is MANDATORY as the LAST option for every question
### Violation 3 — Chat notification message was too long and repeated question content
`functional-design.md` Step 5 prescribes this exact short format:
```markdown
# 📝 Functional Design Plan Ready - [unit-name]
I've created a functional design plan with questions at:
`aidlc-docs/features/{feature-slug}/construction/plans/[unit-name]-functional-design-plan.md`
Please answer each question by filling in your choice after the `[Answer]:` tag in the file.
Let me know in chat when you're done.
```
Instead, the agent produced a long chat message with headers, all 5 questions restated in full with options (Q1Q5), and no `[Answer]:` pointers — making it indistinguishable from a content interaction rather than a process notification.
---
## Observed Behavior
The agent:
1. Created `frontend-cms-page-functional-design-plan.md` with questions missing `[Answer]:` tags and `Other` options
2. Posted a lengthy chat message restating all 5 questions with options (A/B/C) directly in chat
3. Did NOT produce a separate `*-questions.md` file following the question-format-guide format
The chat message looked like other phases' content discussions rather than a process notification pointing to a file.
---
## Root Cause
Two likely causes:
**A) The plan file format ambiguity**: The construction plan file (used to track steps) and the question file (used to collect answers) were conflated into one file. The agent embedded questions in the plan instead of creating a separate dedicated question file (e.g. `frontend-cms-page-fd-questions.md`).
**B) Step 5 format not enforced strongly enough**: `functional-design.md` Step 5 says "Present a brief chat message" but does not use strong directive language (`CRITICAL`, `MANDATORY`) to prohibit reproducing question content in chat. The agent defaulted to showing the questions for "helpfulness."
---
## Impact
- User receives questions in chat instead of in a file → cannot fill in `[Answer]:` tags
- No audit trail of answers in the file system
- Chat message looks inconsistent with other phases (FD questions in previous units had the correct format)
- User is confused about where and how to answer the questions
---
## Suggested Fix
### Fix 1 — Separate plan file from question file
In `functional-design.md` Step 34, make explicit that:
- The **plan file** (`{unit-name}-functional-design-plan.md`) contains only step checkboxes
- A **separate question file** (`{unit-name}-fd-questions.md`) is always created alongside it, following `question-format-guide.md` exactly
- The plan file MUST NOT contain `[Answer]:` questions inline
**Current Step 4 (ambiguous)**:
```markdown
### Step 4: Store Plan
- Save as `aidlc-docs/features/{feature-slug}/construction/plans/{unit-name}-functional-design-plan.md`
- Include all [Answer]: tags for user input
```
**Proposed Step 4 (explicit)**:
```markdown
### Step 4: Store Artifacts
- Save plan as `aidlc-docs/features/{feature-slug}/construction/plans/{unit-name}-functional-design-plan.md`
- Contains ONLY the step checklist — NO questions or [Answer] tags
- Save questions as `aidlc-docs/features/{feature-slug}/construction/plans/{unit-name}-fd-questions.md`
- Follows `common/question-format-guide.md` exactly
- MANDATORY: Every question has `[Answer]:` tag and `Other` as final option
- Validate against question-format-guide.md Definition of Done before saving
```
### Fix 2 — Add CRITICAL language to Step 5
**Current Step 5**:
```markdown
### Step 5: Notify User (in chat)
Present a brief chat message pointing the user to the plan file:
```
**Proposed Step 5**:
```markdown
### Step 5: Notify User (in chat)
**CRITICAL**: Present ONLY this short process notification. Do NOT reproduce questions in chat. Do NOT add extra explanation.
```
Then keep the existing template as-is, but add:
```markdown
**PROHIBITED**: Repeating question content in the chat notification. The questions are in the file — do not summarize or restate them here.
```
### Fix 3 — Add self-validation step after question file creation
Add a checkpoint between Step 3 and Step 4:
```markdown
### Step 3b: Validate Question File
Before saving, verify the question file against `question-format-guide.md` Definition of Done:
- [ ] Every question is multiple choice with lettered options (A, B, C…)
- [ ] Every question has `Other` as the final option
- [ ] Every question has a `[Answer]:` tag immediately after the options
- [ ] No question appears in the plan file or chat
If any check fails: fix the file before proceeding to Step 4.
```
---
## Related Files
- Skill: `C:\Users\Bryan\.claude\skills\aidlc-workflow-workspace\skill-snapshot\`
- Rule: `.aidlc-rule-details/construction/functional-design.md` — Steps 3, 4, 5
- Rule: `.aidlc-rule-details/common/question-format-guide.md` — "Never Ask Content Questions in Chat", "Definition of Done"
---
## Immediate Workaround (current session)
The agent must now:
1. **Delete** the plan file's embedded questions section (or rewrite it as a proper question file)
2. **Create** `aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-fd-questions.md` with all 5 questions in correct format (multiple-choice + `Other` + `[Answer]:` tag)
3. **Send** only the prescribed short Step 5 notification in chat
---
## Acceptance Criteria for Fix
- [ ] `functional-design.md` Step 4 distinguishes plan file (checklist only) from question file (answers)
- [ ] `functional-design.md` Step 5 has CRITICAL/PROHIBITED language against reproducing questions in chat
- [ ] A Step 3b self-validation checkpoint is added before saving question files
- [ ] After the fix, FD question files always contain `[Answer]:` tags and `Other` options
- [ ] After the fix, chat notification for FD is always the short 4-line format from the template