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.mdAbsorbing 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 anonymous full-configuration dump at GET /config-info (#290) and the four pre-existing test failures (#288).

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) this MR
3 Stateless slices under /api/bmo/*, Dapper → EF for the query/mutation services; one MR per slice next
4 SQL-backed state store, then the stateful slices (fleet, export, import, stage removal)
5 CLI retarget + --strict; BenefitManager pipeline cutover; retire Benefits.CommandCenter
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.