# Inter-app communication design (`@wrnexus/rpc`) Date: 2026-08-05 Status: phase 1 implemented (2026-08-05); phases 2–4 remain deferred Depends on: the permissions system (`@wrnexus/authz`), merged 2026-08-05 ## Problem Workspace apps are separate OS processes behind a Host-routed gateway — full isolation, own database registry, own memory. They can currently reach each other in exactly two ways: - `@wrnexus/helpers` — `appOrigin(name)` / `appUrl(name, path)`, string builders over the `WRNEXUS_WORKSPACE_ORIGINS` environment map. - `@wrnexus/pubsub` — fire-and-forget events, cross-process via the Redis, NATS or Kafka drivers. There is no request/response layer. An app that needs an answer from another hand-writes `fetch(appUrl("billing", "/api/invoice"))`: no shared contract, no type safety, no timeout, no retry policy, and no way for the callee to learn who the end user is. The permissions system reserved a seam for that last part — `exportSubjectContext` / `importSubjectContext` — and left it unbuilt. ## Scope Of the four cross-app call shapes, three already have implementations: | Shape | Status | | ---------------------- | ----------------------------------------------------------------- | | Request/response | Missing. This is the gap. | | Fire-and-forget events | `@wrnexus/pubsub` | | Scheduled / deferred | `@wrnexus/queue` — `defineJob`, durable store, workflows, cron | | Streaming | `@wrnexus/realtime` covers browser↔server; app↔app is not covered | This design builds request/response and app-to-app streaming. It does **not** wrap `pubsub` or `queue` in a new facade: they work, and an abstraction over working code leaks and needs keeping in sync. They instead gain identity propagation, and all four shapes are documented in one place so a developer knows what to reach for. ## Approach Service contracts live in the workspace's shared package and are imported by both sides. The callee `implement`s a contract; the caller gets a typed proxy client. Types flow through a normal TypeScript import — no generator, no generated file to go stale, no build step. This matches the idiom the framework already uses: `defineAuthz`, `defineJob`, `defineRbac`, `defineWorkflow`. Rejected alternatives: - **Callee exports types, caller imports them.** No new abstraction, but the callee still hand-writes and hand-mounts every route and nothing keeps the route path and the type in agreement. - **Generate a client from the callee's `app/api` routes.** Codegen over hand-written routes is brittle, and the permissions work has already demonstrated that a generated file with no consumer becomes dead weight. ## Module layout ``` @wrnexus/rpc contract.ts defineService(), the procedure builder server.ts implement(); routes discovered from app/services/*.ts client.ts serviceClient(contract, { as: ctx }) -> typed proxy transport.ts Transport interface, httpTransport(), inProcessTransport() identity.ts exportSubjectContext / importSubjectContext errors.ts ServiceError and retryability classification ``` ## Contracts ```ts // packages/shared/src/services/billing.ts — imported by both apps import { defineService, procedure } from "@wrnexus/rpc"; import { v } from "@wrnexus/validation"; export const billing = defineService({ name: "billing", procedures: { createInvoice: procedure .input(v.object({ userId: v.string(), amountCents: v.number().integer() })) .output<{ invoiceId: string }>() .permission("invoice:create"), }, }); ``` Input validation reuses `@wrnexus/validation`'s `v`, which already returns `{ ok, value, errors }`. No new validator is introduced. `.permission(id)` is **enforced**, not merely declarative: `implement` checks it against the callee's own catalog before invoking the handler, and a denial never reaches the handler. A handler may additionally perform finer-grained, resource-level checks — `.permission()` is the coarse gate, not the whole authorization story. Declaring it on the contract also means the caller can see, at the type level, what the call requires. ## Server side ```ts // app/services/billing.ts export default implement(billing, { async createInvoice({ userId, amountCents }, ctx) { /* ... */ }, }); ``` `buildRouter` discovers `app/services/*.ts` exactly as it discovers `app/authz` and `app/schemas`, skipping `*.gen.ts`. Procedures mount at `/__wrnexus/rpc//`. ## Client side ```ts const client = serviceClient(billing, { as: ctx }); const { invoiceId } = await client.createInvoice({ userId, amountCents }); ``` `{ as: ctx }` carries identity: it calls `exportSubjectContext(ctx)` to mint a short-lived signed token. ## Identity The token carries **`sub` and `tenant` only — never roles.** Every app shares the `PermissionStore`, so the callee resolves roles itself. A stale or forged roles claim is therefore impossible by construction, the token stays small, and there is no path to injecting privileges through a claim. Built on `@wrnexus/jwt`: `alg` pinned to HS256, signature verified before the payload is parsed, `exp`/`nbf`/`aud`/`iss` enforced. | Claim | Purpose | | -------- | ------------------------------------------------------------------------------ | | `iss` | calling app name | | `aud` | target app name, so B cannot replay A's token to C | | `sub` | subject id — a non-empty string, matching the permissions system's requirement | | `tenant` | tenant id, absent for global | | `exp` | 60 seconds | **The token authenticates; it never authorizes.** The callee runs its own permission checks against its own catalog. An app cannot grant access the callee's catalog does not allow. ### Trust boundary With a workspace-wide signing secret, any app can mint a token claiming to be any user. All workspace apps are therefore one trust boundary: compromising the lowest-privilege app compromises user identity across all of them. This is the standard trade-off and is acceptable for apps in one repository under one operator, but it must be documented rather than discovered. Per-app signing keys are the hardening path if apps ever run at differing trust levels. The RPC secret MUST NOT be the session secret. Reusing it would make a leaked RPC token a session-forgery primitive. ### The RPC path is a new attack surface `/__wrnexus/rpc/*` must be unreachable from the public internet — blocked at the gateway **and** verified at the app, because either alone is a single point of failure. If that endpoint is reachable with a forged token, every permission check in the workspace is bypassable. This is the single most important property in the design. ## Transport ```ts interface Transport { call( target: { app: string; service: string; procedure: string }, payload: unknown, options: { signal: AbortSignal; identity?: string }, ): Promise; } ``` `httpTransport()` is the default and the only production transport in v1. A gRPC transport, if ever needed, implements this interface and nothing else changes. `inProcessTransport()` implements the same interface by calling the implementation directly with no network. It exists for tests. ## Errors and retries Errors classify into three kinds, because the classification decides whether a retry is safe: | Kind | Retryable | | --------------------------------------------------------------------- | --------- | | Transport failure — connection refused, timeout, 5xx | Yes | | Application error — the procedure threw or returned a failure | No | | Rejected — validation failed, authorization denied, unknown procedure | No | Only procedures explicitly marked `.idempotent()` are retried at all. Retrying a non-idempotent `createInvoice` because a response was slow is how a customer gets billed twice: the default is no retry, and opting in is a deliberate act recorded in the contract. `ServiceError` crossing an app boundary is **opaque by default** — code and message only, no stack, no internal detail. An error that explains the callee's internals is an information leak, the same defect class as `authorizeDecision`'s original 403 body. ## Testing - **In-process transport.** Contract, identity round-trip and error classification are all testable without standing up two servers. - **Contract conformance suite.** An implementation must satisfy its contract at runtime, not only at compile time. - **Security regressions**, each proven by reverting the guard and confirming the test fails: the RPC path rejects an external request; a forged signature is rejected; a token with the wrong `aud` is rejected; an expired token is rejected; a missing token is rejected. ## Build order 1. Contract, server discovery, typed client, HTTP transport, identity — the usable core. 2. Failure handling: timeouts, idempotent retry, circuit breaking. 3. App-to-app streaming. 4. Identity propagation for `pubsub` and `queue`, and the unified "how apps talk" guide. Phase 1 is what makes the system usable; everything after is additive. ## Out of scope - gRPC and Protocol Buffers. The `Transport` seam exists so this stays additive. gRPC's value is cross-language interop and streaming; workspace apps are all TypeScript in one repository sharing types through workspace packages, so a typed HTTP transport delivers end-to-end type safety without a code generator. - Wrapping `pubsub` or `queue` behind a unified facade. - Service discovery beyond the existing `WRNEXUS_WORKSPACE_ORIGINS` map. - Cross-workspace federation.