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

51 lines
2.8 KiB
Markdown

# 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.