diff --git a/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md b/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md
index 68aff9f..843d899 100644
--- a/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md
+++ b/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md
@@ -5,7 +5,7 @@
- **Feature Slug**: gitea-deployment-workflow
- **Project Type**: Brownfield
- **Start Date**: 2026-07-27T00:00:00Z
-- **Current Stage**: CONSTRUCTION - Code Generation, Round 1 (U1 + U2 complete, awaiting approval)
+- **Current Stage**: CONSTRUCTION - Round 2, NFR Design complete for U3 + U4 (awaiting approval)
- **Branch**: feature/gitea-deployment-workflow
## Workspace State
@@ -65,9 +65,9 @@ U1 Hosting & Serving · U2 Data Durability · U3 Security Headers & CSP · U4 Ob
Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 + U6 · **R4** = U7. One commit per unit; single PR at the end (Q6 = A).
-- [~] Functional Design — **EXECUTE for U1, U2, U3, U4**; SKIP for U5, U6, U7. **U1 ✅ U2 ✅** approved 2026-07-27
+- [x] Functional Design — **EXECUTE for U1, U2, U3, U4**; SKIP for U5, U6, U7. **U1 ✅ U2 ✅** approved 2026-07-27 · **U3 ✅ U4 ✅** 2026-07-28
- [ ] NFR Requirements — **SKIP (all units)** — already comprehensively captured in `requirements.md` § 5 and § 6
-- [ ] NFR Design — **EXECUTE for U3, U4**; SKIP for the rest. *Deliberate deviation from the default NFR-Requirements/NFR-Design coupling — rationale in the execution plan.*
+- [x] NFR Design — **EXECUTE for U3, U4**; SKIP for the rest. *Deliberate deviation from the default NFR-Requirements/NFR-Design coupling — rationale in the execution plan.* **U3 ✅ U4 ✅** 2026-07-28 — 11 patterns for U3, 10 for U4. Closed OPEN-01; raised REF-U3-01
- [ ] Infrastructure Design — **EXECUTE for U6, U7**; SKIP for the rest
- [~] Code Generation — **EXECUTE** (all 7 units, each built and tested before its completion message). **U1 ✅ U2 ✅** generated and verified 2026-07-27 — build 0 errors, 253 backend tests pass (was 219)
- [ ] Build and Test — **EXECUTE**
@@ -84,9 +84,19 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 +
## Current Status
- **Lifecycle Phase**: CONSTRUCTION
-- **Current Stage**: Code Generation complete for Round 1 (U1 Hosting & Serving, U2 Data Durability)
-- **Next Stage**: Round 2 — U3 Security Headers & CSP + U4 Observability, starting with Functional Design
-- **Status**: Awaiting Round 1 code approval
+- **Current Stage**: Round 2 — NFR Design complete for U3 Security Headers & CSP and U4 Observability
+- **Next Stage**: Code Generation for U3 + U4
+- **Status**: Awaiting NFR Design approval. Round 1 (U1 + U2) code approved and committed 2026-07-28
+
+## Round 2 Design Record (2026-07-28)
+- Functional Design U3 + U4 complete and committed (`357d395`)
+- NFR Design U3 — `construction/u3-security-headers/nfr-design/` — 11 patterns. All new types in `Core/Hosting/Security/`, so `Core.Tests` can reach them (avoids repeating U1's Step 11 deviation)
+- NFR Design U4 — `construction/u4-observability/nfr-design/` — 10 patterns. `Sentry.AspNetCore` 6.8.0 into `Core`; `@sentry/react` ^10.68.0 into `frontend`
+- **OPEN-01 CLOSED**: correlation ID = W3C trace ID from the ambient `Activity`, `TraceIdentifier` as fallback. Rationale: propagates master→slave via `traceparent`, and equals the `traceId` ASP.NET Core's `ProblemDetails` already returns
+- **REF-U3-01 raised**: BR-U3-22's Umami-origin startup warning is **not implementable** — the backend cannot read `VITE_UMAMI_WEBSITE_ID`. Withdrawn from U3 and replaced by a **blocking U5 CI gate** comparing the frontend build variable against that environment's `SecurityHeaders:AllowedScriptOrigins`
+- **Additions beyond the functional design**, each with rationale in the pattern docs: `Set-Cookie` added to the scrub list; a `sentry-tunnel` rate limiter; `OnRejected` on the existing rate limiter (today a `429` leaves no trace anywhere); `SentrySdk.FlushAsync` before the migration-failure rethrow (otherwise the one `Critical` event dies with the process)
+- **Three defaults chosen rather than escalated** (each one line to change, listed at the end of U4's pattern doc): JSON console outside Development, `TracesSampleRate` 0.1, tunnel cap 200 KB
+- U4 modifies two files from already-committed units — `DatabaseMigrationExtensions` (U2) and `AdminTokenValidator` (U1). Both additive; to be named in the U4 commit message
## Round 1 Verification Record (2026-07-27)
- `dotnet build SlpModularCms.sln -c Release` — 0 errors
diff --git a/aidlc-docs/features/gitea-deployment-workflow/audit.md b/aidlc-docs/features/gitea-deployment-workflow/audit.md
index 6693801..e41773d 100644
--- a/aidlc-docs/features/gitea-deployment-workflow/audit.md
+++ b/aidlc-docs/features/gitea-deployment-workflow/audit.md
@@ -800,3 +800,80 @@ Note this is a **blocking** consideration under the enabled Security Baseline ex
**No blocking security findings. No new deviation** — FU2 = C removed the need for the DEV-06 that Q2 = B would have required.
---
+
+## 2026-07-28 — CONSTRUCTION: NFR Design (Round 2, U3 + U4)
+
+**Stage**: NFR Design — executed for U3 and U4 only, per the execution plan. NFR Requirements skipped for all units (already captured in `requirements.md` § 5 and § 6).
+
+No question round. The two decisions the earlier stages had left open were technical rather than preferential, and three remaining values had defensible defaults; all five are recorded below with the reasoning, and each is one line to change.
+
+### OPEN-01 CLOSED — the correlation ID is the W3C trace ID
+
+Decided: the trace ID from the ambient `Activity`, with `HttpContext.TraceIdentifier` as the fallback when no `Activity` exists. Enabled by `ActivityTrackingOptions` plus `IncludeScopes`, so every entry from every category — framework included — carries it with **no change to any call site**.
+
+Two reasons, the first decisive:
+
+1. **It crosses the master/slave HTTP boundary.** `HttpClient` injects `traceparent`; the slave's hosting layer adopts it. The hardest diagnostic question in this codebase — "the master says the slave rejected its API key; what did the slave see?" — is answerable with one identifier. `TraceIdentifier` is host-local and cannot answer it at all.
+2. **It is already the value the client is shown.** ASP.NET Core's `ProblemDetails` writes `traceId` as `Activity.Current?.Id ?? HttpContext.TraceIdentifier`. Choosing the W3C trace ID makes the log entry, the Sentry event, the slave's log entry and the browser's error response carry one value. Choosing `TraceIdentifier` would create two competing identifiers for the same request.
+
+Trap recorded: `ActivityTrackingOptions` populates the scope, `IncludeScopes` renders it. Set the first and forget the second and every log line looks entirely normal with no correlation ID and no error anywhere. A test asserts the field is present in rendered output.
+
+### REF-U3-01 — BR-U3-22 is not implementable and has been replaced
+
+BR-U3-22 asked the backend to warn at startup when a Umami website ID is configured but its script origin is missing from the CSP. **The backend cannot see that ID**: it is a `VITE_` variable baked into the frontend bundle at build time. The check would either always warn or never warn.
+
+Withdrawn from U3 and moved to the **U5 CI workflow**, where both values are visible: if `VITE_UMAMI_SCRIPT_URL` is set for an environment's frontend build, its origin must appear in that environment's `SecurityHeaders:AllowedScriptOrigins`, or the **pipeline fails**. Stronger than the rule it replaces — drift now blocks the deployment instead of writing a warning into a log on a Raspberry Pi.
+
+Carried to U5 as a required gate.
+
+### Four risks closed that the functional design did not name
+
+1. **`Set-Cookie` added to the scrub list.** The functional design named the request `Cookie` header. Response headers can be attached to an event, and `Set-Cookie` on the login and refresh responses carries the `refreshToken` being issued. Scrubbing the request cookie while sending the response cookie would protect nothing.
+2. **`SetBeforeSendTransaction` as well as `SetBeforeSend`.** Performance transactions carry request data too. Scrubbing only events leaves a second channel open — less obvious precisely because nobody thinks of a transaction as containing headers. Enabling tracing later without touching that file would start leaking.
+3. **`OnRejected` on the rate limiter.** `AddCmsRateLimiting` sets only `RejectionStatusCode`. Today a brute-force attempt against `/api/v1/Auth/login` returns `429` and leaves **no trace anywhere** — the one rate limiter this application has is unobservable. The `RateLimitTriggered` security event needs this callback to exist.
+4. **`SentrySdk.FlushAsync` before the migration-failure rethrow.** The SDK sends in the background; a process that throws during startup and exits kills the sender first. The migration failure is the one `Critical` event in the system, and it was the event most likely never to arrive. Bounded at five seconds: a host that cannot reach its database is already down.
+
+### Two "looks correct, does nothing" traps recorded with tests attached
+
+- **Sentry groups log-derived events by message template.** Emitted with interpolation, every distinct email produces a separate Sentry issue, and an alert rule of the form "more than 20 failed logins in 5 minutes" can never fire — no single issue ever reaches 20. FR-19 would be unimplementable while appearing to work. Resolved with source-generated `LoggerMessage` (fixed templates, `EventId` 5001–5006) plus an `ISentryEventProcessor` that maps the ID to a `security_event` tag, so alert rules filter on a tag rather than message text.
+- **`DefaultHttpContext.Response.OnStarting` is a no-op.** A unit test that drives the security-headers middleware through a `DefaultHttpContext` finds no headers — and a test asserting *nothing was set* passes for the wrong reason and keeps passing after the middleware is deleted. Resolved by putting all decision logic in a static, dependency-free `SecurityHeaderWriter` tested against a bare `HeaderDictionary`, leaving the middleware as glue whose only risk is ordering, which is verified at Build and Test.
+
+### Three defaults chosen rather than escalated
+
+| Decision | Chosen | Reasoning |
+|---|---|---|
+| Console format | JSON outside Development, human-readable locally | Supervised process on the Pi; `TraceId` becomes a queryable field. Both set `IncludeScopes`, so the information is identical either way |
+| `TracesSampleRate` | `0.1` on both sides | Sentry's free plan counts transactions against the same quota as errors, and this design's value is in errors. The reference project's `1.0` is for a low-traffic marketing site with no API |
+| Tunnel max payload | 200 KB | Envelopes with a stack trace and breadcrumbs run tens of KB |
+
+Each is one line; all three are listed at the end of U4's pattern document for review.
+
+### Packages added
+
+| Project | Package | Version |
+|---|---|---|
+| `SlpModularCms.Core` | `Sentry.AspNetCore` | `6.8.0` — latest on nuget.org; `net10.0` asset to be confirmed at Code Generation |
+| `frontend` | `@sentry/react` | `^10.68.0` — same major as the reference project |
+
+`Sentry.AspNetCore` goes into `Core` rather than the hosts, because the scrubber, the processor, `UseCmsSentry` and the tunnel all live there and both hosts consume them. **FR-22 interaction**: this adds a new dependency subtree while U5 is due to pin two packages against advisories — the new tree must be checked in the same pass, not assumed clean because it is new.
+
+### Three divergences from the reference project, all deliberate
+
+- **The dev tunnel proxy targets the local API, not Sentry's ingest host.** The reference proxies straight to Sentry, which hard-codes the project ID in `vite.config.ts` and carries a `LET OP` comment about keeping it in sync by hand. Targeting the local API removes both the synchronisation and the committed project ID.
+- **`UmamiAnalytics` drops the script-removing cleanup.** Under React 18 `StrictMode` the double-invocation becomes inject → remove → inject, and removing the element does not unregister Umami's listeners, so the first page view can be counted twice. The component lives for the application's lifetime and has nothing to clean up; the existing duplicate guard handles re-invocation.
+- **`tanstackRouterBrowserTracingIntegration` not adopted.** It needs the router instance, and importing `@/router` from `sentry.ts` — called before anything else in `main.tsx` — inverts the initialisation order and pulls the whole route tree into startup. Can be added later inside `main.tsx`, where the router is already imported.
+
+### Artifacts generated
+- `construction/u3-security-headers/nfr-design/`: `nfr-design-patterns.md` (11 patterns), `logical-components.md`
+- `construction/u4-observability/nfr-design/`: `nfr-design-patterns.md` (10 patterns), `logical-components.md`
+- `inception/requirements/requirements.md`: OPEN-01 struck through and closed; open-item count 3 → 2
+
+### Security Compliance (Security Baseline extension — enabled, blocking)
+- **SECURITY-03 — compliant, and now mechanised.** The correlation ID arrives by configuration rather than by discipline. EF's `Database.Command` category pinned at `Warning`, because at `Information` it prints parameter values and the login path passes a normalised email through it.
+- **SECURITY-11 — compliant.** The tunnel destination is parsed once at startup from the DSN and no part of it can come from the request, which is the single rule separating a tunnel from a server-side request forgery primitive. A rate limiter was added because the other controls bound what each call can do but not how many calls there can be.
+- **SECURITY-14 — addressed; DEV-01 unchanged.** Six tagged event types; alert rules configured in Operations.
+- **SECURITY-15 — compliant.** Both units fail closed at startup (`ValidateOnStart`, unparseable DSN) and neither throws into the response path.
+
+**No blocking security findings. No new deviation.**
+
+---
diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/nfr-design/logical-components.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/nfr-design/logical-components.md
new file mode 100644
index 0000000..91d29c5
--- /dev/null
+++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/nfr-design/logical-components.md
@@ -0,0 +1,146 @@
+# Logical Components — U3 HTTP Security Headers & CSP
+
+All new types live in `SlpModularCms.Core/Hosting/Security/`, alongside the existing `AdminTokenValidator` from U1. Nothing is added to `SlpModularCms.Api`, so everything here is reachable by `SlpModularCms.Core.Tests` — deliberately, after U1's Step 11 deviation showed what happens when host-only code needs testing.
+
+---
+
+## Component Wiring
+
+```mermaid
+graph TD
+ subgraph Configuration["Configuration"]
+ Section["SecurityHeaders section appsettings per environment"]
+ Options["SecurityHeadersOptions"]
+ Validator["IValidateOptions ValidateOnStart"]
+ end
+
+ subgraph Policy["Policy Composition — startup only"]
+ Catalog["CspPolicyCatalog static, in code"]
+ Provider["ICspPolicyProvider CspPolicyProvider"]
+ Frozen["FrozenDictionary<string, SecurityHeaderSet>"]
+ end
+
+ subgraph Request["Request Path — per response"]
+ Middleware["SecurityHeadersMiddleware"]
+ Resolver["PathPolicyResolver"]
+ State["HeaderWriteState struct-like state object"]
+ Writer["SecurityHeaderWriter static, pure"]
+ end
+
+ subgraph Host["Host Wiring"]
+ AddExt["AddCmsSecurityHeaders"]
+ UseExt["UseCmsSecurityHeaders"]
+ Logger["ILogger — startup origins, disabled warning"]
+ end
+
+ Response["HTTP response headers"]
+
+ Section --> Options
+ Options --> Validator
+ Options --> Provider
+ Options --> Resolver
+ Catalog --> Provider
+ Provider --> Frozen
+ AddExt --> Options
+ AddExt --> Provider
+ AddExt --> Resolver
+ UseExt --> Middleware
+ UseExt --> Logger
+ Middleware --> Resolver
+ Middleware --> Provider
+ Middleware --> State
+ State --> Writer
+ Writer --> Response
+
+ classDef cfg fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
+ classDef code fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
+ classDef runtime fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
+ classDef host fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
+ classDef out fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
+ class Section,Options,Validator cfg;
+ class Catalog,Provider,Frozen,Writer code;
+ class Middleware,Resolver,State runtime;
+ class AddExt,UseExt,Logger host;
+ class Response out;
+```
+
+Text alternative: configuration binds to validated options that feed both the policy provider and the path resolver at startup; the provider composes the in-code catalog into a frozen dictionary of header sets; per response the middleware resolves a policy name, registers a response-start callback carrying a state object, and a static writer applies the headers.
+
+---
+
+## Component Responsibilities
+
+| Component | Lifetime | Responsibility | Pattern |
+|---|---|---|---|
+| `SecurityHeadersOptions` | Options singleton | Binds `Enabled`, `DefaultPolicy`, `PathPolicies`, `AllowedScriptOrigins`, `AllowedConnectOrigins` | 1, 11 |
+| `PathPolicyRule` | Record | One `PathPrefix` → `Policy` pair | 3 |
+| `CspPolicyCatalog` | Static | The two policy definitions. Pure function of origin lists → directive list | 2 |
+| `SecurityHeaderSet` | Record | The three HTML-only header values for one policy | 2 |
+| `ICspPolicyProvider` / `CspPolicyProvider` | Singleton | Composes and caches header sets once; one lookup per response | 2 |
+| `PathPolicyResolver` | Singleton | Ordered, segment-aware prefix match → policy name | 3 |
+| `SecurityHeadersMiddleware` | Per-request instance, singleton delegate | Gate on `Enabled`, resolve the policy, register the response-start callback | 4 |
+| `SecurityHeaderWriter` | Static | Per-header scoping, HSTS gating, never-overwrite. **All decision logic** | 5, 6 |
+| `SecurityHeadersExtensions` | Static | `AddCmsSecurityHeaders` / `UseCmsSecurityHeaders`, startup logging | 9, 10 |
+
+**Why `SecurityHeaderWriter` is static and separate from the middleware**: it is the only component whose behaviour is worth exhaustive testing, and `DefaultHttpContext` cannot drive it through the middleware (Pattern 5). Splitting it makes the interesting part testable with `new HeaderDictionary()` and leaves the middleware as glue with nothing to get wrong except ordering — which is verified at Build and Test.
+
+**Why `PathPolicyResolver` is a class rather than a method on the provider**: it answers a different question (which policy) from the provider (what the policy contains), and the `/administrator` versus `/admin` trap deserves its own test class rather than being buried in provider tests.
+
+---
+
+## DI Registration Order
+
+Inside `AddCmsSecurityHeaders(configuration)`:
+
+```
+1. services.AddOptions()
+ .Bind(configuration.GetSection("SecurityHeaders"))
+ .Validate(...) // unknown policy name, origin format
+ .ValidateOnStart()
+2. services.AddSingleton()
+3. services.AddSingleton()
+```
+
+Called from `Program.cs` next to the other `AddCms*` calls, before module registration. Order within the file does not matter — nothing here is overridable by a module, unlike Data Protection in U2.
+
+`UseCmsSecurityHeaders()` is called **first inside `UseExceptionHandler()`**; see Pattern 10. That position *does* matter, in both directions.
+
+---
+
+## Both Hosts
+
+`SlpModularCms.Api` and `SlpModularCms.Api.Slave` both call `AddCmsSecurityHeaders` and `UseCmsSecurityHeaders`. The slave host serves no admin SPA and no public website today, but it does serve `/api/v1` and `/health`, and it will be reached directly during diagnosis. There is no reason for it to be the one host without `nosniff` and HSTS.
+
+The slave's `PathPolicies` defaults are identical; the paths that do not exist there simply never match.
+
+---
+
+## NFR Coverage Traceability
+
+| NFR / Rule | Pattern | Component |
+|---|---|---|
+| NFR-01 — no server configuration | Headers emitted in-process | `SecurityHeadersMiddleware`, `SecurityHeaderWriter` |
+| NFR-03 — startup cost | Compose twice at startup, never per request | `CspPolicyProvider` + `FrozenDictionary` |
+| NFR-06 — testability | Decision logic in pure static functions | `SecurityHeaderWriter`, `CspPolicyCatalog`, `PathPolicyResolver` |
+| SECURITY-04 | All five headers; CSP on every HTML path | `SecurityHeaderWriter`, `CspPolicyCatalog` |
+| SECURITY-11 | CSP as a second layer behind output escaping | `CspPolicyCatalog` |
+| SECURITY-15 — fail closed | `ValidateOnStart`; never throws per request | Pattern 1, Pattern 7 |
+| BR-U3-01…03 — per-header scope | Explicit `sendHsts` + content-type parse | `SecurityHeaderWriter` |
+| BR-U3-04 — never overwrite | `TryAdd` rather than the indexer | `SecurityHeaderWriter` |
+| BR-U3-05 — write at response start | `OnStarting(callback, state)` with a static delegate | `SecurityHeadersMiddleware` |
+| BR-U3-06, BR-U3-09 — position | Inside the exception handler, before static files | `UseCmsSecurityHeaders` call site |
+| BR-U3-07 — never throw | Catch-and-log inside the callback | `SecurityHeadersMiddleware` |
+| BR-U3-10…19 — policy content | Two policies in code, origins from configuration | `CspPolicyCatalog` |
+| BR-U3-20 — unknown name fatal | `ValidateOnStart` | Pattern 1 |
+| BR-U3-22 | **Refined — REF-U3-01**, moved to the U5 CI gate | Carried to U5 |
+| BR-U3-23, BR-U3-24 — startup logging | Logged from the `Use` extension | `SecurityHeadersExtensions` |
+
+---
+
+## Carried Forward
+
+| Item | To | Why |
+|---|---|---|
+| **REF-U3-01** — Umami origin drift gate | **U5 CI Workflow** | The backend cannot see `VITE_UMAMI_WEBSITE_ID`; the CI job can see both sides and must fail on drift |
+| Pipeline-order verification | **Build and Test** | A misordered registration passes every unit test and serves the whole website without a CSP |
+| Header presence on a real `503` and a real static asset | **Build and Test** | Needs a running host with a real response feature |
diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/nfr-design/nfr-design-patterns.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/nfr-design/nfr-design-patterns.md
new file mode 100644
index 0000000..baff847
--- /dev/null
+++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/nfr-design/nfr-design-patterns.md
@@ -0,0 +1,273 @@
+# NFR Design Patterns — U3 HTTP Security Headers & CSP
+
+**Unit**: U3 HTTP Security Headers & CSP
+**NFRs addressed**: NFR-01 (no server configuration), NFR-03 (startup cost), NFR-06 (testability), SECURITY-04, SECURITY-11, SECURITY-15
+
+These patterns exist because U3 moves work that normally lives in nginx into the request pipeline. That changes the failure modes: a bad nginx config fails loudly at reload, whereas a bad middleware silently emits nothing. Every pattern below is chosen so that "wrong" is visible rather than quiet.
+
+---
+
+## Pattern 1 — Fail Closed at Startup: `ValidateOnStart`
+
+**Rule**: BR-U3-20 — an unknown policy name must stop the process.
+
+**Pattern**: Bind `SecurityHeadersOptions` through the options builder with a validator and `ValidateOnStart()`, so the failure happens during host start rather than on the first request.
+
+```csharp
+services.AddOptions()
+ .Bind(configuration.GetSection(SecurityHeadersOptions.SectionName))
+ .Validate(o => o.PathPolicies.All(r => CspPolicyCatalog.IsKnownPolicy(r.Policy)),
+ "SecurityHeaders:PathPolicies contains an unknown policy name.")
+ .Validate(o => CspPolicyCatalog.IsKnownPolicy(o.DefaultPolicy),
+ "SecurityHeaders:DefaultPolicy is not a known policy.")
+ .Validate(o => o.AllowedScriptOrigins.All(IsOrigin) &&
+ o.AllowedConnectOrigins.All(IsOrigin),
+ "SecurityHeaders origins must be scheme-and-host only, without a path.")
+ .ValidateOnStart();
+```
+
+**Why `ValidateOnStart` and not a constructor check**: options are resolved lazily. Without `ValidateOnStart`, a typo in `PathPolicies` is discovered when the first request arrives — by which time the deployment has already been reported as successful and the health check is green. `ValidateOnStart` registers an `IStartupValidator` that runs inside `IHost.StartAsync`, so `dotnet SlpModularCms.Api.dll` exits non-zero and the release switch is visibly broken.
+
+**Consequence for the deploy workflow (U6)**: this makes a smoke request unnecessary to catch configuration typos — the process simply does not come up. Worth stating, because it is the difference between "deployment failed" and "deployment succeeded and the site is broken".
+
+---
+
+## Pattern 2 — Precomposed Immutable Policies: `ICspPolicyProvider`
+
+**Rule**: BR-U3-18 — policy strings are composed once and reused.
+
+```csharp
+public interface ICspPolicyProvider
+{
+ SecurityHeaderSet Get(string policyName);
+ IReadOnlyCollection PolicyNames { get; }
+}
+
+public sealed record SecurityHeaderSet(
+ string ContentSecurityPolicy,
+ string FrameOptions,
+ string ReferrerPolicy);
+```
+
+**Implementation shape**: a singleton whose constructor builds a `FrozenDictionary` from `CspPolicyCatalog` (the in-code policy definitions) plus the configured origin lists. Nothing is composed per request; the middleware performs one dictionary lookup and three header writes.
+
+**Why `FrozenDictionary`**: read-only after startup and read on every response. This is the exact workload it exists for, and the build cost is paid once against a two-entry dictionary.
+
+**Why the split into catalog and provider**: `CspPolicyCatalog` is a pure static function of `(policy name, origin lists) → directive list` with no dependencies, so the directive content is unit-testable without a service provider or configuration. The provider only caches it.
+
+---
+
+## Pattern 3 — Path Matching: `StartsWithSegments`, First Match Wins
+
+**Rule**: BR-U3-12.
+
+```csharp
+foreach (var rule in _rules) // ordered, from configuration
+{
+ if (path.StartsWithSegments(rule.Prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ return rule.Policy;
+ }
+}
+return _defaultPolicy;
+```
+
+**`StartsWithSegments`, never `string.StartsWith`.** `"/administrator".StartsWith("/admin")` is `true`. A future route named `/admin-tools` or a public page at `/administration` would silently inherit the strict policy and lose its inline scripts — a failure that looks like a broken page with a console error and no server-side trace at all. `StartsWithSegments` compares whole path segments and does not match.
+
+**First match wins, not longest prefix.** The rule list is ordered, and order is meaningful. Longest-prefix matching would be more forgiving but would make the effective policy non-obvious from reading the configuration. Order is documented in the configuration comment and asserted in a test that puts a broader prefix before a narrower one and expects the broader one to win.
+
+**Prefixes are normalised at startup** — a configured `admin` or `/admin/` both become `/admin` — because `StartsWithSegments` requires a leading slash and treats a trailing slash inconsistently across overloads.
+
+---
+
+## Pattern 4 — Writing at Response Start Without a Per-Request Closure
+
+**Rule**: BR-U3-05 — the content type is unknown before the pipeline continues.
+
+```csharp
+public async Task InvokeAsync(HttpContext context)
+{
+ if (!_options.Enabled)
+ {
+ await _next(context);
+ return;
+ }
+
+ var headerSet = _policyProvider.Get(_resolver.Resolve(context.Request.Path));
+ context.Response.OnStarting(WriteHeadersCallback,
+ new HeaderWriteState(context.Response, headerSet));
+
+ await _next(context);
+}
+
+private static readonly Func