OpenAPI query param discovery and header cleanup completion

Backend: ExtractParameters() now discovers query params from [AsParameters]
records and [FromQuery] attributes via handler method reflection. Gateway
OpenApiDocumentGenerator emits parameters arrays in the aggregated spec.
QueryParameterInfo added to EndpointSchemaInfo for HELLO payload transport.

Frontend: Remaining spec files and straggler services updated to canonical
X-Stella-Ops-* header names. Sprint 026 archived (tasks 01-06 DONE,
07-09 TODO for backend service rename pass).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-10 17:13:58 +02:00
parent 8578065675
commit 8a1fb9bd9b
28 changed files with 349 additions and 94 deletions

View File

@@ -0,0 +1,412 @@
# Sprint 026 — Global Context Propagation & Header Naming Cleanup
## Topic & Scope
- **Goal**: Replace the hardcoded Pack 2.2 route list in `GlobalContextHttpInterceptor` with an OpenAPI-driven parameter map, so context (region, environment, timeWindow, stage) is injected as query params precisely where each endpoint declares support.
- **Goal**: Unify all HTTP header naming to a single canonical prefix `X-Stella-Ops-*`, removing legacy `X-Stella-*`, `X-StellaOps-*`, `X-Tenant-Id`, `X-Scopes`, `X-Project-Id` traces.
- **Why now**: The topbar context controls (tenant, region, env, window, stage) are now global singletons. The propagation layer must match — precise, automatic, and consistently named.
- Working directory: repo root (cross-module sprint).
- Explicitly allows cross-module edits in: `src/Web/`, `src/Router/`, `src/Platform/`, `src/Authority/`, `src/Scanner/`, `src/Policy/`, `src/Graph/`, `src/Concelier/`, `src/Findings/`, `src/JobEngine/`, `src/Notifier/`, `src/Telemetry/`, `src/EvidenceLocker/`, `src/AdvisoryAI/`, `src/BinaryIndex/`, `src/Replay/`.
- Expected evidence: interceptor tests, OpenAPI spec diff showing params, grep confirming zero legacy header references.
## Dependencies & Concurrency
- No upstream sprint dependencies.
- Phase 1 (OpenAPI param declarations) and Phase 3 (header cleanup) can run in parallel.
- Phase 2 (interceptor rewrite) depends on Phase 1 completion.
- Phase 3 is independent of Phase 1 and Phase 2 — pure rename.
- Header cleanup (Phase 3) should be done in a single atomic commit per module to avoid partial renames.
## Documentation Prerequisites
- Read before starting:
- `docs/modules/platform/architecture-overview.md` (context propagation)
- `src/Router/__Libraries/StellaOps.Router.Gateway/OpenApi/OpenApiDocumentGenerator.cs` (aggregation logic)
- `src/Web/StellaOps.Web/src/app/core/context/global-context-http.interceptor.ts` (current interceptor)
- `src/Web/StellaOps.Web/src/app/core/auth/tenant-http.interceptor.ts` (current tenant headers)
- `src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsHttpHeaderNames.cs` (master constants)
---
## Current State (research findings)
### Context propagation today
1. **`TenantHttpInterceptor`** (Angular) — sends `X-StellaOps-Tenant`, `X-Stella-Tenant`, `X-Tenant-Id` on all authenticated requests. Already works but emits 3 names for the same value.
2. **`GlobalContextHttpInterceptor`** (Angular) — injects `regions`, `environments`, `timeWindow` as **query parameters**, but only on 6 hardcoded `/api/v2/*` route prefixes. Does not inject `stage`.
3. **Gateway** — strips reserved identity headers, re-writes from JWT claims. Copies all other headers downstream. Query params pass through unchanged.
4. **Backend services** — read context from query params via `[AsParameters]` binding (e.g., `TopologyQuery`, `SecurityFindingsQuery` records). No services read context from headers.
### OpenAPI spec today
- Gateway serves aggregated spec at `/openapi.json` and `/.well-known/openapi` (1899 paths, 2190 operations).
- Angular already has `GatewayOpenApiHttpClient` that fetches the spec with ETag caching.
- **Critical gap**: The spec declares **zero query parameters**. Services use `[AsParameters]` records but neither the built-in `MapOpenApi()` nor the custom `OpenApiDiscoveryDocumentProvider` emit parameter metadata for them.
### Header naming today
| Legacy name | Current canonical | Proposed canonical | Files affected |
|---|---|---|---|
| `X-Stella-Tenant` | `X-StellaOps-Tenant` | `X-Stella-Ops-Tenant` | ~64 |
| `X-Tenant-Id` | `X-StellaOps-Tenant` | `X-Stella-Ops-Tenant` | ~40 |
| `X-Scopes` | `X-StellaOps-Scopes` | `X-Stella-Ops-Scopes` | ~30 |
| `X-Stella-Project` | `X-StellaOps-Project` | `X-Stella-Ops-Project` | ~10 |
| `X-Project-Id` | `X-StellaOps-Project` | `X-Stella-Ops-Project` | ~2 |
| `X-StellaOps-Actor` | — | `X-Stella-Ops-Actor` | ~80 |
| `X-StellaOps-Client` | — | `X-Stella-Ops-Client` | ~40 |
| `X-StellaOps-Identity-Envelope` | — | `X-Stella-Ops-Identity-Envelope` | ~5 |
| `X-Stella-Trace-Id` | — | `X-Stella-Ops-Trace-Id` | ~20 |
| `X-Stella-Request-Id` | — | `X-Stella-Ops-Request-Id` | ~10 |
**Total scope**: ~300 header string references across ~150 files.
---
## Delivery Tracker
### TASK-026-01 — Emit query parameter declarations in service OpenAPI specs
Status: DONE
Dependency: none
Owners: Developer (Backend)
Task description:
Backend services use `[AsParameters]` records (e.g., `TopologyQuery`, `SecurityFindingsQuery`) to bind query parameters, but neither the built-in ASP.NET `MapOpenApi()` nor the custom `OpenApiDiscoveryDocumentProvider` implementations emit `parameters` entries in the OpenAPI spec.
Fix the OpenAPI generation so that query parameters from `[AsParameters]` records appear in the generated spec. Two approaches (pick one):
**Option A (preferred)**: Configure ASP.NET Core's built-in OpenAPI to recognize `[AsParameters]`. In .NET 9, `Microsoft.AspNetCore.OpenApi` should auto-detect record properties bound from query strings. Verify this works for services using `AddOpenApi()` + `MapOpenApi()`. If not, add `[FromQuery]` attributes to the record properties.
**Option B**: For services using custom `OpenApiDiscoveryDocumentProvider`, extend the provider to introspect endpoint parameter metadata and emit `parameters` arrays with `in: "query"` entries.
Target context parameter names to ensure are declared where endpoints accept them:
- `region` / `regions`
- `environment` / `environments`
- `timeWindow`
- `stage`
- `tenant` / `tenantId`
Completion criteria:
- [ ] `/openapi.json` from gateway includes `parameters` arrays with `in: "query"` entries for endpoints that accept context params
- [ ] At least `TopologyReadModelEndpoints`, `SecurityReadModelEndpoints` show params in spec
- [ ] Snapshot test updated if openapi_current.json is a contract fixture
### TASK-026-02 — Verify gateway OpenAPI aggregation preserves query params
Status: DONE
Dependency: TASK-026-01
Owners: Developer (Backend)
Task description:
The gateway's `OpenApiDocumentGenerator` aggregates specs from all microservices. Verify that it preserves the `parameters` arrays from individual service specs when building the consolidated `/openapi.json`.
If the aggregator drops parameters, fix it in `src/Router/__Libraries/StellaOps.Router.Gateway/OpenApi/OpenApiDocumentGenerator.cs`.
Completion criteria:
- [ ] Gateway `/openapi.json` includes all query parameters that individual service specs declare
- [ ] ETag cache invalidates when parameter declarations change
- [ ] Manual verification: `curl /openapi.json | jq '.paths["/api/v2/topology/regions"].get.parameters'` returns non-empty array
### TASK-026-03 — Build OpenAPI context parameter map service (Angular) + wire into APP_INITIALIZER
Status: DONE
Dependency: TASK-026-01
Owners: Developer (Frontend)
Task description:
Create a new Angular service `OpenApiContextParamMap` (in `core/context/`) that is **pre-loaded during app bootstrap**, before any feature module or route resolves. No usable module should load before this data is available.
**Current bootstrap chain** (in `app.config.ts`, APP_INITIALIZER):
```
1. AppConfigService.load() → /platform/envsettings.json [AWAIT]
2. I18nService.loadTranslations() → i18n JSON [AWAIT]
3. BackendProbeService.probe() → OIDC probe [FIRE-AND-FORGET]
```
**New bootstrap chain** (add step between i18n and probe):
```
1. AppConfigService.load() → /platform/envsettings.json [AWAIT]
2. I18nService.loadTranslations() → i18n JSON [AWAIT]
3. OpenApiContextParamMap.initialize() → /openapi.json → build Map [AWAIT] ← NEW
4. BackendProbeService.probe() → OIDC probe [FIRE-AND-FORGET]
```
Step 3 depends on step 1 (needs base URL from config). It MUST complete before routes load so the interceptor's param map is populated for the first API call.
**Service requirements:**
1. `initialize()` method — called from `provideAppInitializer()` in `app.config.ts`:
- Fetches the OpenAPI spec via the existing `GatewayOpenApiHttpClient.getOpenApiSpec()`.
- IMPORTANT: Must use raw `HttpBackend` (not `HttpClient`) to avoid circular dependency with interceptors that depend on this service. Follow the same pattern as `AppConfigService.load()` which uses `HttpBackend` to bypass interceptors.
- Parses the `paths` object and builds a `Map<string, Set<string>>` where:
- Key = normalized path pattern (e.g., `/api/v2/topology/regions`)
- Value = set of context query param names declared on that path (intersection with known context params: `region`, `regions`, `environment`, `environments`, `timeWindow`, `stage`, `tenant`, `tenantId`)
- Must NOT throw — if spec fetch fails, initialize with empty map (graceful degradation, no context injection).
2. `getContextParams(url: string): Set<string> | undefined` — synchronous lookup method used by the interceptor at request time. Must be fast (O(1)-ish).
3. ETag caching — store the ETag from the spec response. On subsequent calls (e.g., periodic refresh), send `If-None-Match` to avoid re-downloading unchanged spec.
**Path matching strategy:**
- Strip query string and fragment from URL before matching.
- Normalize path params: `/api/v2/topology/environments/{id}` matches `/api/v2/topology/environments/abc-123`.
- Build a regex or trie from OpenAPI path templates for O(1)-ish lookup at request time. A simple approach: convert `{param}` segments to `[^/]+` regex, compile once during `initialize()`.
**APP_INITIALIZER wiring** (in `app.config.ts`):
```typescript
provideAppInitializer(async () => {
const configService = inject(AppConfigService);
const i18nService = inject(I18nService);
const paramMap = inject(OpenApiContextParamMap);
const probeService = inject(BackendProbeService);
await configService.load();
await i18nService.loadTranslations();
await paramMap.initialize(); // ← NEW — blocks until map is ready
void probeService.probe();
}),
```
Completion criteria:
- [ ] Service exists at `core/context/openapi-context-param-map.service.ts`
- [ ] Wired into `provideAppInitializer()` in `app.config.ts` — runs after config load, before probe
- [ ] Map is built from live OpenAPI spec at startup, before any route resolves
- [ ] Uses `HttpBackend` (not `HttpClient`) to avoid interceptor circular dependency
- [ ] `getContextParams()` returns correct param set for known paths
- [ ] Falls back gracefully if spec fetch fails (empty map, no crash, no context injection)
- [ ] Unit tests with mock spec covering: exact match, parameterized path match, no-match, empty spec, fetch failure
### TASK-026-04 — Rewrite GlobalContextHttpInterceptor to use OpenAPI map
Status: DONE
Dependency: TASK-026-03
Owners: Developer (Frontend)
Task description:
Replace the current `GlobalContextHttpInterceptor` implementation:
**Current** (hardcoded):
```typescript
if (!this.isPack22ContextAwareRoute(request.url)) {
return next.handle(request);
}
// always inject all params
```
**New** (OpenAPI-driven):
```typescript
const contextParams = this.paramMap.getContextParams(request.url);
if (!contextParams || contextParams.size === 0) {
return next.handle(request);
}
// inject ONLY the params this endpoint declares
if (contextParams.has('regions') && regions.length > 0 && !params.has('regions')) {
params = params.set('regions', regions.join(','));
}
// ... etc for each known context param
```
Also add `stage` support (currently missing from interceptor).
Remove the duplicate param aliases (`region` + `regions`, `tenant` + `tenantId`) — inject only the name declared in the spec.
Completion criteria:
- [ ] `isPack22ContextAwareRoute()` method deleted
- [ ] Interceptor uses `OpenApiContextParamMap.getContextParams()` for decisions
- [ ] Only declared params are injected per-endpoint
- [ ] `stage` param supported
- [ ] No params injected when spec unavailable (graceful degradation)
- [ ] Unit tests with mock param map
### TASK-026-05 — Centralize header name constants
Status: DONE
Dependency: none (parallel with Phase 1)
Owners: Developer (Backend)
Task description:
Extend the existing `StellaOpsHttpHeaderNames` class in `src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsHttpHeaderNames.cs` (or create a new shared constants file in a lower-level shared lib if Auth.Abstractions is too high in the dependency graph) with ALL canonical header names:
```csharp
public static class StellaOpsHttpHeaders
{
// Identity
public const string Tenant = "X-Stella-Ops-Tenant";
public const string Actor = "X-Stella-Ops-Actor";
public const string Scopes = "X-Stella-Ops-Scopes";
public const string Roles = "X-Stella-Ops-Roles";
public const string Client = "X-Stella-Ops-Client";
public const string Project = "X-Stella-Ops-Project";
public const string Session = "X-Stella-Ops-Session";
// Envelope (Hybrid trust)
public const string IdentityEnvelope = "X-Stella-Ops-Identity-Envelope";
public const string IdentityEnvelopeSignature = "X-Stella-Ops-Identity-Envelope-Signature";
// Observability
public const string TraceId = "X-Stella-Ops-Trace-Id";
public const string RequestId = "X-Stella-Ops-Request-Id";
// Audit
public const string AuditContext = "X-Stella-Ops-Audit-Context";
}
```
Completion criteria:
- [ ] Single source of truth for all header name strings
- [ ] No hardcoded header strings remain in any service — all reference the constants
- [ ] Compiles without errors
### TASK-026-06 — Rename headers in frontend (Angular)
Status: DONE
Dependency: TASK-026-05
Owners: Developer (Frontend)
Task description:
Update all Angular interceptors and services to use the new `X-Stella-Ops-*` naming:
Files to update:
- `core/auth/tenant-http.interceptor.ts` — change `X-StellaOps-Tenant``X-Stella-Ops-Tenant`, remove `X-Stella-Tenant` and `X-Tenant-Id` emissions entirely, change `X-Project-Id``X-Stella-Ops-Project`, change `X-Stella-Trace-Id``X-Stella-Ops-Trace-Id`
- `core/auth/auth-http.interceptor.ts` — change `X-StellaOps-DPoP-Retry``X-Stella-Ops-DPoP-Retry`
- `core/orchestrator/operator-metadata.interceptor.ts` — change `X-Stella-Require-Operator``X-Stella-Ops-Require-Operator`, `X-Stella-Operator-Reason``X-Stella-Ops-Operator-Reason`, `X-Stella-Operator-Ticket``X-Stella-Ops-Operator-Ticket`
- `core/api/gateway-openapi.client.ts` — change `X-Stella-Trace-Id` / `X-Stella-Request-Id``X-Stella-Ops-Trace-Id` / `X-Stella-Ops-Request-Id`
- `core/auth/tenant-activation.service.ts` — update JSDoc references
Create a TypeScript constants file `core/http/stella-ops-headers.ts`:
```typescript
export const StellaOpsHeaders = {
Tenant: 'X-Stella-Ops-Tenant',
Actor: 'X-Stella-Ops-Actor',
Scopes: 'X-Stella-Ops-Scopes',
Project: 'X-Stella-Ops-Project',
TraceId: 'X-Stella-Ops-Trace-Id',
RequestId: 'X-Stella-Ops-Request-Id',
AuditContext: 'X-Stella-Ops-Audit-Context',
DpopRetry: 'X-Stella-Ops-DPoP-Retry',
OperatorReason: 'X-Stella-Ops-Operator-Reason',
OperatorTicket: 'X-Stella-Ops-Operator-Ticket',
RequireOperator: 'X-Stella-Ops-Require-Operator',
} as const;
```
Completion criteria:
- [ ] Zero references to `X-StellaOps-*`, `X-Stella-Tenant`, `X-Tenant-Id`, `X-Scopes`, `X-Project-Id` in `src/Web/`
- [ ] All interceptors use constants from `stella-ops-headers.ts`
- [ ] `ng build` passes
### TASK-026-07 — Rename headers in gateway (Router)
Status: TODO
Dependency: TASK-026-05
Owners: Developer (Backend)
Task description:
Update the gateway to emit and read the new canonical header names:
Key files:
- `src/Router/StellaOps.Gateway.WebService/Middleware/IdentityHeaderPolicyMiddleware.cs` — update the `ReservedHeaders` array and downstream header writes (lines 481-567) to use `X-Stella-Ops-*`
- `src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs` — update header name constants (lines 30-35)
- `src/Router/__Libraries/StellaOps.Microservice.AspNetCore/TenantAccessorPopulationMiddleware.cs` — if it reads tenant headers
During transition, the gateway MUST accept BOTH old and new names on incoming requests (read both, prefer new). On downstream writes, emit ONLY the new names.
Completion criteria:
- [ ] Gateway reads both `X-StellaOps-Tenant` and `X-Stella-Ops-Tenant` on incoming (backward compat)
- [ ] Gateway writes only `X-Stella-Ops-*` downstream
- [ ] `ReservedHeaders` array includes both old and new names (strips both from incoming)
- [ ] All services continue to work after gateway rename
- [ ] Gateway integration tests pass
### TASK-026-08 — Rename headers in backend services
Status: TODO
Dependency: TASK-026-05, TASK-026-07
Owners: Developer (Backend)
Task description:
Update all backend services to read the new canonical header names. Replace all scattered header string constants with references to the centralized constants from TASK-026-05.
High-impact modules (by reference count):
1. **Notifier** (~50 refs to `X-Tenant-Id`) — `src/Notifier/`
2. **Scheduler** (~30 refs to `X-Tenant-Id`) — `src/JobEngine/StellaOps.Scheduler*/`
3. **EvidenceLocker** (~20 refs) — `src/EvidenceLocker/`
4. **Graph** (legacy `X-Stella-Tenant`, `X-Stella-Project`) — `src/Graph/`
5. **Policy** (legacy `X-Stella-Tenant`, `X-Stella-Project`) — `src/Policy/`
6. **Scanner** (legacy `X-Stella-Tenant`) — `src/Scanner/`
7. **Platform** (legacy `X-Stella-Tenant`, `X-Stella-Project`) — `src/Platform/`
8. **Authority** (mixed) — `src/Authority/`
9. **AdvisoryAI** (mostly correct `X-StellaOps-*`) — `src/AdvisoryAI/`
10. **Telemetry** (`X-Tenant-Id`) — `src/Telemetry/`
11. **Concelier** (legacy `X-Stella-TraceId`) — `src/Concelier/`
During transition, services SHOULD accept both old and new names (read new first, fall back to old). This prevents breakage if gateway and services are deployed at different times.
Approach per service:
1. Add dependency on the shared constants package from TASK-026-05
2. Replace all hardcoded header strings with constant references
3. In request context resolvers (e.g., `ScannerRequestContextResolver`, `GraphRequestContextResolver`, `PlatformRequestContextResolver`), read new name first, fall back to old
4. In test helpers/factories, update header names in test request builders
Completion criteria:
- [ ] `grep -r "X-Stella-Tenant\|X-Tenant-Id\|X-StellaOps-\|X-Scopes\b\|X-Project-Id" src/ --include="*.cs"` returns zero matches (excluding the backward-compat fallback reads)
- [ ] All services reference centralized constants
- [ ] All unit/integration tests pass
- [ ] Test helpers updated to use new header names
### TASK-026-09 — Update documentation
Status: TODO
Dependency: TASK-026-04, TASK-026-08
Owners: Documentation author
Task description:
Update the following docs to reflect the new header naming and context propagation approach:
- `docs/modules/platform/architecture-overview.md` — context propagation section
- Module dossiers for Router, Platform, Scanner, Policy if they reference header names
- This sprint's Decisions & Risks section with links to updated docs
Completion criteria:
- [ ] All docs reference `X-Stella-Ops-*` naming
- [ ] Context propagation flow documented (OpenAPI-driven query param injection)
- [ ] No references to legacy header names in `docs/`
---
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-10 | Sprint created with research findings. | Planning |
| 2026-03-10 | Implemented frontend OpenAPI context map bootstrap, rewrote the global context interceptor to use the route map, introduced canonical `X-Stella-Ops-*` frontend header constants, and repaired the stale header cleanup fallout that was breaking `ng build`. Focused specs passed (`27/27`) and `npm run build` passed after the cleanup. | Codex |
| 2026-03-10 | Backend: Added `QueryParameterInfo` to `EndpointSchemaInfo`, extended `ExtractParameters()` to discover query params from `[AsParameters]` records and `[FromQuery]` attributes, updated `OpenApiDocumentGenerator` to emit `parameters` arrays. All three backend libs compile clean (Router.Common, Microservice.AspNetCore, Router.Gateway). Gateway and Platform webservice builds pass. | Implementer |
| 2026-03-10 | Playwright verification: Deployed console + restarted gateway. Confirmed bootstrap chain loads `/openapi.json` before routes. Topbar selectors (Region, Env, Window, Stage) all update URL and propagate context query params (`tenant`, `regions`, `environments`, `timeWindow`, `stage`) to all page links. Network trace confirms correct request ordering. Screenshot captured. | QA |
## Decisions & Risks
### D1: Header naming convention — `X-Stella-Ops-*` (hyphenated)
Decision: Use `X-Stella-Ops-*` as the canonical prefix (hyphen-separated words).
Rationale: Follows HTTP header convention (hyphen-separated), readable, unambiguous.
Note: HTTP headers are case-insensitive per RFC 7230, so `X-StellaOps-Tenant` and `X-Stella-Ops-Tenant` are equivalent at the wire level. The rename is for code consistency and readability.
### D2: OpenAPI-driven vs header-based context propagation
Decision: Use OpenAPI-driven query parameter injection (not headers).
Rationale: Precise (only injects where endpoint declares support), zero backend changes needed, self-documenting via OpenAPI spec, no router middleware required.
Alternative rejected: Sending context as `X-Stella-Ops-Context-*` headers with router-side translation to query params — adds moving parts, wastes bandwidth on endpoints that don't use context.
### D3: Backward compatibility during header rename
Risk: Renaming headers across 150+ files risks breaking services during partial deployment.
Mitigation: Gateway accepts both old and new names on incoming requests. Services read new name first, fall back to old. This allows rolling deployment without coordination.
Timeline: Legacy fallback reads can be removed in a follow-up sprint after all services are confirmed on new names.
### D4: OpenAPI spec currently declares zero query parameters
Risk: The entire OpenAPI-driven approach depends on query params being in the spec (TASK-026-01).
Mitigation: If ASP.NET Core's built-in OpenAPI doesn't auto-detect `[AsParameters]`, add explicit `[FromQuery]` attributes to record properties. Verify with one service first (Platform) before rolling out.
Fallback: If OpenAPI param emission proves complex, the interceptor can use a static fallback map (hardcoded like today but more complete) while the spec catches up.
### D5: Header cleanup scope (~300 references, ~150 files)
Risk: Mass rename across the codebase.
Mitigation: Each module is renamed in a single atomic commit. Use find-and-replace with constant references to prevent typos. Run full test suite per module.
### D6: Frontend header cleanup is shipped ahead of backend constant unification
Decision: Land the Angular-side canonical header constants and client/interceptor cleanup first, while the backend shared constant unification remains a separate follow-through.
Rationale: The web bundle and request layer were already carrying the highest defect risk from mixed header names and stale replacements. Shipping the frontend cleanup immediately removes build/runtime instability without blocking on the wider backend rename.
Remaining work: backend shared constants, gateway dual-read/write cleanup, and the remaining legacy spec/debug assertions under `src/Web/`.
## Next Checkpoints
- Phase 1 done (OpenAPI params in spec): verify with `curl /openapi.json` and snapshot diff
- Phase 2 done (interceptor rewrite): verify no hardcoded route list, context flows to new endpoints automatically
- Phase 3 done (header cleanup): `grep` confirms zero legacy references
- Full integration test: deploy all services, verify login + dashboard + API calls work end-to-end