ADR-012: Service Modularity — Independent Operation of Arbitrary Service Subsets

On this page

Status

Accepted

Context

CRAIG’s target adopters include state agencies replacing legacy systems incrementally, counties operating only relevant modules, and organizations evaluating a subset before committing. In all cases, operators need to run some CRAIG services without running others.

The architecture is structurally modular (independent processes, per-service databases, no shared state), but runtime dependencies create implicit coupling:

  • craig-cases calls craig-rules synchronously for safety assessment evaluation — if craig-rules is not running, the safety assessment endpoint returns 500

  • craig-intake calls craig-cases synchronously for report-to-referral conversion — same problem

  • craig-web (BFF) requires all 8 backend URLs in its config — it won’t start if any are missing

  • RabbitMQ event subscriptions exist in cases and placement but handlers are currently no-op debug logging — these are soft dependencies

When this ADR was first written, only two synchronous inter-service HTTP calls existed in the codebase. Three years of feature growth and the 11-adapter data-exchange implementation have expanded that surface substantially. Current inventory (2026-04-20) of synchronous inter-service or outbound HTTP calls:

  • POST /v1/rules/evaluate from craig-cases (investigation screening, rule-driven branching)

  • POST /v1/cases/referrals from craig-intake (convert_report handler, public-report → formal referral)

  • 11 craig-exchange adapters × 2 operations each (test_connectivity + send) = up to 22 outbound HTTP call sites to external partner systems

  • craig-security detection webhook notifications (critical alerts, fire-and-log)

  • craig-intake CAPTCHA verification (Cloudflare Turnstile)

  • craig-intake partner forwarding sink (standalone mode)

All outbound calls now share a single reqwest::Client per service, built via craig_common::build_shared_client(name, version) (one connection pool, consistent 30s timeout / 5s connect timeout / 32 idle connections per origin / <service>/<version> user-agent). The optional-URL + 503 rule from this ADR still governs inter-service CRAIG calls: every cross-service URL remains Option<Url>, absent means the integration is disabled with a clear 503 response rather than a 500.

Three approaches were evaluated:

Approach Complexity Strengths Weaknesses

Optional URLs + 503

Low

Option<Url>, guard clauses, clear 503 responses

Report conversion could work asynchronously since the report is already stored

Full event-driven

High

Complete decoupling; services communicate only via events

Safety assessment needs immediate result for caseworker workflow; fundamentally changes UX

Hybrid sync/async

Medium

Keeps synchronous calls where real-time results are needed; uses events where deferred processing is acceptable

Two integration patterns to maintain

Decision

Adopt a hybrid approach: synchronous HTTP where the user needs an immediate result, event-driven async where deferred processing is acceptable. All inter-service URLs are Option — absent means the integration is disabled with graceful degradation.

Rules

Rule 1: No mandatory service-to-service dependencies at startup

A service MUST start and serve its core functionality without any other CRAIG service present. Infrastructure dependencies (PostgreSQL, RabbitMQ, OIDC provider) are per-service requirements and are not subject to this rule.

Rule 2: Inter-service integrations activated by URL configuration

When a service integrates with another, the downstream URL is supplied via an Option<String> environment variable. If unset, the integration is disabled. The service MUST handle the absent integration gracefully:

  • Return 503 Service Unavailable with an RFC 9457 Problem Details body naming the missing dependency, OR

  • Operate with reduced functionality (e.g., skip rule evaluation), OR

  • Queue the operation for later processing via RabbitMQ

MUST NOT return a generic 500 or panic.

Rule 3: The BFF renders partial deployments gracefully

craig-web MUST NOT hard-fail when a backend URL is unconfigured or unreachable. Navigation items for unconfigured services are hidden. Pages for unreachable (but configured) services show an informative unavailable state.

Rule 4: Helm charts are independently installable

Each Helm service chart is installable without the umbrella chart or any other service chart.

Rule 5: No cross-service database access

Services access other services' data only through HTTP APIs or RabbitMQ events. This rule already holds and is restated as a modularity constraint.

Integration Points

Integration Pattern Env Var Degraded Behavior

cases → rules (safety assessment)

Synchronous HTTP

CRAIG_CASES__RULES_ENGINE_URL

503 "Rules engine unavailable" — safety assessment cannot be completed

intake → cases (report submission)

Synchronous HTTP forwarder

CRAIG_INTAKE__CASES_URL

Per ADR-017, intake is a stateless edge that forwards POST /v1/cases/reports. Conversion to referral happens entirely on the cases side (caseworker action) and emits case.report_converted.

web → all backends

Synchronous HTTP

CRAIG_WEB__*_URL per service

Nav hidden when URL unset; unavailable banner when URL set but service unreachable

Why Safety Assessment Stays Synchronous

A caseworker submits threat factors and protective capacities during an investigation and needs the safety decision immediately to proceed with the workflow. Making this asynchronous would require polling UI, callback handlers, and a "pending evaluation" state — significant UX degradation for a workflow that takes <100ms synchronously.

Why Report Conversion Becomes Event-Driven

When a caseworker converts a public report to a referral, the report is already stored in craig-intake’s database. The referral creation in craig-cases can happen asynchronously. The caseworker needs confirmation that conversion was initiated, not that the referral exists in craig-cases immediately. The existing RabbitMQ infrastructure makes this straightforward.

Reference Deployments

This decision enables the following topologies without code changes:

  • Minimal (2 services): craig-cases + craig-web — case management only

  • Core (4 services): + craig-rules + craig-placement — primary CCWIS workflow

  • Intake-only: craig-intake standalone — public abuse reporting portal

  • Full stack (9 services): complete CRAIG deployment

Rationale

  • Only 2 synchronous calls exist — this is not a systemic problem requiring a systemic (full event-driven) solution.

  • The hybrid approach matches the data flow: safety assessment is request-response; report conversion is fire-and-forget.

  • RabbitMQ is already deployed: event-driven intake conversion uses existing infrastructure.

  • Option<Url> is a one-line change per config field: low implementation complexity.

Consequences

  • State agencies can adopt CRAIG incrementally without deploying the entire stack.

  • Degraded-mode behavior must be explicitly designed and tested per integration point.

  • The report conversion event flow introduces eventual consistency — the referral may not appear in craig-cases for a few seconds after conversion.

  • Documentation must clearly describe which features require which services.

  • Infrastructure dependencies (PostgreSQL, RabbitMQ, OIDC) remain mandatory per service. This ADR addresses only inter-service CRAIG dependencies.

Edit this page · latest