Files
clean-architecture-backend-…/docs/adr/ADR-GRPC-003-three-axis-execution-evidence.md
T

2.8 KiB

ADR-GRPC-003: Transport, business and stream evidence are three axes, and none implies another

  • Status: accepted
  • Date: 2026-08-30
  • Scope: :grpc:grpc-core-api, :grpc:grpc-policy, :grpc:grpc-testkit

Context

A failed RPC produces a status code, and a status code is not an answer to the question the caller actually has. DEADLINE_EXCEEDED on a mutation does not say whether the mutation happened; UNAVAILABLE after the request was sent does not say the server never saw it; response headers arriving does not say a transaction committed.

Every one of those is a place where a plausible inference produces a duplicate write or a lost one, and none of them is visible in a test that only exercises the happy path.

Decision

Model what happened as three independent axes, and refuse the inferences between them.

GrpcTransportEvidence records what the client observed on the wire, and distinguishes NOT_SENT — the client watched its own send fail — from UNOBSERVED, which is every other case where nothing is known. GrpcBusinessEvidence records what the application confirmed, with COMMIT_UNKNOWN as a real state rather than a placeholder. GrpcStreamEvidence is a sealed hierarchy whose non-empty cases all carry a position, because "partial" without a last sequence can be neither resumed nor reconciled.

GrpcExecutionEvidence holds all three and rejects combinations nobody could have observed: a unary call with stream evidence, or a request the client watched fail to send that nonetheless carries business evidence. Promoting response headers to a confirmed commit is possible only by editing withResponseHeadersSeen, which is one method rather than a plausible line in an interceptor.

GrpcCompletionOutcome.forMutation derives what a caller may conclude, and defaults DEADLINE_EXCEEDED and post-send UNAVAILABLE on a mutation to COMPLETION_UNKNOWN.

The same types are used by the failure model and by the observation convention, so an incident has one account of a call rather than two.

Consequences

A whole class of retry bug becomes unrepresentable. GrpcRetryEligibility reads all three axes plus the idempotency profile; a caller cannot reach "retry" from a status alone because the status alone is not an input.

The fault lane has something to check. GrpcTransportEvidenceClassifier turns a client's observations into evidence and refuses to infer NOT_SENT from an unobserved state — and the lane exercises it against a real connection dropped mid-call, not against a mock.

Callers must handle a third outcome. COMPLETION_UNKNOWN is not a failure and not a success, and a caller that treats it as either is wrong. GrpcOperationStatusQuery and GrpcCompletionReconciler exist so that resolving it is a supported path rather than an exercise for the caller.