ADR — Findings Runtime Score Recompute Async-Migration Trigger

Formerly docs/architecture/ADR-FINDINGS-RUNTIME-SCORE-RECOMPUTE-ASYNC-MIGRATION.md.

FieldValue
StatusAccepted (signed off 2026-04-29 — Architecture lead via user approval)
Date2026-04-29
AuthorsFindings Ledger Runtime sub-team
Modulesrc/Findings/StellaOps.Findings.Ledger/Services/Runtime/RuntimeInstrumentationService.cs, src/Findings/StellaOps.Findings.Ledger.Runtime/RuntimeScoreCalculator.cs
RelatedSPRINT_20260429_013 (score formula), SPRINT_20260429_014 (observability), SPRINT_20260429_015 (runtime documentation), SPRINT_20260429_024_FindingsLedger_runtime_hygiene task FL-RUNTIME-HYG-004

In one line: runtime score recompute runs in-line on the ingest hot path today, which is fine at current load; this ADR pre-commits the migration the on-call will execute when the RuntimeScoreRecomputeP95Threshold alert fires — a single-process Channel + BackgroundService, escalating to a durable outbox + worker only if multi-instance or crash-durability evidence demands it.

Audience: Findings Ledger engineers and the on-call responder for the runtime score-recompute alert. This is a deferred-decision ADR — it records the plan so nobody has to design under incident pressure.

Context

Runtime trace ingest currently runs the score recompute in-line: each POST /runtime/traces call writes the trace, upserts the aggregate, appends a timeline event, and then re-reads the recent aggregate set, runs RuntimeScoreCalculator, and writes the resulting score row before returning 202 Accepted to the agent.

This is the simplest possible design and currently meets the latency target (P95 ingest < 250 ms with Postgres in the loop; per the capacity bench in RuntimeInstrumentationCapacityBench, the in-process recompute itself contributes ≤ 20 ms P95 on synthetic load).

Observability captures the case where this assumption breaks:

Sprint 013 explicitly deferred the async-recompute work and sprint 014 shipped the alert. This ADR records the migration plan we will execute when the alert fires, so the on-call responder does not have to design under pressure.

Decision

When RuntimeScoreRecomputeP95Threshold fires sustained per the trigger conditions below, the default migration path is:

  1. Channel + BackgroundService (single-process, in-memory).
    • Add a bounded Channel<(TenantId, FindingId)> to RuntimeInstrumentationService.
    • The ingest hot path enqueues (tenantId, findingId) and returns 202 Accepted immediately. Score-write moves out of the request.
    • A BackgroundService drains the channel, runs the calculator, and writes findings.runtime_scores. Deduplicates near-identical (tenant, finding) keys arriving in the same batch window.

The migration upgrades to outbox + dedicated worker (durable, horizontally scalable) only if a second observed problem demands it:

2a. Multi-instance Findings.Ledger deployments — multiple processes enqueueing into independent in-process channels would lose coalescing and produce redundant recomputes.

2b. Durability requirement after process crash — a crash between “enqueue” and “drain” loses the recompute pending for that batch until the next ingest for the same finding arrives.

We do not pre-emptively pay the operational cost (extra runtime_score_recompute outbox table, extra worker process, delivery-guarantee logic) of step 2 unless the data justifies it.

Trigger conditions (must be ALL true)

The on-call responder applies the Channel migration only when:

  1. RuntimeScoreRecomputeP95Threshold fires, AND
  2. P95 stays > 100 ms over a contiguous 1-hour window (the alert’s 15m window is the early indicator; a 1h window confirms it is not a transient), AND
  3. No obvious external cause is the proximate driver:
    • Postgres incident (check pg health, replication lag).
    • Recent deploy event (correlate with deploy log).
    • Retention worker actively running large sweeps (check findings_ledger_runtime_retention_deleted_total{rate}).

If a proximate external cause exists, mitigate it first; the recompute itself is healthy at this scale.

Rationale

Why Channel + BackgroundService as the default

Why not jump straight to outbox

We expect the single-process Channel design to be sufficient for multi-year operation at our current per-tenant ingest volumes.

Consequences

After the Channel migration lands

Additionally if outbox migration is later needed

Alternatives considered & rejected

Test coverage (post-migration)

When the Channel migration lands, the following tests must accompany it (these are the binding asserts; the implementer adds them in the migration sprint, not now):

References

Sign-off Window

Accepted on 2026-04-29 by Architecture lead. The 14-day default-accept window from sprint 024 (deadline 2026-05-13) closed early via explicit approval rather than the timeout path. Original window text retained below for history.

Following the project’s RFC default-accept process: if this ADR remains Proposed with no objection logged in this document by 2026-05-13 (14 days from sprint 024 commit), it is automatically promoted to Accepted. Architecture-lead may flip status earlier on review. Objections should be added under “Open Issues” below with name + date.

Open Issues

No objections logged during the sign-off window.