Files
apf_portal/docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md
T
Julien Gautier d691c1f5cf
CI / scan (pull_request) Successful in 2m59s
CI / commits (pull_request) Successful in 3m6s
CI / check (pull_request) Successful in 3m32s
CI / a11y (pull_request) Successful in 1m50s
Docs site / build (pull_request) Successful in 2m34s
CI / perf (pull_request) Successful in 4m4s
feat(portal-bff): ai-bridge controller — SSE chat + JSON rag/models
Wires the AI relay into the BFF per ADR-0024 (now accepted):

- AiBridgeController exposes POST /api/ai/chat (text/event-stream),
  GET /api/ai/rag/search, GET /api/ai/models under /api/ai/*.
  Session-gated (req.session.user) without an admin role — AI is
  an end-user surface. CSRF and double-submit token handled by the
  global middleware mounted in main.ts.
- sse.writer translates ChatEvent oneof cases to kebab-case SSE
  event names (token / citation / agent-step / tool-call / error /
  done), JSON-encodes the inner payload, and produces a relay-side
  error frame on upstream failures (mapped from gRPC Status codes
  into the urn:apf-ai:* namespace the AI service already uses).
- DTOs (class-validator) cap message count + length, restrict the
  role enum to user/assistant/system (tool messages are constructed
  caller-side by the BFF when tool dispatch lands), and bound RAG
  top_k between 1 and 50.
- Browser disconnect (req 'close' event) → AbortController →
  call.cancel(), upstream LLM stops without leaking the stream.
- AiClientModule now implements OnApplicationShutdown to close the
  four gRPC stubs on SIGTERM/SIGINT; main.ts calls
  app.enableShutdownHooks() to make it fire.
- ADR-0024 promoted from proposed to accepted; README index + the
  CLAUDE.md roll-up updated. apps/portal-bff/.env.example documents
  AI_SERVICE_GRPC_ENDPOINT / CLIENT_ID / TLS for local boot.
2026-05-19 22:27:30 +02:00

30 KiB

status, date, decision-makers, tags
status date decision-makers tags
accepted 2026-05-19 R&D Lead
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 §"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 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 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 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 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 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 (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 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, populated by the OIDC callback per ADR-0009:

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.

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), then re-issues ChatService.Chat with a ChatMessage { role: tool, tool_call_id, content } appended to the conversation.

v1 ships with an empty tool registrytools_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 Principalapf-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.
  • mTLSapf-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 — NestJS BFF, the host of the relay module.
    • ADR-0009 — BFF pattern, sole session holder.
    • ADR-0010 — session source from which the Principal is built.
    • ADR-0012 — W3C trace propagation contract honoured by x-correlation-id.
    • ADR-0013 — same actor_id_hash contract joining portal and AI audit trails.
    • ADR-0014 — the OBO downstream pattern this ADR deliberately does not apply (the AI service is not Entra-protected).
    • ADR-0017 — 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.