Skip to content

refactor(api): make process role explicit for boot guards #347

Description

@mforce

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

  • ProcessRole computed once and passed to guards; no guard's scope depends on statement order
  • The one-shot verb list is derived from CliDispatcher.Commands so a new verb classifies itself; healthcheck named explicitly, since it structurally cannot come from there
  • All five verbs keep their exact CLI contracts (verb names, exit codes, stdout/stderr behaviour) — bootstrap-admin and recover-admin still print secrets to stdout only, never the logger/OTLP
  • Existing CLI tests keep passing
  • Dockerfile HEALTHCHECK and compose migrate service unchanged — one image, one entrypoint
  • A regression test that a serving-only guard does not abort a one-shot verb (the fix(api): require secure and redacted OTLP endpoint configuration in Production (#316) #331 shape), two-sided: the same configuration must be shown to actually fail a serving boot, or the assertion is vacuous
  • Mutation evidence recorded (green baseline, each mutant red, restored green)
  • AGENTS.md Deploy: 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 works
  • Decision record in docs/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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions