docs(adr-0024): ai service relay — gRPC dial + SSE bridge + POC principal #194

Merged
julien merged 1 commits from docs/adr-0024-ai-service-relay into main 2026-05-19 20:11:16 +02:00
Owner

Summary

Proposes ADR-0024 — the integration contract between apf_portal's BFF and the sibling apf-ai-service repository. The ADR bundles four tightly-coupled sub-choices: the wire transport between BFF and AI service, the wire transport between BFF and SPA for chat streaming, how the protos reach the BFF, and how user identity travels across the boundary in v1. Status: proposed. No code lands in this PR — the goal is to lock the contract before the implementation chantier starts.

The chosen design:

Boundary Choice
BFF ↔ AI service Native gRPC HTTP/2 via @grpc/grpc-js, h2c in dev / h2 + TLS in prod
BFF ↔ SPA (chat) text/event-stream — one SSE frame per ChatEvent oneof case
BFF ↔ SPA (unary) Plain JSON endpoints for RagService.Search + ModelsService.ListModels
Proto distribution Vendored into apps/portal-bff/src/grpc/proto/apf-ai/, ts-proto codegen on demand, both .proto + generated .ts committed
Identity (POC) Unsigned Principal { subject, roles[], attributes{} } in the proto body — mirrors apf-ai-service's ADR-0010
Production hardening Choice between signed envelope and mTLS — explicitly deferred until first production deployment is in scope

What lands

  • docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md — new MADR-formatted ADR with the four sub-choices, decision drivers, considered options, consequences, confirmation criteria, open production-hardening question, and the related-ADRs map.
  • docs/decisions/README.md — one new index row for ADR-0024 (proposed, tags backend, security, observability, 2026-05-19).

No source-code changes. No CLAUDE.md update — the ADR stays in proposed until reviewed, so the accepted-ADRs roll-up at the top of CLAUDE.md stays at 0001 → 0023. Promotion to accepted lands in the same PR that ships the first implementation chantier (proto vendor + AiClientModule), at which point CLAUDE.md gets the "0024 accepted" line.

Notes for the reviewer

  • Why bundle four sub-choices in one ADR rather than four. They couple tightly: the SPA-facing transport choice depends on the BFF-facing transport choice (gRPC-Web from the browser would dissolve the bridge layer entirely); the auth posture depends on having identity travel in the proto body (vendoring a different contract would change that); the proto-distribution choice depends on the contract being stable enough to vendor (a churning OpenAPI spec would push toward an SDK package). Splitting would force cross-ADR coordination on every revision. The ADR keeps a separate "Sub-choice" section per topic so each one stays reviewable on its own.
  • Out of scope deliberately. The chatbot UI lives in a future frontend chantier; the role mapper (Entra groups → inclusive-expanded roles[]) is a separate proposed ADR; the ingestion-through-BFF path waits for the admin app's "manage AI corpus" surface; tool dispatch is wired but exercised against an empty registry in v1.
  • Hash-salt coordination is the one operational gotcha. The same HashUserIdService salt has to land in both repos' deployment config so apf-ai-service.audit_log.actor_id_hash and apf_portal.audit.events.actor_id_hash produce identical values. Recorded as an open item in the ADR's "More Information" section; the deployment doc that distributes the secret is a v1-launch deliverable.
  • apf-ai-service cross-reference. The ADR references apf-ai-service/docs/adr/ADR-0010 (POC unsigned principal) and apf-ai-service/docs/adr/ADR-0011 (mono-transport gRPC) as upstream anchors. Both are already accepted on the AI side. The "production hardening" decision will be a coordinated amendment in both repos on the same date.
  • No DownstreamApiClient (ADR-0014) reuse. The OBO pattern in ADR-0014 targets Entra-protected downstreams that validate the user's access token. apf-ai-service is not Entra-protected — it accepts an unsigned Principal proto. The ADR explicitly calls this out so the reader does not expect symmetry with the Entra-protected downstream path.
  • Phasing recorded in the ADR's "More Information" section. This PR is step (1) "ADR accepted". Steps 2–5 are separate PRs in order: client skeleton → bridge controller → frontend chatbot → proto-drift CI gate.

Test plan

  • pnpm run --silent prettier --check docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md — passes (hook ran on commit).
  • Markdown links inside the ADR resolve to existing files (0005, 0009, 0010, 0012, 0013, 0014, 0017, plus CLAUDE.md).
  • Index row in docs/decisions/README.md follows the table's existing format (column count, tag vocabulary, date format).
  • No tag-vocabulary additions required — backend, security, observability are all in the existing vocab.
  • Review focus — the four sub-choices and the production-hardening deferral. Code chantier is gated on this PR's acceptance.

What's next (once accepted)

  1. PR — proto vendor + codegen + AiClientModule skeleton — vendors the protos, wires ts-proto codegen, sets up the NestJS module with the metadata interceptor and the Principal mapper, all tested against an in-process fake gRPC server. No live endpoint yet.
  2. PR — ai-bridge controllerPOST /api/ai/chat (SSE), GET /api/ai/rag/search, GET /api/ai/models, live against apf-ai-service in the dev Compose stack.
  3. PR (frontend chantier) — the chatbot widget on portal-shell consuming the SSE endpoint.
  4. PR (post-v1) — proto-drift CI gate that diffs the vendored copy against the upstream tag.
  5. Coordinated amendment — when the first production deployment is in scope, both repos record the same prod-hardening choice (signed envelope or mTLS) on the same date.
## Summary Proposes [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md) — the integration contract between `apf_portal`'s BFF and the sibling `apf-ai-service` repository. The ADR bundles four tightly-coupled sub-choices: the wire transport between BFF and AI service, the wire transport between BFF and SPA for chat streaming, how the protos reach the BFF, and how user identity travels across the boundary in v1. **Status: `proposed`.** No code lands in this PR — the goal is to lock the contract before the implementation chantier starts. The chosen design: | Boundary | Choice | |---|---| | BFF ↔ AI service | Native **gRPC HTTP/2** via `@grpc/grpc-js`, h2c in dev / h2 + TLS in prod | | BFF ↔ SPA (chat) | **`text/event-stream`** — one SSE frame per `ChatEvent` oneof case | | BFF ↔ SPA (unary) | Plain JSON endpoints for `RagService.Search` + `ModelsService.ListModels` | | Proto distribution | **Vendored** into `apps/portal-bff/src/grpc/proto/apf-ai/`, `ts-proto` codegen on demand, both `.proto` + generated `.ts` committed | | Identity (POC) | **Unsigned `Principal { subject, roles[], attributes{} }`** in the proto body — mirrors `apf-ai-service`'s ADR-0010 | | Production hardening | Choice between signed envelope and mTLS — **explicitly deferred** until first production deployment is in scope | ## What lands - `docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md` — new MADR-formatted ADR with the four sub-choices, decision drivers, considered options, consequences, confirmation criteria, open production-hardening question, and the related-ADRs map. - `docs/decisions/README.md` — one new index row for ADR-0024 (`proposed`, tags `backend, security, observability`, 2026-05-19). No source-code changes. No `CLAUDE.md` update — the ADR stays in `proposed` until reviewed, so the accepted-ADRs roll-up at the top of `CLAUDE.md` stays at 0001 → 0023. Promotion to `accepted` lands in the same PR that ships the first implementation chantier (proto vendor + `AiClientModule`), at which point `CLAUDE.md` gets the "0024 accepted" line. ## Notes for the reviewer - **Why bundle four sub-choices in one ADR rather than four.** They couple tightly: the SPA-facing transport choice depends on the BFF-facing transport choice (gRPC-Web from the browser would dissolve the bridge layer entirely); the auth posture depends on having identity travel in the proto body (vendoring a different contract would change that); the proto-distribution choice depends on the contract being stable enough to vendor (a churning OpenAPI spec would push toward an SDK package). Splitting would force cross-ADR coordination on every revision. The ADR keeps a separate "Sub-choice" section per topic so each one stays reviewable on its own. - **Out of scope deliberately.** The chatbot UI lives in a future frontend chantier; the role mapper (Entra groups → inclusive-expanded `roles[]`) is a separate proposed ADR; the ingestion-through-BFF path waits for the admin app's "manage AI corpus" surface; tool dispatch is wired but exercised against an empty registry in v1. - **Hash-salt coordination is the one operational gotcha.** The same `HashUserIdService` salt has to land in both repos' deployment config so `apf-ai-service.audit_log.actor_id_hash` and `apf_portal.audit.events.actor_id_hash` produce identical values. Recorded as an open item in the ADR's "More Information" section; the deployment doc that distributes the secret is a v1-launch deliverable. - **`apf-ai-service` cross-reference**. The ADR references `apf-ai-service/docs/adr/ADR-0010` (POC unsigned principal) and `apf-ai-service/docs/adr/ADR-0011` (mono-transport gRPC) as upstream anchors. Both are already accepted on the AI side. The "production hardening" decision will be a coordinated amendment in both repos on the same date. - **No `DownstreamApiClient` (ADR-0014) reuse.** The OBO pattern in ADR-0014 targets *Entra-protected* downstreams that validate the user's access token. `apf-ai-service` is not Entra-protected — it accepts an unsigned Principal proto. The ADR explicitly calls this out so the reader does not expect symmetry with the Entra-protected downstream path. - **Phasing recorded in the ADR's "More Information" section.** This PR is step (1) "ADR accepted". Steps 2–5 are separate PRs in order: client skeleton → bridge controller → frontend chatbot → proto-drift CI gate. ## Test plan - [x] `pnpm run --silent prettier --check docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md` — passes (hook ran on commit). - [x] Markdown links inside the ADR resolve to existing files (`0005`, `0009`, `0010`, `0012`, `0013`, `0014`, `0017`, plus `CLAUDE.md`). - [x] Index row in `docs/decisions/README.md` follows the table's existing format (column count, tag vocabulary, date format). - [x] No tag-vocabulary additions required — `backend`, `security`, `observability` are all in the existing vocab. - [ ] **Review focus** — the four sub-choices and the production-hardening deferral. Code chantier is gated on this PR's acceptance. ## What's next (once accepted) 1. **PR — proto vendor + codegen + `AiClientModule` skeleton** — vendors the protos, wires `ts-proto` codegen, sets up the NestJS module with the metadata interceptor and the Principal mapper, all tested against an in-process fake gRPC server. No live endpoint yet. 2. **PR — `ai-bridge` controller** — `POST /api/ai/chat` (SSE), `GET /api/ai/rag/search`, `GET /api/ai/models`, live against `apf-ai-service` in the dev Compose stack. 3. **PR (frontend chantier)** — the chatbot widget on `portal-shell` consuming the SSE endpoint. 4. **PR (post-v1)** — proto-drift CI gate that diffs the vendored copy against the upstream tag. 5. **Coordinated amendment** — when the first production deployment is in scope, both repos record the same prod-hardening choice (signed envelope or mTLS) on the same date.
julien added 1 commit 2026-05-19 20:10:58 +02:00
docs(adr-0024): ai service relay — gRPC dial + SSE bridge + POC principal
CI / scan (pull_request) Successful in 2m45s
CI / commits (pull_request) Successful in 2m44s
CI / check (pull_request) Successful in 2m59s
CI / a11y (pull_request) Successful in 2m15s
Docs site / build (pull_request) Successful in 2m23s
CI / perf (pull_request) Successful in 5m9s
01fe01ffe7
Proposes the integration contract between portal-bff and apf-ai-service:

- BFF dials the AI service over native gRPC HTTP/2 (h2c in dev, h2 +
  TLS in prod) via @grpc/grpc-js + ts-proto stubs.
- Browser-facing chat surface is text/event-stream — each ChatEvent
  oneof case translates to one SSE frame; cancellation flows
  browser-close → call.cancel() → upstream LLM stop.
- Proto files vendored from apf-ai-service/contract/proto into the BFF
  with on-demand codegen; both proto + generated stubs committed for
  hermetic builds and reviewable diffs.
- POC ships unsigned Principal { subject, roles[], attributes{} } in
  the proto body, mirroring apf-ai-service's ADR-0010. Subject is the
  Entra oid hashed with the same HashUserIdService salt as the audit
  module so apf_portal and apf-ai-service audit trails join cleanly.
- Production hardening choice (signed envelope vs mTLS) deferred until
  the first production deployment is in scope; recorded as an open
  item to be amended jointly in both repos.
julien merged commit 3f3f47317b into main 2026-05-19 20:11:16 +02:00
julien deleted branch docs/adr-0024-ai-service-relay 2026-05-19 20:11:16 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#194