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 thefailedterminal state.openai_permanent— force a synthesis error to verify thecompleted + is_degradeddegraded 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:
- Domain purity. Business entities must not carry test-framework concerns. A
TranscriptionJobMessagedescribes a transcription job, not test behaviour. Domain consumers cannot distinguish whether the field was intentionally set or arrived via a malformed message. - 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 ./...inwordloop-corepasses with no references toTestForceErrorin 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-errorwhenForceErrorFromContext(ctx)is non-empty; production traffic never sets this key.
Related
Docs are canonical knowledge and skills are the agent execution layer
We keep durable engineering guidance in the docs site and keep agent skills focused on triggering, context routing, tool use, safety, and verification.
A transcription failure after audio compose still closes the recording as completed
Once the final audio artefact is composed and sealed, a failed transcription write-back closes the recording as completed rather than failed, and provisional talking points are refused with 409 once the recording leaves the live phase.