---
status: accepted
date: 2026-05-19
decision-makers: R&D Lead
tags: [backend, security, observability]
---

# AI service relay — vendored gRPC protos, NestJS gRPC client, SSE bridge to the SPA, POC unsigned principal

## Context and Problem Statement

`apf-ai-service` is the standalone AI backend that provides chat streaming, RAG retrieval, ingestion, and model enumeration to the portal. It lives in its own repository at `/home/jgautier/Works/apf-ai-service` and is the sole v1 consumer of the portal's AI surface; conversely, `apf_portal` is the sole v1 consumer of `apf-ai-service`. The two repos are co-developed but stay independent — different stack (.NET vs Node.js), different release cadence, different CI.

`apf-ai-service` has already consolidated its public surface, per its own ADR-0011 (mono-transport, 2026-05-15): a **single gRPC HTTP/2 surface on `:8080`**, h2c in dev / h2 + TLS in prod, no REST controllers, no SSE, no OpenAPI delivered to clients, no `Authorization: Bearer` header validation. The earlier split-transport / signed-JWT design (the older stargate POC's ADR-0005 + ADR-0009 lineage) is superseded. Today the service ships an **unsigned `Principal { subject, roles[], attributes{} }` in the proto request body** under its ADR-0010; production hardening (signed envelope or mTLS) is enumerated but explicitly deferred until at least one production deployment is in scope.

The portal therefore has to integrate with a contract that is _gRPC-only on the wire_ and _RBAC-enforced on every chunk_. The SPA still expects a streaming chat experience — incremental tokens with citations and agent steps — so the BFF needs to bridge the AI service's gRPC server-stream onto a transport the browser can consume. Four decisions are bundled here because they couple tightly: the wire transport between BFF and AI service, the wire transport between BFF and SPA, how the protos reach the BFF, and how the user's identity travels across the boundary in v1.

The four `apf.ai.v1.*` proto files (`common`, `chat`, `rag`, `ingestion`, `models`) under `apf-ai-service/contract/proto/` are the source of truth. They are stable; field tags are immutable per `apf-ai-service/docs/contract.md`; breaking changes create a new `apf.ai.v2.*` package alongside `v1`. The contract can be depended on now even though most agent / provider logic on the AI side is still a v0 stub.

## Decision Drivers

- **Stable, recognized choice** per [CLAUDE.md](../../CLAUDE.md) §"Project rules". gRPC is mature, multi-language, backed by Google, used at internet scale. Pre-1.0 or one-maintainer alternatives are rejected.
- **BFF pattern continuity** — per [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) the SPA never holds tokens nor talks to identity-enriched downstreams. The AI service is identity-enriched (RBAC per chunk), so it sits behind the BFF too.
- **Audit cross-referencing** — per [ADR-0013](0013-audit-trail-separated-postgres-append-only.md) the portal's audit table keys events on `actor_id_hash`. `apf-ai-service` has its own append-only audit table keyed on the _same_ hash. The two trails are useful only if the hash matches exactly across services — same Entra `oid`, same salt, same HMAC algorithm.
- **Observability propagation** — per [ADR-0012](0012-observability-pino-opentelemetry.md) the portal already propagates W3C Trace Context end-to-end via OpenTelemetry. Joining the portal trace with the AI service trace in Jaeger is the v1 baseline for cross-service debugging, not a future nicety.
- **Bundle budget** — per [ADR-0017](0017-performance-budgets-lighthouse-ci.md) the SPA lazy chunks cap at 100 KB gzip. The browser-facing transport choice cannot pull a heavy runtime (e.g. `grpc-web` + an Envoy proxy in front of the BFF).
- **Stack discipline** — per [ADR-0005](0005-backend-stack-nestjs.md) the BFF is NestJS on Express. Any transport library must compose with NestJS's module + DI patterns and not require a parallel runtime.
- **Operational simplicity in v1** — exactly one production caller (the BFF) reaching exactly one production callee (the AI service) over a private network. The auth posture in v1 must reflect that — anything heavier (signed JWT, mTLS, OAuth) before there is a second consumer is over-engineering. The deferral has to be recorded so the harden-up path is one PR away when the second consumer lands.
- **Reproducible BFF build** — the BFF's CI must not require network access to a different repository (or a sibling working tree) to build. That excludes git submodules and live cross-repo references.

## Considered Options

The decision is one ADR that bundles four sub-choices, each with its own option list:

### Sub-choice 1 — BFF ↔ AI service transport

- **Native gRPC over HTTP/2** _(chosen)_
- gRPC-Web from the browser, bypass the BFF
- Custom REST shim on the AI side
- WebSocket relay implemented on the AI side

### Sub-choice 2 — BFF ↔ SPA transport for chat streaming

- **SSE (`text/event-stream`) with JSON-encoded `ChatEvent` frames** _(chosen)_
- WebSocket relay (bidirectional)
- gRPC-Web through a sidecar (Envoy / grpcwebproxy)
- Long-polling

### Sub-choice 3 — proto distribution to the BFF

- **Vendor the protos into `apps/portal-bff/src/grpc/proto/`** _(chosen)_
- Git submodule pointing at `apf-ai-service`
- Generated TypeScript SDK published as an npm package by `apf-ai-service`
- Live cross-repo reference (pnpm `link:../apf-ai-service`)

### Sub-choice 4 — POC user-identity posture across the BFF→AI boundary

- **Unsigned `Principal` in the proto request body** _(chosen, mirrors `apf-ai-service` ADR-0010)_
- Signed RS256 JWT in proto envelope or gRPC metadata
- mTLS with portal client certificate as the proof of identity
- API key per deployment

## Decision Outcome

Chosen: **native gRPC HTTP/2 from a NestJS `AiClientModule` to `apf-ai-service`, an SSE bridge endpoint on the BFF for the SPA chat surface, protos vendored into the BFF repository with on-demand TypeScript stub generation, and an unsigned `Principal` carried in the proto body for the POC.** Each sub-choice is the minimum-friction match for `apf-ai-service`'s current surface, the portal's existing ADR contracts, and the v1 deployment topology (single caller, single callee, single private network).

### Sub-decision 1 — gRPC HTTP/2 between BFF and AI service

The BFF dials `apf-ai-service:8080` via [`@grpc/grpc-js`](https://github.com/grpc/grpc-node) (the standard pure-JS gRPC implementation maintained by the gRPC team). h2c in dev (no TLS overhead in the Compose network); h2 + TLS in prod (edge proxy terminates the public TLS, internal hop is mTLS-or-TLS depending on the prod-hardening decision recorded later). The BFF builds the channel once at module bootstrap, reuses it across requests, and lets gRPC-js handle HTTP/2 multiplexing + connection pooling. Channel options include the keepalive parameters documented in `apf-ai-service/docs/streaming.md` (HTTP/2 PING idle timeout matched to the proxy's idle window).

### Sub-decision 2 — SSE bridge between BFF and SPA

The chat surface is a `POST /api/ai/chat` endpoint on the BFF that responds with `Content-Type: text/event-stream`. Each AI service `ChatEvent` becomes one SSE frame, JSON-encoded:

```
event: token
data: {"value":"…"}

event: citation
data: {"chunkId":"…","documentId":"…","source":"…","score":0.87,"snippet":"…"}

event: done
data: {"stats":{"tokensIn":42,"tokensOut":128,"chunksRetrieved":3}}
```

The frame `event:` field carries the proto `oneof` case tag (`token` / `citation` / `agent_step` / `tool_call` / `error` / `done`); the `data:` payload is the JSON-encoded inner message. The terminal `done` event closes the stream — no `[DONE]` sentinel, matching `apf-ai-service`'s contract. Cancellation flows the other direction: the BFF subscribes to `request.on('close', ...)` (browser disconnect) and propagates by calling `.cancel()` on the gRPC client-stream, which `apf-ai-service` translates to a `ServerCallContext.CancellationToken` to stop upstream LLM generation.

The non-streaming RPCs (`RagService.Search`, `ModelsService.ListModels`, eventually a future `IngestionService.IngestDocument` for the admin surface) get plain JSON endpoints — `GET /api/ai/rag/search?query=...`, `GET /api/ai/models` — that wrap the unary gRPC call. No SSE needed where there is no streaming.

### Sub-decision 3 — vendored protos

The `.proto` files live under `apps/portal-bff/src/grpc/proto/apf-ai/`, copied from `apf-ai-service/contract/proto/`. A small npm script (`pnpm run grpc:codegen`) regenerates the TypeScript stubs via [`ts-proto`](https://github.com/stephenh/ts-proto) into `apps/portal-bff/src/grpc/gen/apf-ai/`. Both folders are committed — the generated code is reviewable; CI does not re-run codegen unless requested. A second small script (`pnpm run grpc:sync`) refreshes the vendored copy from the sibling working tree (`../../apf-ai-service/contract/proto/`) when a contract change lands upstream; it is a developer convenience, not a CI step.

Why both copies committed: review diffs catch unintended drift in either layer; the BFF build is hermetic (no network calls, no cross-repo paths); rolling back a contract bump is one git revert.

The generated stubs are NestJS-friendly out of the box (`ts-proto` emits `interface`-based service definitions that compose cleanly with `@grpc/grpc-js`'s `loadPackageDefinition`). The `AiClientModule` exposes four typed providers (`ChatClient`, `RagClient`, `IngestionClient`, `ModelsClient`) constructed against the shared `GrpcChannel` instance.

### Sub-decision 4 — unsigned Principal in the proto body (POC)

The BFF constructs an `apf.ai.v1.common.Principal` from the active portal session — read from Redis per [ADR-0010](0010-session-management-redis.md), populated by the OIDC callback per [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md):

```ts
const principal: Principal = {
  subject: hashUserIdService.hash(session.entraOid), // SAME hash as audit actor_id_hash
  roles: roleMapper.expand(session.entraGroups), // inclusive expansion, future ADR
  attributes: { delegation: session.delegation ?? '' }, // string-keyed, string-valued
};
```

The `Principal` message is placed in the `principal` field of every gRPC request (`ChatRequest.principal`, `RagSearchRequest.principal`, etc.). No JWT is constructed. No `Authorization` header is sent. The trust model is the same as `apf-ai-service`'s ADR-0010: the AI service trusts that only the BFF reaches it on the private network. The BFF trusts its own session as the source of truth for who the caller is.

The `subject` field MUST be a hash of the Entra `oid`, NOT the `oid` itself. The hash uses the same `HashUserIdService` salt and algorithm as the audit module so that `apf-ai-service.audit_log.actor_id_hash` and `apf_portal.audit.events.actor_id_hash` produce identical values for the same user — enabling cross-service incident analysis without re-identifying the user.

### Metadata contract — `x-client-id` and `x-correlation-id`

Every gRPC call from the BFF carries two metadata entries, per `apf-ai-service/docs/contract.md`:

| Metadata           | Value                                                                                | Purpose                                                                                                                      |
| ------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `x-client-id`      | `apf-portal-<env>` (`apf-portal-dev` / `apf-portal-preprod` / `apf-portal-prod`)     | Lets the AI service tag audit rows + metrics with the calling deployment without inspecting the proto body.                  |
| `x-correlation-id` | The W3C `traceparent` (or its `trace-id` component) of the active OpenTelemetry span | Joins the portal trace and the AI service trace in Jaeger. Required by [ADR-0012](0012-observability-pino-opentelemetry.md). |

A NestJS gRPC interceptor sets both metadata entries automatically for every outbound call. The interceptor is the single point where these IDs are constructed; consumers of `ChatClient` / `RagClient` do not pass them manually.

### Tool-dispatch contract — caller-side execution

The AI service is tool-blind: it advertises in `ChatRequest.tools_available[]` only the tool descriptors the BFF passed in on this very call, and emits `ChatEvent.tool_call { call_id, name, args }` when an agent decides to invoke one. The BFF owns the tool registry, executes the tool against its own dependencies (database, downstream Entra-protected API via the future `DownstreamApiClient` from [ADR-0014](0014-downstream-api-access-obo-pattern.md)), then re-issues `ChatService.Chat` with a `ChatMessage { role: tool, tool_call_id, content }` appended to the conversation.

v1 ships with an **empty tool registry** — `tools_available` is `[]`, no `tool_call` event is ever expected, the tool-dispatch code path is wired but exercised only by tests. The first real tool lands in a follow-up that brings its own scope (proposed `DownstreamApiClient` consumer + ADR on tool authorization model).

### Out of scope for this ADR

- The chatbot UI (a `portal-shell` component consuming the SSE endpoint). Lands as a separate frontend chantier referencing this ADR for the wire format.
- Ingestion through the BFF. `apf-ai-service` exposes `IngestionService.IngestDocument` over gRPC and a CLI tool under `tools/Apf.Ai.Ingest/`. v1 admin ingestion uses the CLI directly; a BFF relay endpoint lands when the admin app gains a "manage AI corpus" surface.
- The role mapper (Entra groups → inclusive-expanded `roles[]`). Designed-in here as a dependency on the Principal construction; specified in a follow-up ADR (proposed: "Role hierarchy + mapper").
- The production hardening choice (signed envelope vs mTLS). Recorded as an open item below; lands as an amendment to this ADR or a paired ADR on both sides simultaneously when the first production deployment is in scope.

### Lib layout

```
apps/portal-bff/src/
└── grpc/
    ├── proto/
    │   └── apf-ai/
    │       ├── common.proto       (vendored from apf-ai-service)
    │       ├── chat.proto
    │       ├── rag.proto
    │       ├── ingestion.proto
    │       └── models.proto
    ├── gen/
    │   └── apf-ai/                (ts-proto output, committed)
    │       ├── common.ts
    │       ├── chat.ts
    │       ├── rag.ts
    │       ├── ingestion.ts
    │       └── models.ts
    ├── ai-client/
    │   ├── ai-client.module.ts    (NestJS module, channel construction)
    │   ├── chat.client.ts         (server-stream wrapper)
    │   ├── rag.client.ts          (unary wrapper)
    │   ├── ingestion.client.ts    (unary wrapper, unused in v1)
    │   ├── models.client.ts       (unary wrapper)
    │   ├── grpc-metadata.interceptor.ts  (x-client-id + x-correlation-id injection)
    │   └── principal.mapper.ts    (session → Principal)
    └── ai-bridge/
        ├── ai-bridge.controller.ts        (POST /api/ai/chat → SSE; GET /api/ai/rag/search; GET /api/ai/models)
        └── sse.writer.ts                   (ChatEvent → SSE frame translator)
```

`grpc-codegen.config.json` at the workspace root records the `ts-proto` flags so a fresh checkout's `pnpm run grpc:codegen` is reproducible.

### Consequences

- Good, because the BFF speaks the AI service's native protocol — no protocol translation layer to maintain on either side.
- Good, because tree-shaking is preserved: `@grpc/grpc-js` is a backend-only dependency that never reaches the SPA bundle; the SPA only consumes SSE, which is in every browser since IE10 (and Angular has no built-in SSE coupling — `EventSource` is enough).
- Good, because the audit trail is joinable across both services via the shared `actor_id_hash` + `x-correlation-id` (= W3C trace-id).
- Good, because the proto contract is versioned (`apf.ai.v1.*`) and field-tag-stable; non-breaking AI service changes do not require a BFF release.
- Good, because the v1 auth posture is the cheapest one that still carries identity end-to-end. Adding signing or mTLS later is a one-PR change in each repo, gated by an ADR amendment.
- Bad, because the BFF and `apf-ai-service` must agree on the `HashUserIdService` salt — the operational coordination has to be recorded somewhere (env var + deployment-config doc) so a rotation on one side does not silently break audit joins.
- Bad, because the BFF is a stateful, long-lived gRPC client — pod restarts during a live chat stream surface as `ChatEvent.error { code: 'urn:apf-ai:relay_disconnect', retriable: true }`. Mitigation: the SPA retries; the BFF surfaces stream-level errors as structured SSE error frames, not as HTTP 5xx.
- Bad, because two copies of the proto files (the AI side's `contract/proto/` and the BFF's vendored copy) can drift if the sync script is not run. Mitigation: a CI gate runs `diff` between the BFF's `proto/apf-ai/` and a Renovate-tracked tag of the upstream repo whenever a release is cut on either side. v1 ships without the gate; the gate is the first follow-up.
- Neutral, because the chosen transport stack adds two new dependencies (`@grpc/grpc-js` + `ts-proto` as devDep) to the BFF — both maintained by their respective gRPC / proto-tooling communities, both well under 1 % of the existing dep surface.

### Confirmation

- **Unit tests** for the `principal.mapper.ts` cover: subject hashing matches the `HashUserIdService` output (same salt fixture); role expansion is order-independent and idempotent; attributes are string-keyed string-valued (no null leakage).
- **Unit tests** for `sse.writer.ts` cover: each `ChatEvent.oneof` case produces the expected SSE `event:` + `data:` frame shape; cancellation closes the stream without flushing partial frames.
- **Integration test** with a fake gRPC server (a `@grpc/grpc-js` in-process server backed by canned `ChatEvent` sequences) covers: full happy-path chat stream end-to-end, mid-stream `error` event closes the SSE stream, browser disconnect propagates `.cancel()` to the gRPC call.
- **Audit cross-reference test** asserts that an outgoing AI call produces an `audit.events` row in the portal whose `actor_id_hash` is the same value as the `Principal.subject` placed in the proto body.
- **CI gate** (first follow-up, not v1) compares the vendored `proto/apf-ai/` directory against the upstream `apf-ai-service/contract/proto/` at the pinned tag; mismatch fails the build with a one-line `pnpm run grpc:sync` remediation hint.
- **OpenTelemetry assertion** — an integration scenario verifies that the AI service's span (collected from its OTLP export to the local collector) carries the same `trace-id` as the portal's calling span.

## Pros and Cons of the Options

### Sub-choice 1 — BFF ↔ AI service transport

#### Native gRPC over HTTP/2 (chosen)

- Good, because it matches `apf-ai-service`'s only supported surface per its ADR-0011; no protocol translation needed.
- Good, because HTTP/2 multiplexing + persistent connection eliminates per-call TCP setup cost.
- Good, because typed stubs from `ts-proto` give compile-time safety on every request and response shape; field renames at the proto layer become TypeScript compile errors in the BFF, not runtime mismatches.
- Bad, because debugging is heavier than for plain HTTP/JSON (no `curl`, no browser network tab, no readable wire frames without `grpcurl` or equivalent). Mitigated by structured logging at the BFF boundary and a future `pnpm dev:grpc-trace` helper.
- Neutral, because `@grpc/grpc-js` is pure JS — no native build step, no Node-version coupling beyond the LTS line already pinned.

#### gRPC-Web from the browser, bypass the BFF

- Good, because no SSE bridge to maintain.
- Bad, because it breaks the BFF pattern from ADR-0009 — the SPA would need to talk to the AI service directly and would inherit auth + RBAC concerns the BFF currently absorbs.
- Bad, because gRPC-Web requires a sidecar proxy (Envoy or grpc-web's reference Go proxy) to translate gRPC-Web wire format to native gRPC. New piece of infrastructure for no v1 benefit.
- Bad, because the gRPC-Web runtime in the browser is ~100 KB minified — close to the entire SPA lazy-chunk budget for one feature.

#### Custom REST shim on the AI side

- Good, because the BFF talks JSON, debugging is trivial.
- Bad, because it requires `apf-ai-service` to expose a REST surface its own ADR-0011 explicitly removed. Rejected upstream; not a real option.

#### WebSocket relay on the AI side

- Good, because bidirectional, browser-native.
- Bad, because `apf-ai-service` does not implement WebSocket and its v0 scaffold doesn't include the lifecycle plumbing for it. Same rejection as the REST shim.

### Sub-choice 2 — BFF ↔ SPA transport for chat

#### SSE (chosen)

- Good, because every browser supports `EventSource`; no client library to ship.
- Good, because Angular needs no special integration — `EventSource` is a standard DOM API. The chatbot widget is a thin signal-driven wrapper.
- Good, because the BFF's existing observability already handles SSE (Pino logs the response, the request stays in the OpenTelemetry span for the duration).
- Good, because semantics match the use case exactly: server-to-client streaming, named events, structured payloads.
- Bad, because SSE is over HTTP/1.1 by default and consumes one TCP connection per active stream. Acceptable in v1 (low concurrent-chat count); upgrade to HTTP/2 multiplexing is transparent if and when the BFF's edge moves to h2.
- Bad, because SSE has no native cancellation primitive — the BFF detects browser disconnect via the underlying socket close, not via a wire message. Standard practice; not a real limitation.

#### WebSocket

- Good, because bidirectional.
- Bad, because the SPA never needs to send mid-stream messages on the same connection — the next user prompt is a new HTTP request. Bidirectionality is unused.
- Bad, because WebSocket auth needs a separate handshake (cookies do not always flow on the upgrade) — extra work for no payoff.
- Bad, because WebSocket framing is binary by default; observability layers (Pino, OpenTelemetry) require explicit instrumentation.

#### gRPC-Web through a sidecar

- Good, because the wire format on both hops would be the same proto schema; one less translation layer.
- Bad, because of the ~100 KB SPA bundle hit and the sidecar infrastructure cost (see Sub-choice 1).
- Bad, because the BFF then becomes a passthrough rather than an enforcement layer — auth, audit, and rate-limiting either move to the sidecar (operational complexity) or get bypassed (security regression).

#### Long-polling

- Bad, because higher latency than SSE for incremental tokens. Rejected on UX alone.

### Sub-choice 3 — proto distribution

#### Vendor + ts-proto codegen (chosen)

- Good, because the BFF build is hermetic — no network, no sibling working tree required at CI time.
- Good, because diffs in code review show both the proto change and the regenerated stub change; reviewers can spot accidental contract drift.
- Good, because rollback is one `git revert` of the BFF-side commit.
- Bad, because two repos hold the same proto text — drift can happen between syncs. Mitigated by the proposed CI gate (first follow-up).
- Neutral, because the sync workflow is a single `pnpm` script; contributors do not need to remember sub-commands.

#### Git submodule

- Good, because there is exactly one source of the proto text.
- Bad, because submodules add friction for every contributor (`git submodule update` on clone and after pull). The CLAUDE.md tech bar penalises operational friction.
- Bad, because submodules complicate Renovate's update flow.

#### npm package published by `apf-ai-service`

- Good, because the BFF's `pnpm install` would resolve to a versioned artifact, Renovate-driven.
- Bad, because `apf-ai-service` does not have an npm publishing pipeline yet — adding one is a chantier on the AI side, not a v1 BFF concern.
- Bad, because the npm package would have to be regenerated from the same protos in a Node-friendly shape, duplicating the codegen step `ts-proto` already does locally. The tradeoff only makes sense once a _third_ consumer exists.

#### Live cross-repo reference (`pnpm link:../apf-ai-service`)

- Bad, because the BFF CI would either have to clone `apf-ai-service` or fail at install time. Rejected on hermetic-build grounds alone.

### Sub-choice 4 — POC user-identity posture

#### Unsigned Principal in the proto body (chosen)

- Good, because it mirrors `apf-ai-service`'s ADR-0010 exactly — the trust contract is documented identically on both sides.
- Good, because no key material is in scope for v1: no JWKS endpoint to host, no key rotation playbook to write, no signing-secret to manage in env vars.
- Good, because the identity _is_ carried end-to-end — the AI service's RBAC enforcement uses the `Principal.roles[]` regardless of whether the principal is signed.
- Bad, because the trust boundary depends on the network ("the AI service is only reachable from the BFF on the private network"). The deployment topology has to enforce that; a misconfigured ingress could expose the AI service to unauthenticated traffic. Mitigation: production deployment guide (when written) calls out the network ACL as a non-optional control.
- Neutral, because the migration to a signed posture later is a one-PR change in each repo — both sides know the destination.

#### Signed RS256 JWT in proto envelope or metadata

- Good, because the identity is cryptographically attested — the AI service does not need to trust the BFF's network address.
- Bad, because v1 has no second consumer. Building the signer + verifier + JWKS distribution for a single trust path is premature.
- Bad, because rotation playbook + monitoring (clock skew, key freshness) add operational surface before there is a security need.
- This is the most likely production-hardening choice and is recorded as such; just not for v1.

#### mTLS

- Good, because identity is at the transport layer — no application-level handling needed in the AI service.
- Good, because it stacks with TLS termination at the edge proxy: the proxy presents the BFF cert, the AI service validates against a known CA.
- Bad, because cert provisioning, distribution, and rotation are a non-trivial PKI chantier — out of scope before there is a production deployment.
- The second viable production-hardening choice. Will be evaluated against the signed-JWT option when the time comes.

#### API key per deployment

- Bad, because a static API key does not carry user identity — the AI service's RBAC would have nothing to enforce against. Rejected on functionality alone.

## Open question — production hardening

When the first production deployment of `apf_portal` is in scope, the auth posture must move from unsigned to either:

- **Signed-envelope Principal** — `apf-ai-service` exposes a JWKS endpoint, the BFF signs an envelope around the `Principal` proto message with a private key, the AI service validates the signature on every call. Mirrors the design recorded in the older stargate ADR-0004 and aligns with the existing `DownstreamApiClient` pattern from [ADR-0014](0014-downstream-api-access-obo-pattern.md).
- **mTLS** — `apf-ai-service` validates the BFF's client certificate against a known CA at the gRPC channel level. Edge proxy still terminates the _public_ TLS; the internal hop becomes mTLS-authenticated. Lighter on application code, heavier on PKI infrastructure.

The decision is **coordinated** — both repos record it on the same date as an amendment to this ADR + an amendment to `apf-ai-service`'s ADR-0010 (or a fresh ADR on each side that supersedes both). v1 does not pick a side; the deferral is the actual decision.

## More Information

- **Vendored protos as of writing**: `apf.ai.v1` package at the head of `apf-ai-service` `main` (state at 2026-05-19). Pinned-tag tracking lands with the proposed CI drift gate.
- **Hash salt coordination**: the value of `Cache__PrincipalScopeHmacSecret` (`apf-ai-service` side) must equal the salt fed to the portal's `HashUserIdService`. The shared-secret distribution mechanism is recorded in a deployment doc when v1 lands; v0 uses the same constant for both repos in dev.
- **Phasing**:
  1. This ADR accepted.
  2. PR — vendor protos + codegen pipeline + `AiClientModule` skeleton + metadata interceptor + Principal mapper. No live endpoint yet, all wired against a fake gRPC server in tests.
  3. PR — `ai-bridge` controller + SSE writer + `POST /api/ai/chat`, `GET /api/ai/rag/search`, `GET /api/ai/models`. Live against `apf-ai-service` in dev Compose.
  4. PR (frontend chantier) — chatbot widget consuming the SSE endpoint.
  5. PR (post-v1 follow-up) — proto-drift CI gate.
- **Related ADRs**:
  - [ADR-0005](0005-backend-stack-nestjs.md) — NestJS BFF, the host of the relay module.
  - [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) — BFF pattern, sole session holder.
  - [ADR-0010](0010-session-management-redis.md) — session source from which the Principal is built.
  - [ADR-0012](0012-observability-pino-opentelemetry.md) — W3C trace propagation contract honoured by `x-correlation-id`.
  - [ADR-0013](0013-audit-trail-separated-postgres-append-only.md) — same `actor_id_hash` contract joining portal and AI audit trails.
  - [ADR-0014](0014-downstream-api-access-obo-pattern.md) — the OBO downstream pattern this ADR deliberately does _not_ apply (the AI service is not Entra-protected).
  - [ADR-0017](0017-performance-budgets-lighthouse-ci.md) — bundle budget the SPA-facing SSE choice stays under.
  - `apf-ai-service/docs/adr/ADR-0010` — POC unsigned principal posture mirrored here.
  - `apf-ai-service/docs/adr/ADR-0011` — mono-transport gRPC choice this ADR depends on.
