# Repo gRPC Map — ground truth

How gRPC actually works in **our** codebase, focused on conversationmgmt, notification
(`internal/notification`), and spike (`internal/spike`), plus shared `internal/golibs`,
`proto/`, and `cmd/server/*`. This is the factual backbone every lesson is anchored to.
All paths are repo-relative; `file:line` refs point at the code on this branch.

> Verified by a code scan on 2026-07-11. Where the code contradicts a common assumption,
> it's flagged ⚠️. If a lesson and this file disagree, trust the code.

---

## Three corrections up front (learned from the scan)

1. ⚠️ **Codegen is raw `protoc`, not `buf generate`.** `make gen-proto-go` (`Makefile:129`)
   runs `docker compose up generate_go` → `proto/gen_go.sh`, which calls `protoc` with
   `--go_out`, `--go-grpc_out`, `--grpc-gateway_out`. **`buf` is used only for lint &
   breaking-change checks** (`proto/buf.yaml`, `make lint-proto`). There is no
   `buf.gen.yaml`. The gen flag `require_unimplemented_servers=false` means impls need
   **not** embed `Unimplemented*Server` (some still do for forward-compat).
2. ⚠️ **Our three services expose only unary RPCs.** No `stream` keyword appears in
   `proto/{conversationmgmt,notificationmgmt,spike}`. Streaming lives elsewhere:
   server-streaming in **tom** (`proto/tom/v1/chat_modifier.proto:171` `Subscribe`),
   client-streaming in **bob** (`proto/bob/v1/media.proto:128` `UploadAsset`).
   **Bidirectional streaming exists nowhere** in `proto/`.
3. ⚠️ **The live auth/RBAC interceptor is `internal/usermgmt/pkg/interceptors`**, not
   `internal/golibs/interceptors` (golibs has an older `Auth`). It reuses golibs claim
   primitives but does repo-backed RBAC.

---

## Protobuf & codegen

- **Proto layout** (one versioned dir per service): `proto/conversationmgmt/v1/`,
  `proto/notificationmgmt/v1/` (+ `/v2`, `/common`), `proto/spike/v1/email.proto`.
- **Package → Go alias** (`option go_package`): `conversationmgmt.v1 → …;cpb`,
  `notificationmgmt.v1 → …;npb` (`/v2 → npbv2`), `spike.v1 → …;spb`. Generated Go lands in
  `pkg/manabuf/<service>/<ver>/`.
- **Codegen**: `make gen-proto-go` → `proto/gen_go.sh` (protoc). `make lint-proto` = buf.
  Repo rule: *don't* run local `buf generate`.

Representative service + message (`proto/spike/v1/email.proto`):
```proto
message SendEmailPayload {
    message Content { string PlainText = 1; string HTML = 2; }
    string subject = 1;
    Content content = 2;
    repeated string recipients = 3;
    EmailType type = 7;
}
service EmailModifierService {
    rpc SendEmail(SendEmailRequest) returns (SendEmailResponse);          // unary, internal
    rpc ExternalSendEmail(ExternalSendEmailRequest) returns (...) {
        option (google.api.http) = { post: "/spike/api/v1/proxy/email/send_email", body: "*" };
    };
}
```
conversationmgmt splits `ConversationModifierService` / `ConversationReaderService`
(+ `SupportConversation*`), a common CQRS-ish Modifier/Reader split.

## The four RPC types (in this repo)

| Type | Proto | Go handler shape | Where |
|---|---|---|---|
| Unary | `rpc F(Req) returns (Resp)` | `func(ctx, *Req) (*Resp, error)` | **all 3 services** (e.g. `conversationmgmt/.../delete_message.go:14`; `spike/.../email_modifier_send_email.go:20`) |
| Server-stream | `rpc F(Req) returns (stream Resp)` | `func(*Req, F_Server) error` | **tom** `internal/tom/infra/chat/api.go:19` |
| Client-stream | `rpc F(stream Req) returns (Resp)` | `func(F_Server) error` | **bob** `internal/bob/services/media/media_modifier.go:166` |
| Bidi-stream | `rpc F(stream Req) returns (stream Resp)` | `func(F_Server) error` | **none in repo** |

## Server setup & registration

Services implement bootstrap `GRPCServicer[T]` and register in `init()`
(`cmd/server/conversationmgmt/gserver.go:51`):
```go
bootstrap.WithGRPC[configuration.Config](s).WithHTTP(s).WithKafkaServicer(s).Register(s)
```
The framework builds the server (`internal/golibs/bootstrap/grpc.go:70-111`):
```go
opts := []grpc.ServerOption{
    grpc_middleware.WithUnaryServerChain(b.unaryInterceptors...),
    grpc_middleware.WithStreamServerChain(grpcStream...),
}
grpcServer := grpc.NewServer(append(opts, servicer.WithServerOptions()...)...)
servicer.SetupGRPC(ctx, grpcServer, *c, rsc)   // per-service RegisterXServiceServer here
```
`SetupGRPC` calls generated registrars (`cmd/server/spike/init_grpc.go:10`):
`spb.RegisterEmailModifierServiceServer(server, svc)`. All three add OpenCensus stats via
`WithServerOptions` → `grpc.StatsHandler(&ocgrpc.ServerHandler{})`.

## Interceptors / middleware (the core)

Two chains, assembled by `grpc_middleware.WithUnaryServerChain`
(`internal/golibs/bootstrap/grpc.go:98`): **framework defaults** + **per-service customs**.

- **Default unary chain** (`grpc.go:120`): `grpc_ctxtags` (tags) → `grpc_zap` (logging) →
  `PayloadUnaryServerInterceptor` (log request payload on failure) →
  `ContextCanceledUnaryServerInterceptor` (remap "context canceled" → `codes.Canceled`).
- **Framework adds** (non-local, `grpc.go:80-96`): `WithUnaryServerRecovery` (panic →
  `codes.Unknown` + stacktrace, `interceptors/utils.go:13`) and a **timeout** interceptor
  (`interceptors/timeout.go:14`, default **5s**, per-method overrides in V2).
- **Per-service customs** appended after defaults: **auth** (`s.authInterceptor.UnaryServerInterceptor`)
  + `tracer.UnaryActivityLogRequestInterceptor`; spike/notification also add a
  fake-JWT interceptor for internal calls.

Interceptor signature + context enrichment (`internal/usermgmt/pkg/interceptors/auth.go:79`):
```go
func (a *Auth) UnaryServerInterceptor(ctx, req, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    // verify JWT, then:
    ctx = glInterceptors.ContextWithUserID(ctx, claims.Subject)
    ctx = glInterceptors.ContextWithJWTClaims(ctx, claims)
    return handler(ctx, req)   // pass enriched ctx down the chain
}
```
Metrics: OpenCensus `GrpcServerViews` (`interceptors/telemetry.go:45`) → Prometheus; spans
via `interceptors.StartSpan` with B3 propagation.

## Auth / RBAC

- ⚠️ Live interceptor: `internal/usermgmt/pkg/interceptors/auth.go`.
- **Per-RPC allowed roles**: each service has `var rbacDecider map[string][]string` keyed
  by full method path (`cmd/server/spike/auth.go:33`):
  ```go
  "/spike.v1.EmailModifierService/ExternalSendEmail": {RoleSchoolAdmin, RoleHQStaff, ...},
  "/grpc.health.v1.Health/Watch": nil,   // nil = any authenticated user
  ```
  Repo rule (`.claude/rules/security.md`): every RPC must appear here or the server panics
  at startup; `nil` ≠ omitted.
- **Enforcement** (`auth.go`): `verify(ctx)` reads the token from metadata
  (`metadata.FromIncomingContext(ctx); md.Get("token")`, `:124`), verifies JWT, injects
  claims into ctx, then `groupDecider.Check(ctx, subject, fullMethod)` (`:107`, `:201`)
  fetches roles from Postgres (`RetrieveUserRoles`, `:242`) and matches `rbacDecider`. Any
  failure → `codes.PermissionDenied`.
- **Claims** (`internal/golibs/interceptors/auth_claims.go:13,101`): `CustomClaims` →
  `Manabie *ManabieClaims{ UserID, ResourcePath, UserGroup, AllowedRoles, TenantID }`.
  Getters `UserIDFromContext`/`JWTClaimsFromContext`/`ResourcePathFromContext`
  (`interceptors/auth.go:354-477`); private int context keys (`:346`).
- **Internal service-to-service**: internal RPCs are listed in both `ignoreAuthEndpoint`
  (skip JWT) and `fakeJwtCtxEndpoint`, then a synthetic school-admin claim is injected
  (`internal/golibs/interceptors/internal_api.go:32`) carrying `resource_path`. That's how
  `SendEmail` / `InternalRetrieveMedia` run without an end-user JWT.

## Errors & status

- Controllers map errors → `status.Error(codes.X, msg)` **at the gRPC boundary**
  (`spike/.../email_modifier_send_email.go:20`): `ResourceExhausted` (limits) /
  `Internal` (infra) / `InvalidArgument` (validation) / `PermissionDenied` (auth).
- ⚠️ These three services do **not** use `errorx.ToStatusError`; that PG→status mapper
  (`internal/golibs/errorx/errorx.go:56`, `UniqueViolation→AlreadyExists` etc., with
  `status.WithDetails(errdetails.PreconditionFailure)`) is used by *other* services.
- notification adds error-remapping interceptors: `UnaryAuthErrorHandler`
  (`auth_err_handler.go:12`) and `UnaryAccessControlErrorHandlingInterceptor`
  (`access_control_err_handling.go:16`, rewrites Postgres RLS violations → `InvalidArgument`).

## Metadata, deadlines & context

- **Incoming**: `metadata.FromIncomingContext(ctx)` → `token`, `x-request-id`, `version`.
- **Outgoing**: `metadata.AppendToOutgoingContext` / `NewOutgoingContext`
  (`golibs/interceptors/metadata.go:10,27`; `SignCtx` copies `pkg`/`token`/`version`). The
  client dial interceptor rebuilds outgoing metadata from incoming
  (`bootstrap/resource.go:364` `UnaryClientAttachHeaderInterceptor`).
- **Deadlines**: the framework timeout interceptor wraps each handler ctx with
  `context.WithTimeout` (default 5s); because it rides `context.Context`, it propagates to
  downstream gRPC calls and DB queries automatically. Gateway maps HTTP `Authorization` →
  `token` via `gateway.WithMetadata`.

## Client-side / service-to-service

- Dial via `Resources.GRPCDial(svcName)` (`bootstrap/resource.go:351`): `grpc.Dial` with
  **insecure creds** (in-cluster trust) + B3 tracing stats handler. Addresses from a
  config service map. Retrying variant `GRPCDialContext` adds `grpc_retry` on
  `Unavailable`/`DataLoss` + `UnaryClientAttachHeaderInterceptor`.
- Connection names used: conversationmgmt → `mastermgmt`, `bob`; notification →
  `mastermgmt`, `spike`; spike → `notificationmgmt`.
- Worked cross-service call — spike → notification
  (`internal/spike/.../media_manager/media_info_fetcher.go:24`):
  ```go
  org, _ := interceptors.OrganizationFromContext(ctx)       // tenant from ctx claims
  resp, err := f.client.InternalRetrieveMedia(ctx, &npb.InternalRetrieveMediaRequest{
      MediaIds: mediaIDs, OrganizationId: org.OrganizationID().String() })
  ```

## Gateway, health, reflection

- **grpc-gateway (HTTP/JSON transcoding): yes for spike & notification** — protos carry
  `option (google.api.http)`; `SetupHTTP` mounts a `gateway.ServeMux` at
  `/spike/api/v1/proxy/*` and `/notificationmgmt/api/v1/proxy/*` (fronting the **gandalf**
  gateway). conversationmgmt uses gin directly for webhooks.
- **Health checks: yes** — all three register `grpc.health.v1`
  (`health.RegisterHealthServer(grpcserver, &healthcheck.Service{DB: pool})`), whitelisted
  in `ignoreAuthEndpoint`.
- ⚠️ **Server reflection: not registered** anywhere.

## Docs
Convention: `docs/{service}/{module}/{Method}.md` (per `CLAUDE.md`), but ⚠️ **not yet
written for our three services** — only `docs/eureka/{module}/` is populated (use it as
the structural template). A gRPC primer exists at
`docs/tech-stack-learning/02-api-contracts/README.md`.
