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.Agentis 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
bmoCLI (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#
CommandCenter.WebApiserves BMO's API natively under/api/bmo/*. The prefix is transitional: it keeps the SPA'sbmoAxiosbase URL unchanged and sidesteps the one hard route collision (GET /api/customersexists in both). The standaloneBenefits.CommandCenterservice and the BFF'scluster-bmoproxy retire once the CLI has been retargeted and exercised by BenefitManager's pipeline — not before.Benefits.AgentandBenefits.Agent.ServiceBusare not touched. WebApi consumes BMO's libraries (Benefits.PackageStore,Benefits.Agent.Client,Benefits.Contracts) byProjectReference; it never references the twonet10.0-windowsprojects.- 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 pushgains a--strictflag, default off, so a pipeline can choose to go red when CommandCenter registration fails instead of today's warning-and-exit-0. - 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'sIDbFactory. - Stateful slices get SQL-backed state first, reusing the
TblBackgroundTaskrow-claiming pattern; no in-memory stores and no session affinity. - 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/environmentsendpoint the SPA can trust. - 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. - 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-bmocatch-all shadows WebApi. A native/api/bmo/<slice>is unreachable until a more specific, higher-priority route sends it tocluster-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 --strictanddeployfrom 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.CommandCenterkeeps running, unchanged, for the duration. Its known defects are tracked and not fixed in passing: the anonymous full-configuration dump atGET /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.