Problem
The five one-shot CLI verbs — migrate, seed, recover-admin, healthcheck, bootstrap-admin — live inside Cluckwork.Api, the web project. The verbs themselves are already reasonably factored (ICliCommand + CliDispatcher under src/Cluckwork.Api/Cli/), so the code organisation is not the issue.
What is tangled is process role. Program.cs interleaves CLI dispatch with serving-process boot guards, and the correctness of several guards depends on where they sit relative to the dispatch rather than on any declared property:
#260 (trusted-proxy guard) is documented as "a serving-process guard placed after the CLI dispatch" — its scope is a function of line order. #319 (AllowedHosts) is a second guard of exactly the same shape.
#331: OTLP endpoint validation ran at service registration, before CliDispatcher, so a plaintext endpoint aborted recover-admin with SIGABRT 134. The break-glass verb — the one that must work when everything else is broken — was killed by a guard meant for the serving process.
Program.cs threads !CliDispatcher.IsCliInvocation(args) into service registration to work around exactly this.
healthcheck is special-cased before host build (it needs no host/DI/DB), which is correct but is another ordering rule held only by a comment.
Every time a boot guard is added, someone has to rediscover "does this apply to migrate?" by reading the order of statements.
Latent bug found while scoping this. IsCliInvocation is derived from CliDispatcher.Commands, which holds only the four verbs that dispatch after Build(). healthcheck is not among them — it is not an ICliCommand (it needs no host, so it takes no WebApplication). So the predicate classifies the container's own health probe as a serving process. Harmless today only because of that early return — i.e. harmless because of statement position, again.
Considered and rejected: a separate console binary
The obvious move is a standalone console app. It costs more than it buys here:
Keep one binary and one image.
Dropped: extracting a Cluckwork.Cli class library
This issue originally also asked for a Cluckwork.Cli class library holding the seven files in src/Cluckwork.Api/Cli/. Dropped, because its stated payoff does not land.
The payoff was "verbs gain unit-level coverage that does not need a web host". But ICliCommand.RunAsync takes a WebApplication — a class library holding the same files still references ASP.NET Core hosting and still needs a fully built web app to run anything. The move delivers namespace churn across seven files plus the test suite, and none of the benefit.
Getting the benefit means changing all five verbs to take something smaller than WebApplication. That is a materially different and larger change, and one that #423's Phase 10 ("convert CLI verbs to contracts") rewrites anyway — so doing it now means doing it twice. #423's target assembly layout also keeps CLI verbs in Cluckwork.Api ("HTTP / CLI / job adapters + composition root").
If the verbs ever stop needing a WebApplication, the split becomes cheap and can be reconsidered then.
Proposed work
Make process role explicit.
Compute a ProcessRole (Serving | OneShot) once, and pass it to each guard instead of relying on ad-hoc booleans and placement rules. Replaces !CliDispatcher.IsCliInvocation(args) threading and the "must be placed after the dispatch" comments.
Target shape: each guard declares which roles it applies to, so the question "does this fire for migrate?" is answered by reading the guard, not by reading Program.cs top to bottom.
Guards to classify explicitly:
#260 trusted proxies — Serving only
#319 AllowedHosts — Serving only
#316 OTLP endpoint validation — Serving only; degrade to export-disabled for one-shot (already the behaviour, but by special case)
#261/#262 Postgres TLS floor — both (a one-shot verb should be held to the same floor)
#264 tzdata/ICU canary — both
The two both-roles guards deliberately take no ProcessRole parameter: an argument nobody branches on implies a branch that does not exist. Their classification is recorded in the decision doc instead, and unconditional code says "applies to both" more strongly than a parameter can.
Acceptance
Timing
Gate cleared: #245 (migration squash) closed 2026-08-02; #339 and #336 are merged.
Context
Raised while reviewing #339. Recurring evidence: #331, #260, #316, #319.
Problem
The five one-shot CLI verbs —
migrate,seed,recover-admin,healthcheck,bootstrap-admin— live insideCluckwork.Api, the web project. The verbs themselves are already reasonably factored (ICliCommand+CliDispatcherundersrc/Cluckwork.Api/Cli/), so the code organisation is not the issue.What is tangled is process role.
Program.csinterleaves CLI dispatch with serving-process boot guards, and the correctness of several guards depends on where they sit relative to the dispatch rather than on any declared property:#260(trusted-proxy guard) is documented as "a serving-process guard placed after the CLI dispatch" — its scope is a function of line order.#319(AllowedHosts) is a second guard of exactly the same shape.#331: OTLP endpoint validation ran at service registration, beforeCliDispatcher, so a plaintext endpoint abortedrecover-adminwith SIGABRT 134. The break-glass verb — the one that must work when everything else is broken — was killed by a guard meant for the serving process.Program.csthreads!CliDispatcher.IsCliInvocation(args)into service registration to work around exactly this.healthcheckis special-cased before host build (it needs no host/DI/DB), which is correct but is another ordering rule held only by a comment.Every time a boot guard is added, someone has to rediscover "does this apply to
migrate?" by reading the order of statements.Latent bug found while scoping this.
IsCliInvocationis derived fromCliDispatcher.Commands, which holds only the four verbs that dispatch afterBuild().healthcheckis not among them — it is not anICliCommand(it needs no host, so it takes noWebApplication). So the predicate classifies the container's own health probe as a serving process. Harmless today only because of that early return — i.e. harmless because of statement position, again.Considered and rejected: a separate console binary
The obvious move is a standalone console app. It costs more than it buys here:
HEALTHCHECKrunsdotnet Cluckwork.Api.dll healthcheck, anddeploy/docker-compose.ymlruns the one-shotmigrateservice withcommand: ["migrate"]on the same image. That shared image is what makes the migrate-before-serve ordering guarantee (Deploy: separate the migration/owner DB role from the runtime app role (no DDL at request-time) #263) work. A second binary means either a second image — doubling the digest-pinning and Trivy surface that Deploy: container/image hardening — non-root USER, pinned base digests, image vulnerability scan #267 exists to control — or two apps in one image, which is the split without the benefit.PostgresConnectionString.NormalizeAndValidate(the Deploy: support URI-form (postgresql://) connection strings — Npgsql's key-value-only parser rejects them #261/Deploy: enforce TLS (sslmode) on every production Postgres connection #262 Production TLS floor), the tzdata/ICU boot assertion (Deploy: farm timezone provisioning — seeded UTC + undocumented tzdata/ICU image dependency #264), the EF context and the Identity stack. A console app building its own host duplicates all of it, and the day it drifts is the daymigrateinterprets a connection string differently from the process serving traffic. Silent, and expensive to detect.healthcheckhas to ship in the serving image regardless.Keep one binary and one image.
Dropped: extracting a
Cluckwork.Cliclass libraryThis issue originally also asked for a
Cluckwork.Cliclass library holding the seven files insrc/Cluckwork.Api/Cli/. Dropped, because its stated payoff does not land.The payoff was "verbs gain unit-level coverage that does not need a web host". But
ICliCommand.RunAsynctakes aWebApplication— a class library holding the same files still references ASP.NET Core hosting and still needs a fully built web app to run anything. The move delivers namespace churn across seven files plus the test suite, and none of the benefit.Getting the benefit means changing all five verbs to take something smaller than
WebApplication. That is a materially different and larger change, and one that #423's Phase 10 ("convert CLI verbs to contracts") rewrites anyway — so doing it now means doing it twice. #423's target assembly layout also keeps CLI verbs inCluckwork.Api("HTTP / CLI / job adapters + composition root").If the verbs ever stop needing a
WebApplication, the split becomes cheap and can be reconsidered then.Proposed work
Make process role explicit.
Compute a
ProcessRole(Serving|OneShot) once, and pass it to each guard instead of relying on ad-hoc booleans and placement rules. Replaces!CliDispatcher.IsCliInvocation(args)threading and the "must be placed after the dispatch" comments.Target shape: each guard declares which roles it applies to, so the question "does this fire for
migrate?" is answered by reading the guard, not by readingProgram.cstop to bottom.Guards to classify explicitly:
#260trusted proxies — Serving only#319AllowedHosts — Serving only#316OTLP endpoint validation — Serving only; degrade to export-disabled for one-shot (already the behaviour, but by special case)#261/#262Postgres TLS floor — both (a one-shot verb should be held to the same floor)#264tzdata/ICU canary — bothThe two both-roles guards deliberately take no
ProcessRoleparameter: an argument nobody branches on implies a branch that does not exist. Their classification is recorded in the decision doc instead, and unconditional code says "applies to both" more strongly than a parameter can.Acceptance
ProcessRolecomputed once and passed to guards; no guard's scope depends on statement orderCliDispatcher.Commandsso a new verb classifies itself;healthchecknamed explicitly, since it structurally cannot come from therebootstrap-adminandrecover-adminstill print secrets to stdout only, never the logger/OTLPHEALTHCHECKand composemigrateservice unchanged — one image, one entrypointAGENTS.mdDeploy: production proxy-trust chain unsolved — silently disables #144 HSTS and the #143 per-IP login limiter #260 bullet corrected — it currently asserts a placement rule that stops being how this worksdocs/decisions/Timing
Gate cleared: #245 (migration squash) closed 2026-08-02; #339 and #336 are merged.
Context
Raised while reviewing #339. Recurring evidence: #331, #260, #316, #319.