WordloopWordloop
Decisions (ADRs)

Context-driven fault injection via typed context keys

Test-only force-error signals propagate through the service stack via typed Go context keys rather than domain model fields, preserving domain purity across the transport, service, and provider layers.

0006 — Context-driven fault injection via typed context keys

Status: Accepted Date: 2026-05-05 Deciders: core platform Supersedes: — Superseded by: —

Context

The upload-finalizes-meeting pipeline must support deterministic failure scenarios in test environments:

  • assemblyai — force a transcription error to verify the failed terminal state.
  • openai_permanent — force a synthesis error to verify the completed + is_degraded degraded path.

The original implementation propagated these signals by adding a TestForceError string field to TranscriptionJobMessage — the domain event shared across the HTTP layer, Pub/Sub publisher, Pub/Sub consumer, and the ML service. This approach violated two core principles:

  1. Domain purity. Business entities must not carry test-framework concerns. A TranscriptionJobMessage describes a transcription job, not test behaviour. Domain consumers cannot distinguish whether the field was intentionally set or arrived via a malformed message.
  2. Hexagonal boundaries. Test-control signals belong to the entrypoint layer (HTTP handler / Pub/Sub publisher). Leaking them into the domain forces the service layer — and downstream consumers like wordloop-ml — to be aware of test infrastructure.

Decision

Propagate test-only force-error signals through the call stack using a typed, unexported Go context key defined in the service package:

// internal/core/service/context_keys.go

type forceErrorContextKey struct{}

func WithForceError(ctx context.Context, value string) context.Context {
    return context.WithValue(ctx, forceErrorContextKey{}, value)
}

func ForceErrorFromContext(ctx context.Context) string {
    v, _ := ctx.Value(forceErrorContextKey{}).(string)
    return v
}

Transport → Service boundary (HTTP handler):

// routes/meeting.go — reads X-Test-Force-Error header, injects into context
if fe := c.GetHeader("X-Test-Force-Error"); fe != "" {
    ctx = service.WithForceError(ctx, fe)
}

Service → Provider boundary (Pub/Sub publisher):

// provider/gcp/pubsub_client.go — reads from context, writes to message attribute
if fe := service.ForceErrorFromContext(ctx); fe != "" {
    msg.Attributes["x-test-force-error"] = fe
}

Consumer (ML worker):

The ML service reads the attribute from the InboundMessage.raw_attributes dict using the canonical key "x-test-force-error". The domain TranscriptionJobMessage has no force-error field.

Consequences

Domain purity preserved. TranscriptionJobMessage carries only business data. Domain consumers — including the ML worker's idempotency guard — never see test infrastructure.

Hexagonal boundary enforced. The entrypoint layer (HTTP handler) is the sole point where external test-control signals enter the system. The service and provider layers treat them as opaque context values; only the Pub/Sub publisher serialises them back to wire format.

Type safety. The unexported struct key type (forceErrorContextKey{}) prevents key collisions with other context values — including from third-party packages. An accidental context.WithValue(ctx, "x-test-force-error", ...) call cannot shadow it.

Test isolation. Force-error injection requires a test environment with a valid X-Service-Token. It cannot be triggered from production traffic without the service-to-service secret, making accidental activation in production practically impossible.

Interoperability with Pub/Sub emulator. The force-error attribute travels as a standard Pub/Sub message attribute, allowing the ML consumer to receive it via the GCP Pub/Sub emulator in integration tests without any special test-framework hooks.

Alternatives considered

  • Domain field (TranscriptionJobMessage.TestForceError). Rejected. Pollutes the domain, propagates test concerns to all consumers, and violates hexagonal architecture.
  • Separate test-only message type. Rejected. Duplicates the domain struct and requires parallel serialisation paths in every layer.
  • Sidecar request header forwarded as Pub/Sub metadata. Considered but equivalent to the chosen approach; the chosen approach is more explicit and does not depend on Pub/Sub metadata forwarding infrastructure.
  • In-process feature flag store. Rejected. A shared mutable store introduces global state and makes parallelised integration tests unreliable.

Debt annotation

Principal: None. The pattern uses standard library primitives with zero dependencies.

Interest: Low. The key and accessor functions are tested implicitly by the integration suite. Adding a new fault-injection signal requires only a new helper function and a corresponding attribute name.

Multiplier: Number of test-controlled failure scenarios. If this pattern is used for more than O(10) distinct signals, consider a structured TestControls context value rather than individual keys.

Verification

  • go build ./... in wordloop-core passes with no references to TestForceError in domain packages.
  • grep -r "TestForceError" services/wordloop-core/internal/core/ returns no results.
  • The ML integration test suite (test_slice_ml_upload_finalizes_meeting.py) passes all three scenarios: happy path, transcription failure, and degraded synthesis.
  • The Pub/Sub publisher only writes x-test-force-error when ForceErrorFromContext(ctx) is non-empty; production traffic never sets this key.

On this page