Skip to content

ADR 0005 — CommandCenter.WebApi absorbs BMO's HTTP API#

Status Accepted
Date 2026-09-22
Authors Ruben Knuijver
Supersedes —
Superseded by —
Related ADR 0004 (BMO lives in this repository); MRs !234 (Phase 0), !235 (Phase 1); issues #288, #289, #290

Context#

ADR 0004 brought BMO into this repository but deliberately fenced it off: its own solution, its own MSBuild roots, "nothing under backend/ references it at build time". The fence had a cost. The same concepts existed twice — CommandCenter.WebApi carried hand-copies of BMO's package store and agent client, and both could drift silently from the originals. More fundamentally, two services serve one set of tables: BMO reads and writes TblCustomerStage, TblServerLog, TblDeployLog, TblToolSchedule, TblApplicationVersion and TblServerVersion with raw Dapper, while CommandCenter.WebApi owns them through EF Core and its migrations. BMO used Dapper for one reason: the EF context lived in WebApi.

The SPA already consumes all of BMO's ~41 routes through the BFF's cluster-bmo proxy at /api/bmo/*, newer version-management routes included. The gap is not the frontend; it is that a second backend process exists for an API that belongs next to the data it queries.

Three facts constrain the shape of the change:

  • Benefits.Agent is why BMO exists. It replaced a legacy CommandCenter implementation that managed BenefitManager hosts over WMI and SMB shares. It stays as it is, authentication aside.
  • The bmo CLI (Benefits.Orchestrator) is production automation. BenefitManager's CI/CD uses it to push packages and install versions onto servers running an Agent; it also manages versions and customer import/export. Every one of its operations goes through the CommandCenter API — it never talks to an Agent directly — so moving the API moves everything the CLI does.
  • BMO holds state in memory (six singleton stores, two bounded channels) and WebApi runs 1–10 replicas. A job accepted on one replica and polled on another is a 404. Anything absorbed must keep its state in the database, as BackgroundTasks/ already does.

Decision#

  1. CommandCenter.WebApi serves BMO's API natively under /api/bmo/*. The prefix is transitional: it keeps the SPA's bmoAxios base URL unchanged and sidesteps the one hard route collision (GET /api/customers exists in both). The standalone Benefits.CommandCenter service and the BFF's cluster-bmo proxy retire once the CLI has been retargeted and exercised by BenefitManager's pipeline — not before.
  2. Benefits.Agent and Benefits.Agent.ServiceBus are not touched. WebApi consumes BMO's libraries (Benefits.PackageStore, Benefits.Agent.Client, Benefits.Contracts) by ProjectReference; it never references the two net10.0-windows projects.
  3. The CLI retargets to WebApi through a configurable API prefix (default /api/bmo) and keeps doing package push, deploy, versions and customer import/export. bmo package push gains a --strict flag, default off, so a pipeline can choose to go red when CommandCenter registration fails instead of today's warning-and-exit-0.
  4. Query and mutation services move from Dapper to EF as CQRS handlers over CommandCenterContext — the consolidation this is for. The two streaming services (export/import, SqlBulkCopy, snapshot isolation) stay Dapper, reached through WebApi's IDbFactory.
  5. Stateful slices get SQL-backed state first, reusing the TblBackgroundTask row-claiming pattern; no in-memory stores and no session affinity.
  6. Multi-environment comes last, as an environment catalogue built from ConnectionStrings:CommandCenter_{env} keys with one control database (tasks, schedules, leases) and N environment databases, behind a /api/bmo/environments endpoint the SPA can trust.
  7. Authentication posture. Absorbed endpoints adopt WebApi's current posture: no RequireAuthorization(), the BFF is the boundary. BMO's ApiKey/mTLS handlers are not ported — they would be a second credential to rotate for a system whose authorization is due a redesign (Keycloak, OPA) that this work must not pre-empt. What is kept is the seam: BMO's per-service, configuration-driven IdP switch (useEntraId = !string.IsNullOrEmpty(AzureAd:ClientId)) is the shape a Keycloak cutover would flip. The CLI works unauthenticated behind the BFF exactly as the SPA does today; that is what "authentication can be disabled" means here.
  8. Conventions for every absorbed endpoint are fixed before the first one lands — route prefix, folder layout, endpoint naming, hosted-service guard, server-emitted URLs, body limits, wire-form check. They are the mechanical part of this decision and live in backend/CLAUDE.md → Absorbing BMO endpoints into WebApi, so they are read by whoever ports a slice rather than re-litigated per slice.

Consequences#

Gained. One data stack over one set of tables; one HTTP surface for the SPA and the CLI; one place for the OpenAPI contract (openapi/web-api.yaml, already drift-checked in CI, so every absorbed slice is a reviewable spec diff); the duplicate package-store and agent-client code is already gone (!235).

Accepted costs.

  • YARP's api-bmo catch-all shadows WebApi. A native /api/bmo/<slice> is unreachable until a more specific, higher-priority route sends it to cluster-next. Every slice ships with that route change; the first slice proves the mechanics.
  • Wire form must be diffed per slice: WebApi's JSON options (string enums, camelCase) apply where BMO used source-generated contexts, and the SPA's Zod schemas validate whatever arrives.
  • The CLI cutover has an ordering constraint the code cannot enforce: retarget, run a real package push --strict and deploy from BenefitManager's pipeline against WebApi, confirm the version appears and agents receive it — then delete the service. Deleting first leaves the pipeline pushing into a void with exit code 0.
  • Benefits.CommandCenter keeps running, unchanged, for the duration. Its known defects are tracked and not fixed in passing: the four pre-existing test failures (#288). The anonymous full-configuration dump at GET /config-info (#290) was the exception, and is removed.

Explicitly not doing. Moving BMO under the root Directory.*.props; a committed openapi/bmo.yaml for a service being deleted; porting BMO's Serilog or auth wiring into WebApi; converting the streaming exporter/importer to EF; a multi-tenancy framework for two or three environments; a big-bang port of all 41 routes; any Keycloak integration.

Sequencing#

Phase Content State
0 .slnx, all 16 BMO projects in the solution, one test runner !234
1 WebApi references Benefits.PackageStore + Benefits.Agent.Client; hand-copies deleted !235
2 Conventions (this ADR, backend/CLAUDE.md, AddHostedServiceUnlessGeneratingOpenApi) !236
3 Stateless slices under /api/bmo/*, Dapper → EF for the query/mutation services; one MR per slice in progress — slice 1 versions (/api/bmo/versions, !240), slice 2 inventory (/api/bmo/installations, !241), slice 3 logs (/api/bmo/logs, !242), slice 4 capabilities (/api/bmo/capabilities, !243), slice 5 packages (/api/bmo/packages, !246), slice 6 customers/stages reads (!248); the /api/agents groups follow ADR 0007 (agent registry, per-agent transport)
4 SQL-backed state store, then the stateful slices (fleet, export, import, stage removal) in progress — fleet (/api/bmo/fleet/state from TblAgentHeartbeat, #304); stage removal is retired in favour of WebApi's stage-delete rather than ported; export/import on shared blob storage (CustomerData:Storage, #307); export ported as the customer-data-export task with TblCustomerDataExport and the customer-data-prune job (#313); import ported as durable upload sessions (TblCustomerDataUpload) and the resumable customer-data-import task (#314); Customers-page dialogs (#315). The SPA no longer calls BMO for customer data (#316); BMO's own endpoints stay, for the bmo CLI, until Phase 5 retires the service
5 CLI retarget + --strict; BenefitManager pipeline cutover; retire Benefits.CommandCenter. manage remove-stage moves to WebApi's stage-delete: --force → POST /api/customers/stages/{id}/deactivate; otherwise DELETE /api/customers/stages/{id} and poll /api/background-tasks/{taskId} (inactive + offline required; the database is dropped after seven days, cancellable) (#306). manage customer-data moves to WebApi's /api/bmo/customers/{environment}/… (#316): export — POST …/export answers 202 with jobId (= the task id), statusUrl and downloadUrl, and status, ranged download and DELETE keep BMO's shapes, so only the base path changes; import — sessions, chunks and analyze keep BMO's shapes, but commit answers 202 with the task instead of 200 with the result: poll GET …/sessions/{id} until Completed or Failed, then read GET …/sessions/{id}/result; errors are problem details whose detail the CLI can print; show is offline and unchanged. The CLI's hard-coded leading-slash paths drop any path in BaseUrl, so they need the new prefix, not just a new base address
6 Multi-environment catalogue

Deferred, with a finding attached#

"Cross-platform logic to WebApi, Windows-only stays in CommandCenter" was part of the original ask. The premise does not hold as stated: CommandCenter targets net10.0 and its Domain/WindowsServices/ is a MassTransit request plus an EF context, no Windows API. The genuinely OS-bound projects are CommandCenter.BenefitsAgent, CommandCenter.SystemStatsAPI, Benefits.Agent and Benefits.Agent.ServiceBus, and that split already exists. The real question — what remains in CommandCenter once WebApi owns the API surface, and whether it should exist at all — is a separate investigation after Phase 1.