feat: grpc 기능 deep 구현

This commit is contained in:
DongHyeonka
2026-08-31 07:28:39 +09:00
parent a24ece9cf7
commit 21234e38cd
400 changed files with 40217 additions and 4 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
# ADR-GRPC-001: The gRPC platform ships as a registered family, not as one adapter leaf
- Status: accepted
- Date: 2026-08-30
- Scope: `:grpc:*`, `:grpc-advanced:*`, `src/config/architecture/modules.json`
## Context
The two source plans describe a type-safe gRPC execution platform with its own API, SPI, adapters and
composition root: fifteen Stable modules under `modules/grpc` and sixteen Advanced ones under
`modules/grpc-advanced`, on Gradle Kotlin DSL, in package `io.backend.skeleton.grpc`, against
Spring Boot 4.1.
None of that layout exists here. This repository uses Groovy DSL, a fail-closed module registry that
owns the leaf list, package root `dev.caskeleton`, and Spring Boot 4.0.8. The plans anticipate this:
their last Global Constraint says that when the repository structure differs, file paths are remapped
and the public contracts, invariants and test meanings are not changed.
Two shapes were available. Fold the platform into the existing `:adapter:inbound:grpc` leaf as
packages — which is what the JPA, GraphQL, WebSocket and HTTP platforms did here — or register it as
a family the way `messaging:*` is registered.
## Decision
Register it as a family: twelve Stable leaves under `src/grpc/` and six Advanced ones under
`src/grpc-advanced/`.
The deciding property is that this is not a layer of this application. Root `CLAUDE.md` already
describes `messaging:*` as "a vendored messaging platform: a product with its own API, SPI, adapters
and composition boundary, not a layer of this application", and the gRPC platform is the same shape
for the same reason — the application is meant to reach it the way it reaches a library, through an
application-owned port. The four platforms that became packages are all layers of this application;
this one is not.
The split between `src/grpc/` and `src/grpc-advanced/` is not organisational. The Stable plan
requires that the Stable starter's build fail if it reaches an Advanced module, and separate Gradle
path prefixes make that a `verifyCleanArchitectureDependencies` failure rather than a review note:
`grpc-spring-boot-starter`'s registry entry names no advanced id, and it cannot acquire one silently.
## Consequences
**The registry grew from 44 leaves to 62.** That is a large registry change, made deliberately and in
one place. Every new leaf is `runtime_memberships: []`, so nothing ships until a second, explicit
decision moves it.
**The advanced boundary is checked twice.** Once by the registry at build time, and once by
`GrpcStableBuildInvariant` at runtime, because a fat jar or a shaded artifact is assembled by
something the registry never sees.
**Four testkit modules became four test lanes.** The plan's split exists so in-process results cannot
be mistaken for network results; this repository expresses that with `ca.strict-test-lane`, whose
lanes fail when they discover nothing and never serve an up-to-date result. `GrpcEvidenceGrade` keeps
the same rule inside the code, so a report cannot cite a contract run as transport evidence.
**Codegen is not wired.** See ADR-GRPC-002.
@@ -0,0 +1,62 @@
# ADR-GRPC-002: Schema governance runs without protoc and without the Buf CLI
- Status: accepted
- Date: 2026-08-30
- Scope: `:grpc:grpc-proto-contract`, `:grpc:grpc-codegen`
## Context
Stable Tasks 8 through 11 require proto style rules, Buf format/lint/breaking governance, a single
Java codegen owner, and a descriptor artifact whose consumer-compile result gates a release.
Two of the tools those tasks name are absent from this toolchain. The Buf CLI is not installed. And
`protoc` is available through the Gradle protobuf plugin, but every leaf in this repository passes
spotless with google-java-format, checkstyle, SpotBugs at HIGH confidence, Error Prone and `-Werror`
— and generated protobuf sources pass none of them. Turning codegen on means excluding a source set
from five quality gates.
There is precedent for such an exclusion: the `jmh` source set has `spotbugsJmh` and `checkstyleJmh`
disabled and Error Prone off. So the carve-out is available. It is also a decision about the quality
baseline of a leaf, taken for one task, and outside what this work was asked to change.
`adapter:inbound:grpc` also carries a recorded decision in the opposite direction: its `CLAUDE.md`
forbids the protobuf plugin and `.proto` in that leaf, on the grounds that a consuming feature module
should own its schema.
## Decision
Commit the `.proto` sources and implement every rule the tasks require as executable Java, with no
protoc run and no Buf CLI invocation.
`GrpcProtoContractValidator` reads `.proto` text and enforces proto3 syntax, the
`{organization}.{domain}.v{major}` package rule, `java_multiple_files`, a generated Java package
disjoint from the hand-written one, `_UNSPECIFIED` enum zero values, `reserved` declarations checked
against a supplied removal history, a well-known-type allowlist and a map-field allowlist. It runs
against the committed schema in its own test, so the shipped `.proto` files are live rather than
decorative.
`GrpcBufPolicy` fixes the breaking gate at Buf's `FILE` category and names the four lifecycle stages
a compliant pipeline registers. `GrpcCodegenManifest` fixes one codegen owner and refuses a literal
generator version. `GrpcDescriptorArtifact`, `GrpcConsumerFixture` and `GrpcSchemaArtifactPublisher`
carry the schema hash, the descriptor digest and the per-consumer source-break report, and refuse a
publish that breaks a consumer or republishes a released version with different bytes.
The committed `buf.yaml` states the same rules, so running the CLI in an environment that has it
reaches the same verdict.
## Consequences
**The invariants are enforced; the process is not run.** Everything Tasks 8 to 11 are about — which
schema changes are refused, which consumer breaks block a release, who owns generation — is a
build-checkable rule here. What is missing is the protoc invocation and the Buf binary.
**Turning codegen on is a bounded change.** `GrpcCodegenManifest.caSkeleton()` already names the
owner, the managed version source, the build-directory output paths and the disjoint package policy
that a real plugin configuration has to satisfy. The work is a source-set carve-out and a plugin
block, not a redesign.
**The fixtures use a text codec.** `GrpcTextCodec` gives the testkit a UTF-8 marshaller so the
in-process and Netty lanes can exercise interceptors, status mapping, metadata limits and stream
sequencing without generated stubs. Those contracts are properties of the platform and the transport,
not of any message shape, so the substitution costs nothing — and the lanes run today rather than
after codegen lands.
@@ -0,0 +1,50 @@
# 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.
@@ -0,0 +1,52 @@
# ADR-GRPC-004: One retry owner, and keyed mutations need a durable ledger
- Status: accepted
- Date: 2026-08-30
- Scope: `:grpc:grpc-policy`, `:grpc:grpc-operation-ledger-jpa`, `:grpc:grpc-core-api`
## Context
Three layers can retry a gRPC call: the application, the channel's service config, and a service
mesh. Their effects multiply. Three attempts at each layer is twenty-seven requests for one call, and
the load arrives exactly when the dependency is already failing.
Separately, a mutation that is safe to repeat needs somewhere to record that it ran. Without one, a
retry after a lost response either duplicates the effect or drops it, and nothing distinguishes the
two afterwards.
## Decision
**Exactly one retry owner per channel.** `GrpcRetryOwner` has four values including `NONE`, which is a
decision rather than an omission. `GrpcServiceConfigPolicy` refuses an in-process retry entry when the
owner is the mesh or nobody, and `GrpcRetryOwnershipValidator` compares the service config's method
names against the policy catalog — a renamed method leaves its retry entry matching nothing, silently,
and the method then runs with channel defaults.
**Retry eligibility reads the method, the evidence and the status together.**
`GrpcRetryEligibility` refuses a non-idempotent method outright, refuses any call whose stream
delivered a prefix, and turns a `DEADLINE_EXCEEDED` or post-send `UNAVAILABLE` mutation into
"resolve the completion first" rather than a retry.
**A keyed mutation is retryable only with both a caller key and a durable ledger.**
`GrpcOperationLedger` is a port in `grpc-core-api`, so the policy layer can require durable
idempotency without depending on a database. Its `claim` contract is a single atomic insert-or-read
against a unique constraint: `JpaGrpcOperationLedger` inserts first and reads on constraint violation,
because a read-then-insert implementation has a window exactly as wide as the race it closes and
passes every test that does not run two attempts concurrently.
The identity is caller fingerprint plus full method plus hashed key. All three are load-bearing:
without the caller, one tenant's key suppresses another's write; without the method, a key reused
across operations makes the second a replay of the first.
## Consequences
**A budget bounds retries as a fraction of traffic.** `GrpcRetryBudget` degrades to roughly no
retries when everything is failing, which is the behaviour that lets a dependency recover.
**The ledger and the mutation should commit together.** `JpaGrpcOperationLedger` carries no
transaction annotations, deliberately: a `REQUIRES_NEW` would put the claim in its own transaction and
reintroduce the window where the write is durable and the claim is not.
**A key reused for a different request is a caller error, not a duplicate.** The stored request
fingerprint turns that into `FAILED_PRECONDITION` rather than silently returning the first request's
answer.
@@ -0,0 +1,50 @@
# ADR-GRPC-005: One writer per stream, a bounded queue, and resume that refuses to guess
- Status: accepted
- Date: 2026-08-30
- Scope: `:grpc:grpc-policy`
## Context
`StreamObserver` is not thread-safe, and the failure when two producers call `onNext` concurrently is
not an exception — it is interleaved bytes, which a client decodes as a corrupt message or, worse, as
a valid one it should never have received.
Two further properties of server streams are easy to get wrong in ways that look healthy. A consumer
that falls behind either terminates the stream or silently loses messages, and the second leaves a
client with a stream that appears fine and is missing changes. And a reconnect either continues from
a position the server can still replay, or skips whatever is no longer there.
## Decision
**A bounded queue drained by one writer.** `GrpcSerializedStreamWriter` accepts messages from any
thread and hands them to the transport only from `flush`, which is synchronized. `write` returning
`ACCEPTED` means queued, and the name is deliberately not `sent`: the transport call returns as soon
as bytes are handed over, so no method here can honestly report delivery.
**Both a message bound and a byte bound.** Either alone is unbounded in the other dimension.
`GrpcFlowControlPolicy` also takes the transport's own readiness signal, because a writer that relies
only on its queue bound produces as fast as it can allocate.
**Termination is the default for a slow consumer.** `GrpcSlowConsumerPolicy.DROP_OLDEST` exists for
feeds whose business meaning tolerates loss, and is not the default, because a client cannot detect
dropped messages: the sequence numbers it sees are the ones it was sent.
**Resume is refused rather than faked.** `GrpcStreamGapDetector` requires a signed, unexpired token
whose caller and filter fingerprints match the current request, refuses one whose snapshot version
moved, and returns `FULL_RESYNC_REQUIRED` when the cursor predates retained history. `GrpcResumeToken`
carries a key id so the signing key can rotate without invalidating every outstanding token.
## Consequences
**A stream carries an envelope, not a bare payload.** `GrpcStreamEnvelope` holds the stream id,
generation, sequence, snapshot version and resume token, because resume, gap detection and drain all
need a position and a generation.
**Four clocks, not one.** `GrpcStreamLifetimePolicy` separates setup deadline, idle timeout, max
duration and heartbeat interval, and refuses combinations where one can never fire. Merging any pair
produces a familiar bug: an idle timeout used as a max duration kills healthy busy streams.
**A heartbeat is a liveness signal and nothing else.** It is not an application acknowledgement and
not an ordering guarantee; `GrpcStreamHeartbeat` says so in the place somebody would otherwise reuse
it.
@@ -0,0 +1,68 @@
# ADR-GRPC-006: Stable discovery is DNS and static, and a Kubernetes profile names who balances
- Status: accepted
- Date: 2026-08-31
- Scope: `:grpc:grpc-discovery`, `:grpc:grpc-client`
## Context
A gRPC channel's discovery configuration has a failure mode with no runtime symptom: it works, and
it does not do what the dashboard says it does.
The specific case is `round_robin` over a Kubernetes Service ClusterIP. The Service is one virtual
address, so the resolver returns one endpoint and the client-side balancer has nothing to rotate
across; kube-proxy picks a pod at connect time, and an HTTP/2 connection is long-lived, so every
request from that client goes to the same pod for the life of the connection. Nothing fails. The
configuration says `round_robin`, the metrics show requests spread across clients rather than pods,
and the conclusion "we have client-side load balancing" is wrong in a way nobody is prompted to
check.
The mirror-image mistake is `pick_first` over a headless record, which pins a client to one pod out
of many.
Separately, a service mesh changes who owns retries, and a deployment that adds mesh routing without
removing its own retry policy has two retriers whose effects multiply.
## Decision
**Stable resolvers are Static, DNS and Unix domain socket; Stable load balancing is `pick_first` and
`round_robin`.** `GrpcDiscoveryPolicyValidator.requireStableScheme` refuses `xds`, `consul`, `etcd`
and `eureka` by name, with a message saying they are Advanced capabilities with their own control
plane and promotion gate rather than unknown schemes.
**The pairing is checked against the resolved address count, not against intent.**
`GrpcResolverProfile` carries `expectedAddressCount`, and `GrpcStableLoadBalancer.effective` answers
whether the policy distributes anything over that many endpoints. A `round_robin` profile over one
address is a reported violation whose message says it describes spreading that is not happening.
**A Kubernetes deployment names its routing mode**, and the mode implies both the balancer and the
retry owner. `GrpcKubernetesRoutingMode` has three values — `K8S_VIP`, `K8S_HEADLESS`, `MESH` — and
`GrpcKubernetesProfile` refuses a mesh profile whose retry owner retries in-process.
**A profile that carries long-lived streams must state a reconnect budget and a readiness drain
grace.** A stream pins a client to one pod for its whole life, so every rollout, eviction and
scale-down ends it. `GrpcKubernetesProfileValidator` additionally reports a VIP profile carrying
long streams, and a drain grace shorter than the reconnect budget — the second means the pod stops
serving before its clients have finished reconnecting elsewhere.
**A DNS profile must refresh.** `GrpcResolverProfile` refuses a zero refresh interval on DNS,
because a channel that resolved once at startup keeps sending to addresses that stopped existing an
hour ago, and the resulting `UNAVAILABLE` looks like an unhealthy deployment long after the rollout
finished.
## Consequences
**Two validators, not one.** `GrpcDiscoveryPolicyValidator` asks whether a balancer does anything
over the addresses it will see; `GrpcKubernetesProfileValidator` asks whether the deployment shape,
the retry owner and the stream obligations agree. A deployment can have a coherent resolver profile
and still have put retries in two places, so merging them would let one answer hide the other.
**`expectedAddressCount` has to come from somewhere.** It is a declared number, and a declaration can
be wrong. It is still better than the alternative, which is not comparing anything: a wrong
declaration is a wrong statement somebody wrote down, and a missing one is a question nobody asked.
`GrpcChannelProfileValidator` takes resolved counts where they are known at startup and skips the
check where they are not, rather than guessing and failing on a name that cannot be resolved yet.
**xDS is reachable, and not by this route.** It lives in `grpc-advanced-resilience` behind its
capability flag and its production approval, and `GrpcXdsStartupGuard.advertisableAsStableSupport()`
returns false so the Stable support statement cannot widen quietly. See ADR-GRPC-ADV-001.
@@ -0,0 +1,55 @@
# ADR-GRPC-ADV-001: Each advanced capability has its own flag, its own grade and its own promotion
- Status: accepted
- Date: 2026-08-30
- Scope: `:grpc-advanced:*`
## Context
The advanced plan covers sixteen capabilities that differ by orders of magnitude in what they bring
with them. gRPC-Web adds a proxy. Reactor adds a dependency. xDS adds a control plane, its outage
modes, its own security boundary and its own version skew. Hedging duplicates production traffic.
Bundling them under one flag makes enabling the cheapest of those the same decision as enabling the
most consequential.
## Decision
**One flag per capability**, under `ca-skeleton.grpc.advanced.<capability>.enabled`, all off by
default.
**Four grades.** `ADVANCED_STABLE` starts on its flag; `EXPERIMENTAL` additionally needs a separate
production approval, because the flag says somebody wanted the feature and the approval says somebody
accepted that its failure modes are not fully characterised; `WATCH` cannot start at all; `DISABLED`
is withdrawn.
`GrpcAdvancedModuleGuard` distinguishes the three refusals — flag unset, grade unstartable,
production unapproved — because the remedy differs in each case.
**Promotion evidence is per capability.** `GrpcAdvancedPromotionEvidence` is one record per
capability, so no promotion can drag another along;
`GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` returns an empty list, and that is a tested
property rather than a claim. Two thresholds: seven days of soak plus complete evidence for
`ADVANCED_STABLE`, thirty for a Stable default, because the second means every deployment gets the
capability's dependencies and its failure modes.
**Infrastructure is named per capability.** `GrpcAdvancedInfrastructureTestkit` records that
gRPC-Web needs a proxy, Servlet needs a container, xDS needs a stoppable control plane and Kotlin
needs a toolchain. A suite that runs without its infrastructure passes and establishes nothing, which
is worse than not having one.
## Consequences
**The Kotlin adapter fails closed here, and says why.** This repository has no Kotlin toolchain, so
`GrpcKotlinCompatibilityGate.supportableHere()` returns false. The four contract requirements — one
schema source, coroutine cancellation propagation, Flow backpressure inside the Stable bounds,
platform evidence types preserved — are checkable and are checked; only the compile lane is missing.
**Edition 2026 cannot be used however its watch report reads.** `GrpcEdition2026Guard` is not
conditional on the report, because letting a status record also authorise use means a schema moves
onto an edition the moment somebody marks four fields SUPPORTED, with no promotion decision, no
consumer migration and no ADR.
**xDS is not part of the Stable support statement.** It works, behind its flag and its approval;
`GrpcXdsStartupGuard.advertisableAsStableSupport()` returns false so a support matrix cannot widen
quietly.
@@ -0,0 +1,62 @@
# gRPC advanced capability support matrix
Every capability in `:grpc-advanced:*`, its grade, and what it would take to raise it.
`GrpcAdvancedSupportMatrix` is the machine-readable form; `GrpcAdvancedCapability.defaultGrade`
carries the same values.
All capabilities are off by default. Flags are `ca-skeleton.grpc.advanced.<capability>.enabled`.
## Grades
| Grade | May start | Production needs a separate approval |
| --- | --- | --- |
| `ADVANCED_STABLE` | Yes | No |
| `EXPERIMENTAL` | Yes | Yes |
| `WATCH` | No | — |
| `DISABLED` | No | — |
## Capabilities
| Capability | Flag | Grade | Real infrastructure its evidence needs |
| --- | --- | --- | --- |
| Protobuf Edition 2024 | `edition-2024` | `ADVANCED_STABLE` | — |
| Protobuf Edition 2026 | `edition-2026` | `WATCH` | — |
| Client streaming | `client-streaming` | `ADVANCED_STABLE` | — |
| Bidirectional streaming | `bidi-streaming` | `ADVANCED_STABLE` | — |
| Manual flow control | `manual-flow-control` | `ADVANCED_STABLE` | — |
| Read-only unary hedging | `hedging` | `EXPERIMENTAL` | — |
| Custom name resolver | `custom-resolver` | `ADVANCED_STABLE` | — |
| Custom load balancer | `custom-load-balancer` | `EXPERIMENTAL` | — |
| Proxyless xDS | `xds` | `EXPERIMENTAL` | xDS control plane |
| gRPC-Web | `grpc-web` | `ADVANCED_STABLE` | gRPC-Web proxy |
| Servlet HTTP/2 | `servlet-compat` | `ADVANCED_STABLE` | Servlet container |
| Spring Integration bridge | `integration-bridge` | `ADVANCED_STABLE` | — |
| Reactor adapter | `reactor` | `ADVANCED_STABLE` | — |
| Kotlin coroutine / Flow | `kotlin` | `ADVANCED_STABLE` | Kotlin toolchain |
| Channelz / CSDS diagnostics | `channel-diagnostics` | `ADVANCED_STABLE` | — |
## What the grades mean here, concretely
**Grade is a statement about the contract, not about a deployment.** Every capability's contract is
implemented and tested in this repository. What no capability has is evidence from a real deployment:
`GrpcAdvancedPromotionEvidence` for each one is empty, and no promotion has been granted.
**Four capabilities cannot produce meaningful evidence here at all**, because the infrastructure they
need is absent. `GrpcAdvancedInfrastructureTestkit.missingInfrastructure` names them, and a suite that
runs without its infrastructure passes and establishes nothing.
**Kotlin is the sharpest case.** This repository has no Kotlin toolchain, so
`GrpcKotlinCompatibilityGate.supportableHere()` returns false and always will until one exists. The
four contract requirements — one schema source shared with Java, coroutine cancellation propagated,
Flow backpressure inside the Stable buffer bounds, platform evidence types preserved — are checkable
without a toolchain and are checked. The compile lane is not.
## Promotion thresholds
| To | Soak | Also required |
| --- | --- | --- |
| `ADVANCED_STABLE` | 7 days | compatibility evidence, security review, fault evidence, performance evidence, ADR, runbook, real-environment test |
| Stable default | 30 days | all of the above, plus a dependency, security and operational-cost review |
`WATCH` becomes `EXPERIMENTAL` before anything else. Promotions are independent: promoting one
capability changes no other's grade.
+75
View File
@@ -0,0 +1,75 @@
# gRPC platform support matrix
What the Stable gRPC platform (`:grpc:*`) is certified against, what it is only checked against, and
what is merely watched. The distinction is the point: "works with Spring Boot" is not a statement
anyone can act on.
`GrpcCompatibilityMatrix.caSkeleton()` is the machine-readable form of this table, and
`GrpcStableReleaseGate` blocks a release when a certified lane has no result or a failing one.
## Lanes
| Lane | Grade | Failure blocks a release |
| --- | --- | --- |
| Boot-managed platform (Spring Boot 4.0.8 BOM) | Certified | Yes |
| proto3 with explicit `optional` | Certified | Yes |
| `grpc-netty-shaded` | Certified | Yes |
| `grpc-netty` (unshaded) | Compatibility | No |
| Upstream gRPC Java version override | Compatibility | No |
| Protobuf Edition 2024 | Watch | No |
| Protobuf Edition 2026 | Watch | No |
## Runtime baseline
| | |
| --- | --- |
| Java | 21 |
| Spring Boot | 4.0.8 (the repository baseline; the plans assume 4.1) |
| io.grpc | `ext.grpcVersion` in `src/build.gradle` |
| Protobuf | `ext.protobufVersion` in `src/build.gradle` |
| Stable transport | Netty (shaded) |
| Stable RPC shapes | Unary, Server Streaming |
| Stable resolvers | Static, DNS, Unix domain socket |
| Stable load balancing | `pick_first`, `round_robin` |
## Evidence grades
A capability may only be advertised on evidence of a grade that can establish it.
`GrpcEvidenceGrade.requireCertifies` enforces this, and `GrpcReleaseEvidence.supports` refuses a
claim backed by the wrong lane.
| Grade | Lane | Establishes |
| --- | --- | --- |
| `CONTRACT` | `grpcInProcessContractTest` | adapter, interceptor order, status mapping, validation, idempotency replay, context propagation |
| `TRANSPORT` | `grpcNettyContractTest` | HTTP/2, TLS, mTLS, metadata limit, message limit, GOAWAY, keepalive, graceful shutdown |
| `FAULT` | `grpcFaultTest` | connection loss, completion unknown, partial stream, evidence classifier |
| `PERFORMANCE` | `grpcPerformanceTest` | latency, stream saturation, executor saturation, drain budget |
In-process results are never transport evidence. The in-process transport does not negotiate TLS,
does not frame HTTP/2 and does not enforce transport-level limits, so a suite that passes there has
tested the adapter and not the transport.
## What is not supported
| | Where it lives |
| --- | --- |
| Client streaming, bidirectional streaming | `grpc-advanced-streaming` |
| Manual flow control | `grpc-advanced-streaming` |
| Hedging | `grpc-advanced-resilience` |
| Custom name resolver, custom load balancer | `grpc-advanced-resilience` |
| xDS | `grpc-advanced-resilience` |
| gRPC-Web, Servlet HTTP/2, Spring Integration, Reactor, Kotlin | `grpc-advanced-compat` |
| Channelz / CSDS diagnostics | `grpc-advanced-diagnostics` |
## Current release status
Not released. Every `:grpc:*` leaf is `runtime_memberships: []` in the module registry, so the
platform is build-only: it compiles, its lanes run, and no deployed artifact carries it.
Two release gate inputs are outstanding and are the work between here and a release:
- **Performance baseline.** The performance lane runs and asserts shape — ordered percentiles, a gate
that reads them — rather than absolute numbers. A recorded baseline on a known runner is what turns
it into a regression gate.
- **Schema codegen.** No `protoc` runs in this build (ADR-GRPC-002), so the descriptor artifact and
the consumer-compile fixture are governed as policy rather than produced from a compiled schema.
@@ -0,0 +1,98 @@
# Runbook: gRPC advanced capabilities
Scope: the `:grpc-advanced:*` family. Everything here is off by default and stays off until a
deployment names it. Nothing in this family ships in a runtime composition today.
Flags are `ca-skeleton.grpc.advanced.<capability>.enabled`. `GrpcAdvancedCapability` owns the list of
capability names; `GrpcAdvancedSupportMatrix` owns their current grades.
---
## A capability refuses to start
`GrpcAdvancedModuleGuard` gives three different refusals, and the remedy differs:
| Message contains | Meaning | Remedy |
| --- | --- | --- |
| "its feature flag is not set" | Nobody enabled it | Set the property named in the message |
| "tracked rather than implemented" | Grade is `WATCH` | Nothing to do here; the capability is not implemented |
| "uncharacterised failure modes" | Grade is `EXPERIMENTAL` and this is production | Record a production approval, or run it outside production |
The refusal message always names the property key, so the first case is a configuration line rather
than a support question.
---
## xDS: the control plane went away
**What you are seeing.** The control plane is unreachable and clients are still routing.
**What it means.** `GrpcXdsFailurePolicy` serves the last-known-good snapshot, up to its staleness
bound.
**What to do.**
1. Check the snapshot's age. `SERVE_LAST_KNOWN_GOOD` is the healthy degraded state.
2. `STALE_BEYOND_BOUND` means the snapshot is older than the policy allows and is no longer trusted.
Beyond that bound, a decommissioned backend would otherwise keep receiving traffic indefinitely.
3. `NO_SNAPSHOT_YET` on a starting instance means it never reached the control plane. It fails after
the initial fetch timeout rather than starting with no routing.
**Do not** add application-level retry policy while xDS is enabled. `GrpcXdsStartupGuard` refuses it,
because retry defined in two places has a winner that depends on resolution order rather than on a
decision.
---
## gRPC-Web: a browser call hangs and then fails with no status
**Almost always the proxy.** A gRPC status arrives as a trailer, and a browser cannot read a trailer
the proxy did not expose. Check that the proxy's CORS `expose_headers` includes `grpc-status` and
`grpc-message`; `GrpcWebProxyContract.violations` reports exactly this, and the reference
configuration in `envoy/envoy.yaml` shows it in place.
**If the method is client- or bidirectional-streaming**, it cannot work over gRPC-Web at all — a
browser has no way to send a stream of messages. `GrpcWebCompatibilityGate` reports such a method
before it is exposed.
---
## Servlet: a transport setting appears to be ignored
It is ignored. The container owns the socket, so keepalive tuning, maximum connection age and
flow-control window tuning belong to it. `GrpcServletStartupValidator` refuses those settings at
startup rather than accepting and dropping them, because a setting that is silently ignored sends the
investigation somewhere else.
A Servlet run never substitutes for Netty certification.
---
## Hedging: backend load doubled
**Expected, within a bound.** Hedging trades duplicate load for tail latency.
`GrpcHedgingResult` records both `duplicateBackendCalls` and `cancelledLoserAttempts`; a dashboard
showing only the latency improvement makes the trade look free.
**What to check.** `GrpcHedgingBudget` caps hedges as a fraction of completed calls. If duplicate
load is above that fraction, the budget is not being consumed — which means something is issuing
hedges outside the coordinator.
**Hedging is refused** for anything but a read-only unary method. A hedged mutation runs twice by
design, and an idempotency key does not help: the second attempt duplicates a success in progress
rather than retrying a failure.
---
## Promoting a capability
`GrpcAdvancedPromotionGate.evaluate` names every missing item. Promotion to `ADVANCED_STABLE` needs
compatibility evidence, a security review, fault evidence, performance evidence, an ADR, a runbook, a
real-environment test and seven days of soak. A Stable default needs thirty.
Promotions are independent: promoting one capability changes no other's grade, and
`GrpcAdvancedSupportMatrix.apply` refuses a decision made against a different matrix state.
Before citing a suite as evidence, check `GrpcAdvancedInfrastructureTestkit.missingInfrastructure`.
A suite that ran without the proxy, the container, the control plane or the toolchain it needs passed
and established nothing.
+142
View File
@@ -0,0 +1,142 @@
# Runbook: gRPC platform operations
Scope: the `:grpc:*` family. All of it is build-only today — every leaf's `runtime_memberships` is
empty — so nothing here fires in production yet. It is written now because the states it covers are
the ones an on-call cannot work out from first principles at three in the morning, and shipping the
behaviour before the runbook means the first person to meet one is doing that.
Configuration lives under `ca-skeleton.grpc.platform.*` and is bound by `GrpcPlatformProperties`.
The platform does not start unless `ca-skeleton.grpc.platform.enabled=true`.
---
## COMPLETION_UNKNOWN on a mutation
**What you are seeing.** A client received `DEADLINE_EXCEEDED`, `UNAVAILABLE` or `INTERNAL` on a
state-changing call, and the response trailer `completion-outcome` reads `COMPLETION_UNKNOWN`.
**What it means.** The server may have committed. This is not a failure and not a success; the status
code cannot distinguish them, which is why the outcome is carried separately.
**What not to do.** Do not re-issue the call. Do not tell the caller it failed. Both are wrong half
the time, and which half is not knowable from the status.
**What to do.**
1. Take the `error-execution-id` from the trailers. It is the only link between what the client saw
and what the server did.
2. If the method is `IDEMPOTENCY_KEY_REQUIRED`, query the operation ledger with the caller
fingerprint, the full method name and the caller's key. `GrpcOperationStatusQuery` returns one of
`IN_PROGRESS`, `COMMITTED`, `FAILED_TERMINAL`, `NOT_FOUND` or `UNKNOWN`.
3. `COMMITTED` means return the stored outcome reference, not a freshly computed answer — the resource
may have changed since, and a new answer would describe the state at reconciliation time rather
than the state the caller's own call produced.
4. `NOT_FOUND` means the operation never started and is safe to re-issue. `FAILED_TERMINAL` means the
same.
5. `UNKNOWN` means the ledger could not be consulted. Nothing may be concluded. The case is queued by
`GrpcCompletionReconciler` and retried later.
6. If the method is not keyed, there is no ledger row. Resolve it against the business resource, or
escalate to the service owner. This is the case the keyed profile exists to avoid.
**Escalate when** the reconciler's pending list grows across passes. That means the ledger is
unreachable rather than slow.
---
## A stream ended with FULL_RESYNC_REQUIRED
**What you are seeing.** A client's resume was refused and it was told to resynchronise.
**What it means.** The server can no longer replay from the client's cursor. Either the snapshot
version moved, or the cursor predates retained history.
**What to do.** Nothing on the server. The client is expected to discard its position and start a new
stream from a fresh snapshot. A client that instead retries the same token will keep receiving the
same answer.
**Escalate when** it is happening to many clients at once. That usually means history retention was
reduced, or snapshots are rotating faster than clients reconnect.
---
## A stream ended with SLOW_CONSUMER
**What you are seeing.** Streams terminating with `SLOW_CONSUMER`, and
`grpc.stream.flow_control_stalls` rising.
**What it means.** The consumer could not keep up with the bounded queue. The stream was terminated
rather than silently dropping messages, because a client cannot detect drops — the sequence numbers
it sees are the ones it was sent.
**What to do.**
1. Check whether the consumer is slow or the producer is fast. `grpc.stream.messages` against
`grpc.stream.lifetime` tells you the rate.
2. If the consumer is slow, the fix is on the consumer. Raising the queue bound moves the failure
later and makes it larger.
3. A resume is not available after this ending: the messages that overflowed the queue are gone, so
continuing from the last delivered sequence would silently skip them. The client resynchronises.
---
## RESOURCE_EXHAUSTED under load
**What you are seeing.** Calls refused with `RESOURCE_EXHAUSTED` and `GrpcAdmissionController`
reporting rejections.
**What it means.** The server is at its concurrency and queue bounds and is shedding rather than
queueing. This is the designed behaviour: accepting work whose callers have already given up spends
capacity on nothing.
**What to do.**
1. Read `grpc.rpc.duration` and `grpc.rpc.queue_wait` separately. Queue time rising with duration flat
means the bottleneck is admission, not the work.
2. Check which saturation counter is moving — executor, channel or flow control. They look identical
in a latency graph and have different fixes.
3. Raising `ca-skeleton.grpc.platform.executor-queue-capacity` defers the problem; it does not remove
it. `GrpcExecutorProfile` refuses a queue above ten thousand for that reason.
---
## A rollout is producing errors at every deploy
**What you are seeing.** A burst of `UNAVAILABLE` or `CANCELLED` each time an instance goes away.
**What it means.** The drain sequence is not completing, or is running out of order.
**What to do.**
1. `GrpcDrainResult` records what each drain achieved: completed and cancelled unary calls, signalled
and cancelled streams, and which phases ran. A drain that routinely force-cancels is the cause.
2. The order matters. Readiness flips first and nothing is refused during that window, because there
is a gap between an instance reporting unready and routing acting on it. Refusing during that gap
turns a clean rollout into a burst of errors at every deploy.
3. For long streams, check the Kubernetes profile's `streamReconnectBudget` and
`readinessDrainGrace`. A stream is pinned to one pod for its whole life, so every rollout ends it;
a profile with long streams and no reconnect budget has not decided what clients do next.
---
## Verifying a deployment's configuration
`GrpcPlatformSnapshotService` produces a secret-free snapshot for a caller on the admin network
holding an admin role. Both gates are required.
`GrpcPlatformSnapshotService.driftAgainstRelease` compares a running snapshot with the release
manifest and reports schema version, method policy hash and per-channel profile differences. An
instance running a configuration the release did not ship is behind a whole class of incidents that
are otherwise diagnosed by reading logs.
The snapshot carries hashes and names only. A field whose name looks like a credential is refused at
construction rather than redacted.
---
## Things that are deliberately off
- **Reflection in production.** `GrpcReflectionMode.defaultFor` returns `DISABLED` for `STAGE` and
`PROD`. Reflection publishes the whole schema to anyone who can open a connection.
- **Every advanced capability.** See `docs/runbooks/grpc-advanced-capabilities.md`.
- **The platform itself.** `ca-skeleton.grpc.platform.enabled` defaults to false, and every `:grpc:*`
leaf is build-only in the registry.
@@ -0,0 +1,117 @@
# 타입 안전 gRPC 실행 플랫폼 — 실행 계획과 실행 결과
설계 SSOT: [docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md](../specs/2026-08-30-grpc-platform-adaptation-design.md)
원본:
- `docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md` (Stable, Task 153)
- `docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md` (Advanced, Task 118)
이 문서는 실행 전 계획이자 실행 결과 기록이다. 각 phase는 leaf 단위로 닫혔고, 닫힘 조건은
`./gradlew <gradle-path>:check` 통과다 — 즉 test + spotless + checkstyle + SpotBugs(HIGH) +
Error Prone/`-Werror` + 저장소 전역 게이트 전이 실행이다.
## Phase 0 — 레지스트리와 스캐폴딩
- [x] `src/config/architecture/modules.json`에 18개 leaf 등록 (`grpc:*` 12, `grpc-advanced:*` 6)
- [x] leaf별 `build.gradle` 18개. io.grpc를 쓰는 leaf는 `grpc-bom`을 module scope로 import
- [x] `src/build.gradle`: `:grpc:` / `:grpc-advanced:` 를 plain JUnit+AssertJ 테스트 분기에 추가
(`messaging:*`와 같은 이유 — core-api가 Spring도 io.grpc도 이름 부르지 않는다는 주장을
검증 가능하게 만든다)
- [x] `./gradlew --write-locks ...resolveAndLockAll` 로 lockfile 18개 생성
- [x] `verifyCleanArchitectureDependencies` 통과
## Phase 1 — Foundation (`grpc-core-api`, Stable Task 17 + ledger port)
- [x] Task 1 `GrpcStableModuleCatalog` / `GrpcStableBuildInvariant`
- [x] Task 2 `GrpcMethodName` / `GrpcServiceName` / `GrpcChannelProfileName` / `RpcType` / `GrpcStatusCode`
- [x] Task 3 `RpcIdempotencyProfile` / `WaitForReadyPolicy` / `GrpcMethodPolicy` / `GrpcMethodPolicyCatalog`
- [x] Task 4 `GrpcTransportEvidence` / `GrpcBusinessEvidence` / `GrpcStreamEvidence` / `GrpcExecutionEvidence`
- [x] Task 5 `GrpcFailureCategory` / `GrpcCompletionOutcome` / `GrpcFailureContext` / `GrpcPlatformException`
- [x] Task 6 `GrpcDeadlineProfile` / `GrpcDeadlineBudget` / `GrpcCancellationToken` / `GrpcDeadlineExceededException`
- [x] Task 7 `GrpcRequestContext` / `GrpcMetadataKey` / `GrpcMetadataBudget` / `GrpcClientIdentity`
- [x] 추가: `dev.caskeleton.grpc.ledger` port (`GrpcOperationLedger` 외 3) — 정책 계층이 DB에 의존하지
않고 durable idempotency를 요구할 수 있게 하기 위해 core-api에 둔다
## Phase 2 — Contract governance (`grpc-proto-contract`, `grpc-codegen`, Task 811)
- [x] Task 8 `.proto` 2개 + `buf.yaml` + `GrpcProtoStyleManifest` / `GrpcProtoRuleViolation` / `GrpcProtoContractValidator`
- [x] Task 9 `GrpcBufPolicy` / `GrpcBreakingCategory` / `GrpcSchemaBaseline`
- [x] Task 10 `GrpcCodegenManifest` / `GrpcGeneratedPackagePolicy` / `GrpcCodegenOutput`
- [x] Task 11 `GrpcDescriptorArtifact` / `GrpcConsumerFixture` / `GrpcSchemaArtifactPublisher`
- 편차: protoc·Buf CLI 미실행. 근거는 ADR-GRPC-002
## Phase 3 — Policy (`grpc-policy`, Task 12·16·17·20·2831·33·34·3743)
- [x] Task 12 validation, Task 16 context propagation, Task 17 status/rich error, Task 20 TLS/credential rotation
- [x] Task 28 deadline calculator, Task 29 cancellation coordinator
- [x] Task 30 service config/retry owner, Task 31 retry eligibility/budget/coordinator, Task 42 wait-for-ready
- [x] Task 33 idempotency interceptor, Task 34 completion reconciliation
- [x] Task 3741 stream envelope·writer·flow control·resume token·lifetime
- [x] Task 43 message size / compression / payload boundary
## Phase 4 — Server boundary (`grpc-server`, Task 1315·1819)
- [x] Task 13 boundary rules + raw API import rule, Task 14 typed service adapter SPI
- [x] Task 15 interceptor 순서 계약, Task 18 Netty profile/executor/admission, Task 19 shaded parity
## Phase 5 — Client / discovery / admin / observability / ledger
- [x] `grpc-client` Task 2427
- [x] `grpc-discovery` Task 3536
- [x] `grpc-admin` Task 2123·45
- [x] `grpc-observability` Task 44
- [x] `grpc-operation-ledger-jpa` Task 32 (entity + repository + migration + port impl)
## Phase 6 — Composition과 인증 (`grpc-spring-boot-starter`, `grpc-testkit`)
- [x] Task 52 properties / auto-configuration / startup validator
- [x] Task 46 in-process fixture, Task 47 실제 Netty + TLS/mTLS fixture
- [x] Task 48 fault point / scenario / evidence classifier
- [x] Task 4950 unary·streaming contract suite, Task 51 performance budget/gate
- [x] Task 53 compatibility matrix / release evidence / release gate
- [x] strict test lane 4개 등록 및 실행: `grpcInProcessContractTest`, `grpcNettyContractTest`,
`grpcFaultTest`, `grpcPerformanceTest`
## Phase 7 — Advanced (`grpc-advanced:*`, Advanced Task 118)
- [x] A1·A18 `grpc-advanced-bootstrap`
- [x] A2·A3 `grpc-advanced-edition`
- [x] A4A7 `grpc-advanced-streaming`
- [x] A8A11 `grpc-advanced-resilience`
- [x] A12A16 `grpc-advanced-compat`
- [x] A17 `grpc-advanced-diagnostics`
## Phase 8 — 문서와 게이트
- [x] `src/grpc/CLAUDE.md`, `src/grpc-advanced/CLAUDE.md`
- [x] ADR-GRPC-001..005, ADR-GRPC-ADV-001
- [x] `docs/runbooks/grpc-platform-operations.md`, `docs/runbooks/grpc-advanced-capabilities.md`
- [x] `docs/compatibility/grpc-support-matrix.md`, `docs/compatibility/grpc-advanced-support-matrix.md`
- [x] `verifyRunbookReferences`, `verifyDocumentedLeafCount` 통과
## Phase 9 — 2026-08-31 계획 대조 감사
사용자가 "빠짐없이 반영한게 맞나"를 물어 계획의 `Files:` 절 전체를 기계 대조했다. 결과와 조치는
설계 문서 §6이 SSOT다. 요약:
- [x] 대조 스크립트 실행 → 초기 결과 present=316 / missing=38
- [x] 실제 누락 6건 보완: `GrpcKubernetesProfileValidator`, `ADR-GRPC-006`(discovery/Kubernetes),
xDS `bootstrap.json`, `control-plane-snapshot.json`, `DocumentClientFixture`,
`buf.gen.yaml`+`buf.lock`
- [x] 추가한 픽스처는 전부 실제 검사에 물렸다 — 장식이 되지 않도록:
`GrpcXdsStartupGuard.bootstrapMismatches`(namespace 불일치·비TLS control plane),
redactor가 실제 모양의 CSDS 데이터로 검증, `GrpcConsumerFixture.fromJavaSource`가 fixture
소스에서 요구사항을 역산
- [x] 테스트 클래스 17건을 계획이 명시한 이름으로 분리
- [x] 재대조 → present=341 / missing=13, 잔여 13건은 전부 §6.1–6.4의 기록된 편차
(ADR 파일명 6, codegen convention plugin 3, TLS 인증서 3, Kotlin `.kt` 1)
## 남은 작업 (이 계획 범위 밖, 별도 결정 필요)
1. **런타임 투입.** 신규 leaf 전부 `runtime_memberships: []`다. `app-bootstrap`에 배선하려면 registry
membership 변경 + `verifyRuntimeModuleMembership` 통과가 선행이며, 그것은 별도 결정이다.
2. **`adapter:inbound:grpc` 브리지.** registry의 `allowed_dependencies``grpc-core-api`·`grpc-server`
추가하고 typed service adapter를 배선하는 작업. 플랫폼이 green이 된 지금이 시작점이다.
3. **protoc / Buf CLI 활성화.** ADR-GRPC-002가 조건과 비용을 기록한다.
4. **performance baseline 기록.** 현재 lane은 shape만 검증한다. 알려진 runner에서의 baseline이
regression gate를 만든다.
@@ -0,0 +1,208 @@
# 타입 안전 gRPC 실행 플랫폼 — 이 저장소 규약으로의 어댑팅 설계
두 계획 문서를 이 저장소(`ca-skeleton`)의 실제 구조·정책·빌드 게이트에 맞춰 실행 가능한 형태로
옮기는 설계다.
- 원본 A: `docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md` (Stable, Task 153)
- 원본 B: `docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md` (Advanced, Task 118)
원본은 `modules/grpc/**` · Gradle Kotlin DSL · 패키지 `io.backend.skeleton.grpc` · Spring Boot 4.1을
전제한다. 이 저장소는 `src/**` · Groovy DSL · `dev.caskeleton` · Spring Boot 4.0.8 · fail-closed
module registry를 쓴다. 원본 Global Constraint의 마지막 항목이 이 어댑팅을 명시적으로 허용한다:
"실제 저장소 구조가 예상 경로와 다르면 파일 경로만 매핑하고 공개 계약·불변 조건·테스트 의미는
변경하지 않는다."
## 1. 패밀리 위치 — 왜 `adapter:inbound:grpc` 확장이 아닌가
원본이 기술하는 것은 인바운드 어댑터 하나가 아니라 **자기 API·SPI·adapter·조립 경계를 가진 벤더드
RPC 플랫폼**이다. 이 저장소에는 그 형태의 선례가 이미 있다 — `messaging:*`. root `CLAUDE.md`
직접 그렇게 규정한다: "vendored messaging platform: a product with its own API, SPI, adapters and
composition boundary, not a layer of this application".
따라서 gRPC 플랫폼도 같은 자리에 둔다.
```text
src/grpc/ → :grpc:* Stable 플랫폼 (12 leaf)
src/grpc-advanced/ → :grpc-advanced:* Advanced/Experimental (6 leaf)
src/adapter/inbound/grpc 기존 인바운드 전송 어댑터 (그대로 유지)
```
`grpc-advanced`를 별도 디렉터리·별도 Gradle prefix로 分離하는 이유는 원본 Stable Task 1의 불변
조건 "Stable starter가 `modules/grpc-advanced`를 참조하면 build를 실패시킨다"를 registry의
`allowed_dependencies`만으로 기계 검증할 수 있게 만들기 위해서다. `verifyCleanArchitectureDependencies`
그 게이트다.
패키지 루트는 `dev.caskeleton.grpc` / `dev.caskeleton.grpc.advanced`다.
## 2. Leaf 매핑 — 원본 31개 모듈 → 이 저장소 18개 leaf
원본 모듈 경계 중 **이 저장소가 이미 다른 메커니즘으로 표현하는 것**만 합친다. 능력(capability)은
하나도 버리지 않는다.
### Stable — `src/grpc/`
| leaf | 원본 모듈 | 담는 Task | 의존 |
| --- | --- | --- | --- |
| `grpc-core-api` | grpc-core-api | 27 | (없음, Java stdlib) |
| `grpc-proto-contract` | grpc-proto-contract | 8 | core-api |
| `grpc-codegen` | grpc-codegen | 911 | core-api, proto-contract |
| `grpc-policy` | grpc-policy | 12, 16, 17, 20, 2831, 33, 34, 3743 | core-api |
| `grpc-server` | grpc-server | 1315, 18, 19 | core-api, policy |
| `grpc-client` | grpc-client | 2427 | core-api, policy |
| `grpc-discovery` | grpc-discovery | 35, 36 | core-api, client |
| `grpc-admin` | grpc-admin | 2123, 45 | core-api, server |
| `grpc-observability` | grpc-observability | 44 | core-api |
| `grpc-operation-ledger-jpa` | grpc-operation-ledger-jpa | 32 | core-api |
| `grpc-spring-boot-starter` | grpc-spring-boot-starter | 52 | 위 전부 |
| `grpc-testkit` | testkit-core + -inprocess + -netty + -fault | 4651, 53 | 위 전부 |
**testkit 4개를 1개로 합친 근거.** 원본이 testkit을 넷으로 쪼갠 목적은 "in-process 증거를 실제
네트워크 증거로 오인하지 않게 한다"이다. 이 저장소는 그 목적을 모듈 경계가 아니라 **strict test
lane** 컨벤션(`ca.strict-test-lane`)으로 이미 표현한다 — lane은 태그/소스셋/명시 테스트 중 하나로만
선택하고, 아무것도 실행하지 않으면 실패하며, up-to-date 결과를 제공하지 않는다. 그래서 네 모듈은
`grpc-testkit` 한 leaf 안의 네 lane이 된다:
`grpcInProcessContractTest`, `grpcNettyContractTest`, `grpcFaultTest`, `grpcPerformanceTest`.
원본의 "in-process는 HTTP/2·TLS 증거가 아니다"는 lane 분리와 `GrpcEvidenceGrade`로 강제한다.
### Advanced — `src/grpc-advanced/`
| leaf | 원본 모듈 | 담는 Task |
| --- | --- | --- |
| `grpc-advanced-bootstrap` | grpc-advanced-bootstrap | A1, A18 |
| `grpc-advanced-edition` | grpc-edition-2024 + grpc-edition-2026-experimental | A2, A3 |
| `grpc-advanced-streaming` | grpc-client-streaming + grpc-bidi-streaming + grpc-manual-flow-control | A4A7 |
| `grpc-advanced-resilience` | grpc-hedging + grpc-custom-resolver + grpc-custom-load-balancer + grpc-xds | A8A11 |
| `grpc-advanced-compat` | grpc-web + grpc-servlet-compat + grpc-integration-bridge + grpc-reactor + grpc-kotlin | A12A16 |
| `grpc-advanced-diagnostics` | grpc-channel-diagnostics | A17 |
Advanced 모듈은 capability마다 leaf를 나누는 대신 **capability grade와 feature flag**
(`GrpcAdvancedCapability` / `GrpcAdvancedFeatureFlags`)로 분리한다. A18의 요구사항
"Edition, streaming, xDS, gRPC-Web, Servlet, language adapter가 서로의 승격을 묶지 않는다"는
leaf 경계가 아니라 capability별 독립 promotion evidence로 표현되므로, 합쳐도 그 불변 조건은 유지된다.
## 3. 원본과 달라지는 지점 (deviation)과 근거
| # | 원본 | 이 저장소 | 근거 |
| --- | --- | --- | --- |
| D1 | Spring Boot 4.1 BOM | Spring Boot 4.0.8 BOM | 저장소 실제 baseline(`src/build.gradle:13`). BOM이 SSOT라는 계약 자체는 유지 |
| D2 | Boot-managed Spring gRPC starter | self-managed `io.grpc` (`ext.grpcVersion`) | 기존 기록된 결정(`adapter/inbound/grpc/README.md` "왜 self-managed Netty 인가"). starter 커플링 회피 |
| D3 | Gradle Kotlin DSL, `settings.gradle.kts` | Groovy DSL + `config/architecture/modules.json` | registry가 leaf 목록의 SSOT이고 settings는 그것을 읽기만 한다 |
| D4 | `io.backend.skeleton.grpc` | `dev.caskeleton.grpc` | 저장소 기본 패키지 |
| D5 | Buf CLI (`bufLint`/`bufBreaking` 등) | 저장소 소유 규칙 엔진 + Gradle verify task | Buf CLI 바이너리가 이 환경에 없다. lint/format/breaking **규칙**을 Java로 구현해 동일 판정을 내리고, CLI는 동일 규칙을 재확인하는 선택 경로로 남긴다 |
| D6 | protoc/grpc-java codegen을 빌드에서 실행 | `.proto` 소스 + codegen **정책·descriptor 계약**만 실행, protoc 실행은 명시적 확장점 | 아래 별도 절 |
| D7 | `grpc-kotlin``.kt` 소스 | Java 쪽 coroutine/Flow **경계 계약**만 | 저장소에 Kotlin 플러그인·소스셋이 없다. A16의 5개 요구사항 중 4개(스키마 단일 소스, cancellation 전파 계약, backpressure 우회 금지, evidence 타입 보존)는 Java 계약으로 표현 가능하고, Kotlin 툴체인 lane은 `GrpcKotlinCompatibilityGate`가 미충족으로 fail-closed 판정한다 |
| D8 | 새 leaf가 곧 런타임 | 신규 leaf 전부 `runtime_memberships: []` (build-only) | `messaging:*`가 처음 착지한 방식과 동일. 런타임 투입은 registry membership 변경 + `verifyRuntimeModuleMembership` 통과가 선행 조건이며, 그것은 별도 결정이다 |
### D6 — protoc 실행을 지금 켜지 않는 이유
이 저장소의 모든 leaf는 예외 없이 spotless(google-java-format) · checkstyle · spotbugs(HIGH) ·
errorprone · `-Werror`를 통과해야 한다(`src/build.gradle``configure(subprojects)` 블록). protoc가
만든 소스는 그 어느 것도 통과하지 못하므로, 실제 codegen을 켜려면 해당 소스셋에서 다섯 게이트를
모두 끄는 carve-out이 필요하다. 저장소에 선례는 있다(`jmh` 소스셋). 하지만 그 carve-out은 Task
10 하나를 위해 품질 게이트를 여는 결정이고, 그 결정은 이 작업 범위 밖의 승인 사항이다.
그래서 Task 8–11의 **불변 조건**은 전부 실행 가능한 형태로 구현한다:
proto style 규칙 검증, 삭제 필드 `reserved` 이력 대조, Buf breaking category(`FILE`) 판정,
generated package와 hand-written package 겹침 금지, descriptor+schema hash 릴리스 아티팩트,
consumer fixture 실패 시 릴리스 차단. 빠지는 것은 protoc 프로세스 호출 하나이고,
`GrpcCodegenManifest`가 그 지점을 단일 owner로 고정한 채 비워둔다.
## 4. 유지되는 원본 불변 조건 (변경 없음)
- 실행 증거 3축(Transport/Business/Stream) 분리, `RESPONSE_HEADERS_SEEN``COMMIT_CONFIRMED` 자동 승격 금지
- `DEADLINE_EXCEEDED` mutation = `COMPLETION_UNKNOWN` 후보, `UNAVAILABLE`만으로 상태 변경 RPC 재호출 금지
- `NON_IDEMPOTENT`에 explicit retry·hedging 금지, explicit retry owner는 하나
- Stable Unary는 positive deadline 필수
- Server Streaming: bounded queue + single serialized writer, partial delivery 후 whole-call retry 금지
- Stable RPC 유형은 Unary·Server Streaming, Client/Bidi는 Advanced
- production reflection 기본 비활성, dev·stage·prod TLS 필수, trust-all 금지
- Stable resolver = Static·DNS, Stable LB = pick_first·round_robin
- metric tag에 raw metadata/payload/actor/tenant/object/stream/idempotency ID 금지
- Netty가 Stable certification transport, in-process는 network/TLS 증거가 아님
## 5. 애플리케이션이 이 패밀리에 도달하는 경로
`messaging:*`의 MSG-015(bridge 부재)를 반복하지 않는다. 계약상의 경로는
```text
application-owned port → adapter:inbound:grpc (typed service adapter) → :grpc:grpc-server SPI
```
이고, Stable Task 13/14가 그 경계를 소유한다(`GrpcApplicationBoundaryRules`,
`GrpcRawApiImportRule`, `GrpcServiceAdapter`). 플랫폼이 green이 된 뒤 마지막 단계에서
`adapter-inbound-grpc``allowed_dependencies``grpc-core-api`·`grpc-server`를 추가하고
브리지를 배선한다. 그 전까지 플랫폼은 self-contained build-only다.
## 6. 원본 파일 목록 대비 대조 (2026-08-31 감사)
두 계획이 `Files:` 절에 명시한 산출물 전체를 기계적으로 대조했다. 감사 스크립트는 각 Task의 파일
basename이 저장소에 존재하는지 확인한다(build 산출물 제외, 단 annotation processor가 생성하는
`spring-configuration-metadata.json`은 build 출력에서 확인).
```text
STABLE (Task 153) : present=250 missing=11
ADVANCED (Task 118) : present= 91 missing= 2
TOTAL : present=341 missing=13
```
잔여 13건은 전부 아래 편차로 설명된다. **행동이 빠진 것은 없다.**
### 6.1 ADR 파일명 (6건) — 저장소 명명 규약
계획은 `ADR-060` ~ `ADR-065` 연번을 쓴다. 이 저장소의 `docs/adr/`는 접두사 규약을 쓴다
(`ADR-WS-001`, `ADR-MONGO-004`, `ADR-WEB-ADV-003`). 내용은 1:1이다.
| 계획 | 이 저장소 |
| --- | --- |
| ADR-060 platform-boundary | `ADR-GRPC-001-platform-family-and-registry-shape.md` |
| ADR-061 execution-evidence | `ADR-GRPC-003-three-axis-execution-evidence.md` |
| ADR-062 retry-idempotency | `ADR-GRPC-004-retry-ownership-and-durable-idempotency.md` |
| ADR-063 streaming-resume | `ADR-GRPC-005-server-streaming-single-writer-and-resume.md` |
| ADR-064 discovery-kubernetes | `ADR-GRPC-006-stable-discovery-and-kubernetes-routing.md` |
| ADR-065 advanced-promotion | `ADR-GRPC-ADV-001-capability-promotion-is-per-capability.md` |
계획에 없던 `ADR-GRPC-002-schema-governance-without-protoc.md`가 추가로 있다 — D6 결정을 기록한다.
### 6.2 codegen convention plugin 3건 — D6
`io.backend.grpc-buf-conventions.gradle.kts`, `io.backend.grpc-codegen-conventions.gradle.kts`,
`.properties`. protoc를 실행하지 않으므로 실행할 convention plugin이 없다. 이들이 고정했을 결정은
`GrpcCodegenManifest`·`GrpcCodegenOutput`·`GrpcGeneratedPackagePolicy`가 Java로 강제하고,
`buf.yaml`·`buf.gen.yaml`·`buf.lock`이 CLI가 있는 환경에서 같은 판정을 내리도록 커밋되어 있다.
### 6.3 TLS 인증서 3건 — 런타임 생성으로 대체
`ca.crt`, `server.crt`, `client.crt`. 커밋된 인증서는 만료되고, 커밋된 개인키는 개인키다.
`GrpcTlsTestMaterial`이 JDK `keytool`로 fixture마다 PKCS12를 생성하고 `close()`가 지운다. Netty의
`SelfSignedCertificate`는 JDK 21에서 `sun.security.x509` 미export로 실패하므로 쓰지 않았다.
### 6.4 Kotlin 소스 1건 — D7
`GrpcCoroutineAdapter.kt`. 저장소에 Kotlin 툴체인이 없다. 계약 요구 4건은 Java로 검증하고
(`GrpcKotlinProfile`, `GrpcCoroutineContextBridge`), compile lane은
`GrpcKotlinCompatibilityGate.supportableHere()``false`로 fail-closed다.
### 6.5 2026-08-31 감사에서 실제로 메꾼 것 (6건)
감사가 아니었으면 남았을 것들이다.
| 항목 | 조치 |
| --- | --- |
| `GrpcKubernetesProfileValidator` | 이름 있는 타입으로 분리. VIP+장기스트림, drain grace < reconnect budget 두 규칙 추가 |
| ADR-064 상당 (discovery/Kubernetes) | `ADR-GRPC-006` 작성 |
| xDS `bootstrap.json` | 테스트 리소스로 추가 + `GrpcXdsStartupGuard.bootstrapMismatches`가 실제로 대조 (namespace 불일치·비TLS control plane 검출) |
| `control-plane-snapshot.json` | 테스트 리소스로 추가 + redactor가 실제 모양의 데이터로 검증됨 |
| `DocumentClientFixture.java` + fixture `build.gradle.kts` | 추가. `GrpcConsumerFixture.fromJavaSource`가 fixture 소스에서 service·method·package 요구사항을 **역산**하므로 손으로 적은 목록이 아니다 |
| `buf.gen.yaml`, `buf.lock` | 추가 + proto contract 테스트가 내용을 검증 |
### 6.6 테스트 클래스 17건 — 감사 후 계획대로 분리
감사 시점에 leaf별로 통합돼 있던 테스트를 계획이 명시한 이름으로 분리했다. 분리 전에도 모든
타입이 실제로 검증되고 있었으나(통합 클래스가 해당 타입을 참조), 계획과의 추적성을 위해 나눴다:
`GrpcNettyVariantSelectorTest`, `GrpcReflectionPolicyTest`, `GrpcClientMetadataPolicyTest`,
`GrpcKubernetesProfileTest`, `GrpcEdition2026GuardTest`, `GrpcClientStreamPolicyTest`,
`GrpcClientMessageDeduplicatorTest`, `GrpcBidiSequenceTrackerTest`, `GrpcDemandControllerTest`,
`GrpcHedgingEligibilityTest`, `GrpcResolverSafetyPolicyTest`, `GrpcLoadBalancerSafetyPolicyTest`,
`GrpcXdsStartupGuardTest`, `GrpcWebCompatibilityGateTest`, `GrpcServletStartupValidatorTest`,
`GrpcIntegrationBridgePolicyTest`, `GrpcReactorContextBridgeTest`, `GrpcKotlinCompatibilityGateTest`.