# Units of Work — Gitea Deployment Workflow **Date**: 2026-07-27 **Decomposition basis**: Q1 = C (split the oversized unit), Q2 = B (merge the quality-gate fixes into the CI unit), Q3 = B (split into Hosting & Serving plus Data Durability) --- ## Decomposition Outcome The proposed 7-unit split changed in two ways that cancel out numerically: - **Unit 2 was split in two** (Q3 = B) — it had bundled three different kinds of work whose only commonality was the deadline "before the first deploy" - **The quality-gate prerequisites were merged into the CI unit** (Q2 = B) — the lint fixes and package pins exist *because* the gates are being switched on, so they land in the same commit as the gates Net result: **still 7 units**, but with boundaries drawn along the work rather than along the deadline. | # | Unit | Type | |---|---|---| | U1 | Hosting & Serving | Application | | U2 | Data Durability | Application | | U3 | HTTP Security Headers & CSP | Application | | U4 | Observability Integration | Application + Frontend | | U5 | CI Workflow & Quality Gates | Pipeline | | U6 | Deploy Workflow | Pipeline | | U7 | Repository Documentation | Documentation | --- ## Execution Rounds (Q4 = B) Serial where dependent, grouped where independent. **One commit per unit** regardless of grouping (Q6 = A); a round is an approval boundary, not a commit boundary. | Round | Units | Why grouped | |---|---|---| | **R1** | U1 + U2 | Mutually independent — one changes serving and middleware, the other changes persistence and startup. Neither reads the other's output | | **R2** | U3 + U4 | Tightly coupled — U3's CSP configuration is populated with the Umami and Sentry origins that U4 introduces. Splitting them means writing a CSP against origins that do not exist yet | | **R3** | U5 + U6 | U6 is invoked by U5; the two workflow files are designed against one shared input interface | | **R4** | U7 | Depends on U6's settled host layout | Everything in R1 and R2 must land before R3's deploy workflow can safely run — the reason the durability work is not left until later. --- ## U1 — Hosting & Serving **Purpose**: make one process serve two independent front-ends correctly, and expose infrastructure liveness that the CMS's own on/off state cannot mask. **Scope**: - Remount static files: `wwwroot/web/` at `/`, `wwwroot/admin/` at `/admin`, each with its own `PhysicalFileProvider` (Q3 of Application Design = A) - Retarget both SPA fallbacks, preserving the `nonfile` constraint so missing assets still `404` - Tolerate a **missing `wwwroot/web/` at startup** — a fresh deployment has none until a website workspace deploys into it, and the CMS must still start and serve `/admin` and `/api/v1` - Add `AddCmsHealthChecks()` / `MapCmsHealthChecks()` exposing `GET /health`, liveness only, no database call - Add `/health` to `AvailabilityMiddleware._bypassPrefixes` - **Fix `IsAdminBypass`** to stop trusting an unvalidated token (FR-24) **Components**: C-04, C-10, C-13, and the U1 portion of C-16 **Requirements**: FR-07, FR-10, FR-24 **Projects touched**: `SlpModularCms.Core`, `SlpModularCms.Api`, `SlpModularCms.Api.Slave`, `SlpModularCms.Modules.Availability` **Carried-in design item**: § 5.2 of `application-design.md` — `AvailabilityMiddleware` runs *before* `UseAuthentication()`, so `HttpContext.User` is unpopulated when the admin bypass is evaluated. Functional Design for this unit decides between validating the token in the middleware (contained, duplicates validation parameters) and moving authentication earlier (smaller change, wider blast radius). **Definition of done** (Q5 = B): builds; all existing tests pass; new tests for `/health` reachability including while the instance is disabled, for the two static mounts' fallback precedence, for graceful startup without `wwwroot/web/`, and for the admin bypass rejecting a forged token while still accepting a valid one. --- ## U2 — Data Durability **Purpose**: make a redeploy safe. Nothing this unit delivers is visible in normal operation; its entire value is that the atomic release switch in U6 does not silently destroy trust or schema state. **Scope**: - `ApplicationDbContext` implements `IDataProtectionKeyContext` with a `DataProtectionKeys` set; one new Core migration - `AddCmsDataProtection()` configuring `PersistKeysToDbContext` - **Set an explicit application discriminator** — the default derives from the content root path, which changes on every atomic release switch, defeating the purpose by a different route - **Remove `services.AddDataProtection()` from `AvailabilityModule` and `MasterModule`** — module registration runs after the host's, so those calls would override the persistent key store - `MigrateCoreDatabase()` applying `ApplicationDbContext` migrations at startup, **fail fast** on failure (Q8 of Application Design = A) **Components**: C-05, C-06, C-07, and the U2 portion of C-16 **Requirements**: FR-11, FR-12 **Projects touched**: `SlpModularCms.Core`, `SlpModularCms.Modules.Availability`, `SlpModularCms.Modules.Master`, both hosts **Carried-in design item**: § 5.1 of `application-design.md` — the duplicate `AddDataProtection()` conflict. This is the unit's highest-value test: without it, FR-12 passes registration tests while remaining ephemeral, and the failure only surfaces later as an apparent network fault between master and slave. **Definition of done** (Q5 = B): builds; all existing tests pass; new tests asserting that the persistent key store **survives module registration**, that the application discriminator is explicit and stable, and that a protected value round-trips across a simulated content-root change. Both hosts start successfully. --- ## U3 — HTTP Security Headers & CSP **Purpose**: supply, from inside the application, the headers that would normally come from nginx or IIS configuration — which NFR-01 forbids relying on. **Scope**: - `SecurityHeadersMiddleware` applying headers at response start via `OnStarting` - Per-header scoping (FU1 = A): `X-Content-Type-Options` and `Strict-Transport-Security` on **all** responses; `Content-Security-Policy`, `X-Frame-Options` and `Referrer-Policy` on HTML responses only - `SecurityHeadersOptions` binding a new `SecurityHeaders` section - `CspPolicyBuilder` with two code-defined policies — `Strict` and `Relaxed` — composed once at startup - Path-to-policy assignment and allowed origins in configuration; policy definitions in code (FU2 = A) - Registration **before static files**, since static files short-circuit the pipeline - An unknown policy name fails at **startup**, not per request **Components**: C-01, C-02, C-03, and the U3 portion of C-16 **Requirements**: FR-18 **Projects touched**: `SlpModularCms.Core`, both hosts **Definition of done** (Q5 = B): builds; all existing tests pass; new tests for policy composition per name, path-to-policy resolution including the default fallback, per-header applicability across HTML and non-HTML responses, headers reaching **static-file responses**, not overwriting pre-set headers, and startup failure on an unknown policy name. --- ## U4 — Observability Integration **Purpose**: make it possible to tell, without host access, whether the application is erroring and whether it is being used. **Scope**: - `AddCmsLogging()` — structured logging with a correlation identifier, independent of Sentry (Q10 of Application Design = B) - `AddCmsSentry()` — initialises only when a DSN is configured; absent DSN is a supported state, not an error - Environment and release tagging - Emit security-relevant events for alerting (FR-19) - Frontend: `@sentry/react` initialisation, Umami tracking script with a per-environment website ID, absent in local development - Frontend: `config.ts` treats an absent or empty `VITE_API_BASE_URL` as same-origin while still accepting an explicit absolute URL for local development **Components**: C-08, C-09, C-14, C-15, and the U4 portion of C-16 **Requirements**: FR-13, FR-14, FR-15, FR-16, FR-19 **Projects touched**: `SlpModularCms.Core`, both hosts, `frontend/` **Carried-in open item**: OPEN-01 — the correlation-ID mechanism (`TraceIdentifier` versus W3C `traceparent`) is decided in this unit's NFR Design, along with the definition of an alertable security event. **Note on lint**: because the quality-gate fixes moved to U5 (Q2 = B), `pnpm run lint` is still failing for pre-existing reasons while this unit changes frontend files. Lint should be run on the **changed files** during this unit so no new violations accumulate, even though the blocking gate is not switched on until U5. **Definition of done** (Q5 = B): builds; all existing tests pass; new tests for logging configuration with correlation ID present, Sentry registration being a no-op without a DSN, same-origin resolution when `VITE_API_BASE_URL` is empty, explicit-URL behaviour preserved, and the Umami component rendering nothing without a website ID. --- ## U5 — CI Workflow & Quality Gates **Purpose**: validate every change, and be the only route to production. **Scope**: - Fix the 5 frontend lint errors and 1 warning (FR-21) — merged here per Q2 = B, so the gates and the fixes land together and the pipeline is never red on arrival - Pin `Microsoft.OpenApi` and `System.Security.Cryptography.Xml` to patched versions (FR-22, OPEN-03) - `.gitea/workflows/continuous_integration.yaml` with triggers on `pull_request`, `push` to `master`, and `workflow_dispatch` with a `deploy_production` boolean defaulting to `false` - Six blocking gates: backend build, backend tests, vulnerability scan, frontend build, frontend tests, frontend lint and format-check - Toolchain installed explicitly and pinned (`actions/setup-dotnet`, `pnpm/action-setup`); no `latest` tags - Two environment-specific builds with their own Vite variables (FR-05) - Production reachable **only** via `workflow_dispatch` with the flag set (FR-04) **Components**: C-12 **Requirements**: FR-01, FR-04, FR-05, FR-21, FR-22 **Projects touched**: `.gitea/workflows/`, `frontend/src/` (lint fixes), `*.csproj` (package pins) **Definition of done** (Q5 = B): all six gates pass locally against the current tree — `pnpm run lint` clean, `dotnet list package --vulnerable` clean, all tests green. Workflow YAML is syntactically valid. Production cannot be triggered by a push. --- ## U6 — Deploy Workflow **Purpose**: turn a validated build into a running release without endangering the customer's website, the database, or master↔slave trust. **Scope**: - `.gitea/workflows/deploy-scp.yaml` as a reusable `workflow_call` workflow (Q11 of Application Design = B — one workflow per transport, identical input interface) - Plain shell steps, no container actions (they fail on the Podman-backed runner) - Deployment sequence: download artifact → **production only**: database backup before any change → upload to a new release directory → link the persistent `wwwroot/web/` into it → switch the active release atomically → restart the process → verify `/health` → prune old releases keeping at least the previous one - Automatic test deployment on `master`; production deployment only when invoked with the flag - Environment-specific paths from Gitea variables, credentials from secrets **Components**: C-11 **Requirements**: FR-02, FR-03, FR-06, FR-08, FR-20 **Projects touched**: `.gitea/workflows/` **Carried-in assumption**: ASM-01 — `wwwroot/web/` must live **outside** the swapped release directory and be linked into each new release. Confirmed in this unit's Infrastructure Design. Getting this wrong destroys the customer's website, the highest-severity risk in the feature. **Definition of done** (Q5 = B): workflow YAML valid; the deployment sequence documented step by step including the rollback path; the `wwwroot/web/` linking step explicit and justified. Note that end-to-end verification requires the actual Pi, SSH credentials and a database, so it cannot be fully proven in CI — real-run verification belongs to the Operations phase. --- ## U7 — Repository Documentation **Purpose**: let a website workspace deliver a site that works, without its author needing to read this repository's code. **Scope** (Q8 = A — repository documentation here; operational documents in the Operations phase): - Website workspace contract (FR-09): target path `wwwroot/web/`, required structure, forbidden paths (`admin/`, the application root), reserved paths (`/admin`, `/api/v1`, `/health`), SPA-fallback behaviour, how to call `/api/v1` same-origin without CORS, which CSP applies, and how to include the Umami script - README updates: the new `wwwroot` layout, the health endpoint and what it does *not* mean, the changed production setup section - `frontend/.env.example` updates for the same-origin default and the new observability variables **Components**: none — documentation only **Requirements**: FR-09 **Projects touched**: repository root, `frontend/` **Definition of done**: documentation is accurate against the code as built in U1–U6, and the website contract is complete enough to follow without reading source. --- ## Out of Unit Scope — Delivered by the Operations Phase | Requirement | Delivered at | |---|---| | FR-17 — UptimeRobot monitors for `/health`, `/` and `/admin` | Monitoring Setup | | FR-19 — Sentry alert rules (the application-side event emission is in U4) | Monitoring Setup | | FR-23 — deployment instructions, host setup, rollback plan, FTPS switch path | Deployment Setup | | DEV-01…04 re-confirmation, appsettings compliance gate | Production Readiness Validation | --- ## Code Organization Brownfield — the existing structure is retained. New code follows the solution layout mandated by `CLAUDE.md` / `AGENTS.md`: - Cross-cutting concerns go in `src/SlpModularCms.Core/Hosting/`, in new subfolders `Security/`, `Health/` and `Observability/` - No new project is added to the solution - Workflow files go in `.gitea/workflows/` at the repository root - Tests mirror their production project, per the existing Tests solution folder convention