gRPC↔GraphQL translation (the edge BFF)
Every request you send to Osyra hits exactly one public process: the edge gateway (service AP00001). The gateway is a Backend-for-Frontend (BFF) — it speaks typed, client-friendly GraphQL plus versioned REST surfaces to the outside world, and it speaks gRPC over mutual TLS to the fleet of backend services that actually do the work. This page explains that translation: why it exists, what crosses the boundary, and what happens when a backend a field needs is not yet deployed.
The two-language boundary
The gateway is deliberately bilingual, and the split is a design decision, not an accident of history:
- Outward (to you): GraphQL and REST over HTTPS. GraphQL is the primary typed
BFF endpoint (
POST /graphql), so one round trip can fetch exactly the fields a screen needs and nothing more. The gateway also exposes Osyra-native REST under/api/v1/*, a narrow OpenAI-compatible subset under/v1/*, OAuth endpoints under/oauth/*, OpenID discovery under/.well-known/*, and SCIM 2.0 provisioning under/api/v1/iam/scim/v2/*. - Inward (to backends): gRPC over mutual TLS. Internally, services talk gRPC. It is binary, schema-checked by protobuf, and on the order of several times faster than chaining REST hops — which matters when one GraphQL query expands into several backend calls. The internal network is private; the only way in is through the gateway.
You (browser, SDK, CLI)
│ HTTPS — GraphQL (/graphql) + REST (/api/v1, /v1, /oauth, /.well-known)
▼
┌──────────────────────────────────────────────────────────┐
│ OSYRA EDGE GATEWAY (AP00001, Go) │
│ TLS → RequestID → SecurityHeaders → Recover → Log │
│ protected: API key exchange (if present) → JWT validate │
│ → Rate limit → OPA policy → Quota → Audit │
│ → GraphQL resolvers / REST proxy │
└──────────────────────────────────────────────────────────┘
│ gRPC over mutual TLS (private network)
▼
IAM · Billing · Model Broker · Audit · OME
Verifier · OsyraStream · Policy · Policy-authoring
Agent runtime · Ingestion · Workflow · …The protected middleware chain shown above is the same one documented for the
authentication flow. An HTTP GraphQL request can
present a bearer JWT directly or a canonical X-Api-Key; an API key is
authenticated through IAM and exchanged for a short-lived bearer before the
normal JWT validation, rate-limit, OPA, quota, and audit gates run. WebSocket
subscriptions have a different boundary: the browser handshake deliberately
bypasses the HTTP JWT and gateway.rego middleware because it cannot carry the
bearer header. The GraphQL server validates the JWT from connection_init, then
subscription resolvers require the authenticated context and operation-specific
permissions before dispatching backend gRPC. Long-lived subscriptions also
revalidate the token during the stream and emit their typed subscription audit
events. There is no request-time gateway.rego decision or org-scoped HTTP quota
decision on the WebSocket handshake.
What a GraphQL request becomes
When a resolver runs, it does not touch a database. It calls a typed service adapter backed by one or more internal gRPC clients, then maps the protobuf reply into the GraphQL types you requested. The clients share the gateway's mTLS connection and circuit-breaking foundation, but deadlines and retries are method-specific: curated idempotent reads can use bounded automatic retries, while long-lived streams use the caller's context and deliberately do not receive a blanket per-call timeout or transparent replay.
A single query can fan out to several of these clients. Consider one request that
asks for a workspace, its members, and recent usage: the gateway resolves the
workspace and members against the IAM client and the usage figures against the
Billing client, then assembles them into one typed GraphQL response. To keep
that fan-out from degenerating into the classic N+1 problem, the gateway's
DataLoader deduplicates repeated organization lookups, coalesces organization
workspace lookups, and collapses a set of Workspace.members loads into one
org-scoped ListWorkspaceMembersBatch RPC. There is no GetUsersBatch path in
the current gateway.
This is why GraphQL errors carry a structured extensions.osyra block with a
request id and an OSY-* code (see error codes):
the gateway is translating a gRPC status from some backend into a GraphQL error,
and the code tells you which layer produced it.
The backend fleet
The GraphQL schema is a façade over the internal service fleet. You address a capability by GraphQL field; the gateway resolves the required service adapter. The major service families are:
| Backend (gRPC) | What the gateway routes to it |
|---|---|
| IAM (AP00002) | Users, organizations, workspaces, sessions, OAuth, SCIM |
| Billing (AP00003) | Usage, quota, subscription tier, invoices |
| Model broker (AP00006) | Chat completions, embeddings, model catalog, chatCompletionStream, and the inferenceStream projection over CompleteChatStream |
| OME / Osymem (AP00011) | Verified-memory claims, receipts, portable memory |
| Verifier (AP00016) | Verifier-of-record (VIR) receipt verification |
| OsyraStream (AP00012) | Continuous-attestation drift and stream receipts |
| Policy-compute (AP00019) | Policy evaluation; policy authoring |
| Audit | Audit-event storage (Kafka-backed) |
| Agent-runtime / Ingestion / Workflow | Agent execution, ingestion, workflow runs |
Resolvers in internal/graphql/resolver/ map these service contracts onto
GraphQL fields. The implementation is not a universal one-file-per-backend
layout: some projections share an existing client connection, and
inferenceStream deliberately reuses the AP00006 Model Broker client rather than
dialling AP00008 from Edge.
Typed Unwired* fallbacks — never a stub, never a fake row
Osyra ships the GraphQL schema for a capability before every backend behind it
is deployed in a given environment. A field for a backend that is not yet wired
must not return invented data, and it must not 500. The gateway resolves this
with an explicit, typed Unwired* fallback: when a backend's address is unset
in configuration, the resolver is wired to an Unwired<Service>Service
implementation that fails closed with a precise, machine-readable code rather
than fabricating a result.
Concretely, the general memory-event and Verified Memory receipt families have separate fail-closed adapters and separate codes. The same pattern covers other not-yet-deployed surfaces, each with its own code, for example:
| Field family | Fallback implementation | Error code when unwired |
|---|---|---|
| Memory events | UnwiredMemoryService | OSY-OSYMEM-NOT-WIRED |
| Verified Memory receipts | UnwiredVerifiedMemoryService | OSY-VERIFIED-MEMORY-NOT-WIRED |
| Verifier-of-record receipts | UnwiredReceiptService | OSY-VIR-NOT-WIRED |
| Policy authoring | UnwiredPolicyAuthoringService | OSY-POLICY-NOT-WIRED |
| Drift / attestation | UnwiredDriftService | OSY-DRIFT-NOT-WIRED |
| Streaming inference | UnwiredInferenceService | OSY-INFER-NOT-WIRED |
| Ingestion | UnwiredIngestionService | OSY-INGEST-NOT-WIRED |
| Workflow | UnwiredWorkflowService | OSY-WORKFLOW-NOT-WIRED |
| Audit feed | UnwiredAuditService | OSY-AUDIT-NOT-WIRED |
| Agent runtime | UnwiredAgentRuntimeService | OSY-AGENTRT-NOT-WIRED |
The selection is made once, at gateway start, from the service addresses in
internal/config/config.go and the wiring in internal/graphql/server.go: a
configured address gets the real gRPC client; an unset one gets the typed
fallback. Because the fallback still runs the resolver's authorization gates
before returning its NOT-WIRED code, an unauthorized caller is rejected the same
way it would be against the live backend — the fallback never relaxes auth.
This matters for you as a client: a NOT-WIRED code is a clear, stable signal
that a capability exists in the schema but is not deployed in this environment.
It is distinct from an authorization or validation error, so your client can tell
"not available here" apart from "you may not do this."
Subscriptions cross the same boundary, over WebSocket
Queries and mutations cross the boundary as request/response. Subscriptions
(chatCompletionStream, inferenceStream) are long-lived streams, so they ride a
WebSocket transport on the same /graphql path. Both projections ultimately
open AP00006 Model Broker's CompleteChatStream; AP00006 owns the downstream
AP00008 handoff and routing decision, while Edge relays translated chunks back
over the socket. Because a browser cannot attach an Authorization header to a
WebSocket handshake, subscriptions authenticate through a connection_init
payload instead — a carefully scoped carve-out covered in the
GraphQL transport reference.
Why this shape
The BFF boundary buys three things at once:
- One audited, policy-checked front door. Protected GraphQL, Osyra-native
REST, and OpenAI-compatible REST traffic enter through the same gateway.
HTTP queries and mutations share the API-key/JWT, rate-limit, OPA, quota, and
audit chain before a backend is touched. WebSocket subscriptions use
connection_inittoken validation, resolver permission gates, mid-stream revalidation, and typed subscription audit instead of the HTTP JWT/OPA/quota handshake chain. Public and optional-auth protocol routes retain their own deliberately narrower security boundary; there is no public route to a backend service around the gateway. - Client-shaped responses over a microservice backend. You ask for the fields one screen needs; the gateway does the multi-service fan-out, batching, and type mapping so you do not orchestrate gRPC yourself.
- Honest degradation. A capability can ship in the schema ahead of its
backend and degrade to a typed
NOT-WIREDerror — never a fabricated success — so what you see is always backed by a real service or an explicit "not here."
See also
- Authentication and authorization — the JWT, rate-limit, OPA, and audit chain for protected HTTP requests.
- GraphQL transport — the HTTP
POSTenvelope and the WebSocket subscription transport with itsconnection_initcarve-out. - GraphQL API reference — the generated schema: queries, mutations, subscriptions, and types.
- Error codes — the
OSY-*code families, including the*-NOT-WIREDsignals.