Typed request/response between workspace apps over HTTP, behind a Transport seam so gRPC stays additive rather than a rewrite. Contracts live in the workspace's shared package and are imported by both sides, so types flow through a normal import with no code generator. Consumes the exportSubjectContext/importSubjectContext seam the permissions system reserved, with one improvement on what that seam implied: 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 becomes impossible by construction and there is no path to injecting privileges through a claim. The token authenticates; it never authorizes. Records two properties that are easy to get wrong and expensive to discover: /__wrnexus/rpc/* must be unreachable from the public internet, blocked at the gateway AND verified at the app, or every permission check in the workspace is bypassable; and only procedures explicitly marked idempotent may be retried, because retrying a slow createInvoice is how a customer gets billed twice. Deliberately does not wrap pubsub or queue - they work, and an abstraction over working code leaks and needs keeping in sync. They gain identity propagation instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.9 KiB
Inter-app communication design (@wrnexus/rpc)
Date: 2026-08-05
Status: approved, not yet implemented
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 theWRNEXUS_WORKSPACE_ORIGINSenvironment 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 implements 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/apiroutes. 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
// 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
// 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/<service>/<procedure>.
Client side
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
interface Transport {
call(
target: { app: string; service: string; procedure: string },
payload: unknown,
options: { signal: AbortSignal; identity?: string },
): Promise<ServiceResult>;
}
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
audis rejected; an expired token is rejected; a missing token is rejected.
Build order
- Contract, server discovery, typed client, HTTP transport, identity — the usable core.
- Failure handling: timeouts, idempotent retry, circuit breaking.
- App-to-app streaming.
- Identity propagation for
pubsubandqueue, 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
Transportseam 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
pubsuborqueuebehind a unified facade. - Service discovery beyond the existing
WRNEXUS_WORKSPACE_ORIGINSmap. - Cross-workspace federation.