Sigra.Doctor (Sigra v1.0.0)

Copy Markdown View Source

Pure diagnostic module that builds Sigra's nine-feature optional-dependency matrix, validates boot-time wiring for configured features, and returns a structured diagnosis result.

Design

Sigra.Doctor is a pure library module — no IO calls, no Mix.Task dependency, no side effects. All diagnostic logic lives here so that security-sensitive checks ship via mix deps.update rather than being locked in a generator or task layer.

The Mix task Mix.Tasks.Sigra.Doctor is a thin formatter + exit shell that calls run/1 here, formats the structured result, and handles exit codes. All IO formatting stays in the task.

Injection Seam (D-04)

diagnose/1 accepts injected inputs so the matrix and wiring logic are unit-testable without toggling the ambient dep tree (since Code.ensure_loaded? cannot be toggled per-test). The injection keys are:

  • :predicates — a map of predicate name atom → boolean, overriding individual Sigra.OptionalDeps calls.
  • :host_sigra — the raw keyword config list (same shape as the two-hop config read in Sigra.Application).
  • :oban_running — a boolean override for both async-email and audit-forwarder Oban supervision checks (replaces the real internal oban_running?/1 call in Sigra.Audit.Forwarders). When not provided, the real function is called.
  • :module_loaded? — a (module :: atom() -> boolean()) function override for Code.ensure_loaded?/1 used in the D-09 #4 forwarder-module-loaded check. Allows deterministic unit-testing of the not-loaded branch without relying on a module name that happens not to be defined in the runtime.

When injection keys are absent, the real functions are called:

OptionalDeps SOT (D-06)

This module consumes Sigra.OptionalDeps predicates directly and must NOT call Code.ensure_loaded? itself to re-implement those checks — preserving the single source of truth established in Phase 137.

The one narrow exception is the dynamic forwarder-module check: configured forwarder modules are host-provided dynamic atoms that cannot be listed in OptionalDeps, so Code.ensure_loaded?(module) is called directly for that check (mirrors the internal attach_forwarders/0 in Sigra.Application).

Deliberate Omissions (D-08)

This module deliberately does NOT call:

  • the internal verify_vault!/1 in Sigra.Application — it raises, which would abort the report before the full matrix prints.
  • the internal attach_forwarders/0 in Sigra.Application — it raises ArgumentError on async-without-Oban, which would abort.

Instead, doctor uses the non-raising mirrors: Sigra.OptionalDeps.encryption_active?/1 and the internal oban_running?/1 in Sigra.Audit.Forwarders.

Summary

Functions

Diagnoses the optional-dependency wiring for all nine Sigra features.

Runs the diagnostic and returns the same structured map as diagnose/1.

Types

diagnosis()

@type diagnosis() :: %{
  rows: [feature_row()],
  wiring: [String.t()],
  verdict: :ok | :fail
}

feature_row()

@type feature_row() :: %{
  feature: atom(),
  deps: [String.t()],
  state: :missing | :available | :loaded_active | :configured_but_missing,
  hint: String.t()
}

Functions

diagnose(opts \\ [])

@spec diagnose(keyword()) :: diagnosis()

Diagnoses the optional-dependency wiring for all nine Sigra features.

Returns a structured map with:

  • :rows — list of nine feature row maps, each with :feature, :deps, :state, and :hint keys.
  • :wiring — list of wiring check failure strings (empty when all wiring is OK).
  • :verdict:ok when no configured features are misconfigured; :fail when any configured feature has broken wiring or a missing required dependency.

Injection Options (for testing; all optional)

  • :predicates — map of predicate atom → boolean. Keys: :oban, :bcrypt, :eqrcode, :threadline, :assent, :swoosh, :joken, :hammer, :req, :encryption_active.
  • :host_sigra — raw keyword list of host application Sigra config.
  • :oban_running — boolean override for the Oban supervision check.
  • :module_loaded? — a (module :: atom() -> boolean()) function override for Code.ensure_loaded?/1 used in the D-09 #4 forwarder-module-loaded check. Allows deterministic unit-testing of the not-loaded branch without relying on a module name that happens not to be defined.

run(opts \\ [])

@spec run(keyword()) :: diagnosis()

Runs the diagnostic and returns the same structured map as diagnose/1.

This is the entrypoint called by Mix.Tasks.Sigra.Doctor. All IO formatting and exit logic lives in the Mix task, not here.

Accepts the same injection options as diagnose/1. Output verbosity is handled by Mix.Tasks.Sigra.Doctor; this function always returns the full structured diagnosis.