Oshun Platform · Architecture

Communication Patterns

A focused page within the Oshun Platform Architecture documentation. The full map and every sibling page live in the Architecture hub.

11sections17 minread2diagrams5tables

On this page

This page describes how the parts of Oshun V1 talk to each other: the synchronous request paths between client surfaces, the BFF, and per-domain services; the asynchronous event bus and job queues that carry cross-domain side effects; and the shared middleware contracts (idempotency, tenant routing, residency, the partial-failure envelope, audit) that every write threads through. It is the "plumbing" companion to Foundations and High-Level Architecture, and it serves anyone wiring a new domain route, subscribing to a new topic, or reasoning about what happens when a write half-succeeds. Everything here maps to real code under libs/shared/, libs/oshun/, libs/contracts/, libs/openapi/, and libs/proto/; where a capability is contracts-level or provider-gated rather than deployed, that is called out explicitly.

For the data model and tenancy details behind these flows, see Data Architecture and Tenancy. For the security and compliance posture of the audit/identity pieces, see Security, Privacy, and Compliance.


Two axes: synchronous request/response and asynchronous fan-out#

Oshun separates synchronous request/response traffic (a client asks for something and blocks for the answer) from asynchronous event/job traffic (a write commits, and downstream work happens later, independently, and idempotently). The reason for the split is blast radius. A customer pressing "complete session" should get a fast, definitive 200 OK the moment Tara's data is committed — they should not wait for Arete to recompute next-practice, for Iris to update memory, or for analytics to ingest. Those are consequences of the write, not part of it, so they ride the event bus and the queue substrate where each consumer fails and retries on its own clock without holding the request open.

The rest of this page walks both axes in turn, then shows two end-to-end flows — a generic BFF write and the Veritas claim-retraction cascade — that exercise the full middleware chain.


Synchronous#

HTTP REST — client ↔ BFF ↔ domain services#

The primary synchronous transport is HTTP REST. Client surfaces (the shared shell, admin web, Oshun Studio) call the Oshun BFF (a Fastify backend-for-frontend), and the BFF calls per-domain adapters. Every request carries JWT auth, and the BFF applies per-client rate limiting at the edge so a noisy tenant or a runaway script cannot starve other tenants of capacity.

The contract for every REST surface is an OpenAPI 3.1 spec, and the specs are derived from the Zod contracts, never hand-authored against them. Schemas drive specs (Zod → OpenAPI), and a CI gate fails the build when a spec drifts from the runtime. The specs live under libs/openapi/src/specs/, which contains 15 real per-domain spec directories plus the aggregate main.yaml and a v3.yaml:

text
arete  bellona  calliope  concordia  hathor  isis  lilith  metis
nisaba  nyx  oshun-bff  sophia  tara  veritas  yemaya

Note that this is broader than the customer-facing domain set: it includes substrate and studio specs (sophia, isis, hathor, bellona, calliope, yemaya, concordia) alongside the six customer domains and the BFF's own aggregate spec (oshun-bff).

Each customer-facing domain is mounted under a stable base path. The bff-base-path values come straight from the domain registry at libs/oshun/domain-registry/src/registry.ts (OSHUN_DOMAIN_IDS and DOMAIN_REGISTRY):

Domain BFF base path
Tara /api/oshun/domains/tara
Veritas /api/oshun/domains/veritas
Nyx /api/oshun/domains/nyx
Arete /api/oshun/domains/arete
Nisaba /api/oshun/domains/nisaba
Metis /api/oshun/domains/metis

The registry also names OSHUN_SHELL_PRIMARY_DOMAIN = 'tara', which is the domain the shared shell defaults to when no other primary is configured. See Customer-Facing Domains for what each domain does behind these paths.

WebSocket — real-time runtime#

REST is request/response; some surfaces are inherently streaming. WebSocket is the transport for Psyche, the real-time runtime substrate (Psyche — Real-Time Runtime Substrate): live transcripts, voice envelopes, and avatar sync flow over a persistent socket, as do the Living Scenes Live Direction Channel and Living Activities. These channels need bidirectional, low-latency frames that a polled REST endpoint cannot provide.

gRPC — internal service-to-service#

For internal service-to-service calls where latency or streaming matters, Oshun uses gRPC with Protocol Buffers defined under libs/proto/. The proto package is real and broad — it ships a buf.work.yaml workspace, a generated/ output tree, and roughly two dozen proto domain directories under libs/proto/src/, including agent, ai, asset, auth, bridge, generation3d, hathor, isis, sophia, splatting, rendering, concordia, collaboration, pipeline, loadbalancing, reflection, health, common, shared, user, oshun, procedural, project, and oya. In practice the highest-traffic gRPC surfaces are the ingestion and runtime pipelines for Psyche, Isis, and Sophia, but the proto surface is not limited to those three — it spans the generation, rendering, and agent stacks as well.


Asynchronous#

Event bus — @oshun/event-bus#

Cross-domain side effects ride the event bus at libs/shared/event-bus/. The public surface is EventBus (export class EventBus implements IEventBus) plus the createEventBus(config: EventBusConfig) factory, both exported from libs/shared/event-bus/src/index.ts. The pattern is commit-then-publish: a producer commits its owned data first, then publishes a domain event carrying a correlation ID. Subscribers project the event into their own read models or workflows, and idempotency is keyed by event.id so a redelivery is a no-op.

What the substrate actually is — not native Redis Streams#

The event bus is built on ioredis (import { Redis } from 'ioredis') with two separate connections — one publisher (pub) and one subscriber (sub), since Redis pub/sub connections in subscribe mode cannot also issue commands. But it is not built on native Redis Streams. There is no XADD/XREAD/XREADGROUP/XGROUP anywhere in libs/shared/event-bus/src/. Instead, the durability and delivery semantics are assembled from primitive Redis structures, as documented in the header of libs/shared/event-bus/src/event-bus.ts:

  • Fan-out is plain Redis pub/sub. publish() serializes the envelope and pushes it to subscribers over a pub/sub channel.
  • Replay source is a TTL-bounded key. The serialized envelope is stored under a key with eventTtl (default DEFAULT_EVENT_TTL = 86400 seconds, i.e. 24 hours), so replayUnacked can re-deliver work after a crash-restart. A separate Redis HASH tracks which (eventId, subscriptionId) / (eventId, group) pairs have already been acked, so restarts don't re-run already-processed work.
  • Delay / nack uses a durable sorted set named scheduled. nack(delay) and publish({ delay }) both push the event into scheduled keyed by its due time; a scheduler loop (default DEFAULT_SCHEDULER_INTERVAL = 250 ms) pulls due entries and publishes them. The event is durable before the call returns — setTimeout state is never the only copy.
  • Consumer groups are emulated with SET NX claim keys. When a subscription declares a group, members race for a per-(eventId, group) claim key via SET NX; only the winner runs the handler. Without a group, every matching subscription runs — classic broadcast.
  • Dead letter lives in a Redis list (for ordered pagination) plus a hash (for O(1) id lookup). removeDeadLetter uses LREM on the serialized entry so removal is atomic and sibling ordering is preserved. The default dead-letter config is { enabled: true, maxEntries: 10000 }.

So the accurate one-line description is: the event bus runs on ioredis pub/sub for fan-out, with a TTL key as the replay source, a scheduled sorted set for delay/nack, lists+hashes for the dead-letter, and SET NX claim keys to emulate consumer groupsnot native Redis Streams. (Older doc copy labeled this "Redis Streams"; that label was incorrect and has been retired from the V1 docs.)

Topic registry and schema versioning#

Topics are governed by a topic registry (topic-registry.ts, DEFAULT_EVENT_TOPIC_REGISTRY) with schema versioning. The bus carries a registryMode config: in 'advisory' mode (the default) it tolerates retired schemas and falls back to a default schema version; in 'enforced' mode an unknown or retired topic is rejected. Each published envelope records the resolved schemaVersion, so consumers can branch on it and producers can evolve a topic without breaking older subscribers.

Outbound delivery and the webhook simulator#

Beyond internal fan-out, the same library handles outbound webhook delivery to tenant integrations via outbound-delivery.ts, whose surface includes OutboundSigningKey, OutboundSignatureHeaders, SignOutboundEventInput, OutboundDeliveryTransport, OutboundDeliveryDeadLetter, and OutboundEventAuditRecord — i.e. signed payloads, retry/backoff, a dead-letter for failed deliveries, replay, and a per-event audit record. A webhook-simulator.ts lets a tenant's integration sandbox exercise these deliveries before going live.

Job queues — @oshun/queue#

Long-running work — editorial, asset processing, agentic-AI runs, notifications, billing, integration syncs — runs on the durable queue substrate at libs/shared/queue/. The files are exactly the substrate the docs claim: durable-queue.ts (priority classes, replay, deduplication), dead-letter-queue.ts, sla-monitor.ts (per-job-class SLA monitoring), memory-queue.ts (an in-memory implementation for tests/local), and worker.ts. A single shared substrate is consumed by every domain that needs background work, which keeps replay/DLQ/SLA behavior uniform instead of leaving each team to reimplement it.

The two async substrates are complementary: the event bus carries small, fan-out facts ("this happened"), and the queue carries large or slow units of work ("do this"). A producer often publishes an event, and a subscriber turns that event into a queued job.


The request lifecycle through the BFF#

Every customer- or operator-issued write passes the same sequence of middlewares before it reaches a domain adapter. The ordering is deliberate so that the cheap, short-circuiting checks run first:

  1. Idempotency is checked first — replays return the cached envelope without touching the domain at all.
  2. Tenant and residency resolve next, so the rest of the pipeline can short-circuit on policy (wrong region, missing cross-region consent) before any validation or write.
  3. The body is validated against its Zod contract.
  4. The domain adapter commits owned data with tombstone semantics.
  5. A domain event is published and an audit record appended.

The building blocks for every stage exist as real code (idempotency middleware, tenant context, residency enforcer, Zod contracts, domain adapters, event bus, audit platform). The diagram below describes that intended composition. No single production BFF route has been traced to prove that every middleware fires in exactly this order in the live wiring, so treat the ordering as the design intent rather than a verified end-to-end trace.

sequenceDiagram autonumber actor C as Client participant BFF as Oshun BFF participant Idem as Idempotency Cache participant T as Tenant Middleware participant R as Residency Enforcer participant V as Zod Contract Validator participant D as Domain Adapter participant DB as Domain DB participant EB as Event Bus participant A as Audit Platform C->>BFF: POST /api/oshun/domains/X<br/>Idempotency-Key · X-Tenant-ID · JWT BFF->>Idem: lookup(idempotencyKey) alt cache hit Idem-->>BFF: stored response BFF-->>C: 200 OK (replayed) else cache miss BFF->>T: resolve tenant + scopes T-->>BFF: tenant context · homeZone BFF->>R: check residency vs request region R-->>BFF: allow / require consent BFF->>V: validate body V-->>BFF: parsed payload BFF->>D: invoke(operation, payload) D->>DB: write owned data (tombstone-aware) DB-->>D: ack D->>EB: publish domain event<br/>{correlationId} D->>A: append mutation record D-->>BFF: result BFF->>Idem: store(key, response) BFF-->>C: 200 OK end

Stage details, grounded in code#

Idempotency comes from libs/shared/http-client/src/idempotency.ts. The header name is DEFAULT_IDEMPOTENCY_HEADER = 'Idempotency-Key', the default retention is DEFAULT_IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000 (24 hours), and the machinery is the IdempotencyMiddleware class with a createIdempotencyMiddleware() factory. This is one of several cross-cutting HTTP concerns in the same library: the sibling modules circuit-breaker.ts, ssrf-guard.ts, retry.ts, tenant-context.ts, and tracing.ts provide circuit-breaking, SSRF protection, retry/backoff, tenant propagation, and distributed tracing, respectively — the BFF and inter-service callers compose these rather than reinventing them.

Tenant and residency are enforced by libs/shared/data-residency/, whose modules are enforcer.ts, home-zone.ts, traffic-shaping.ts, and dsr-routing.ts. Tenant-aware middleware reads X-Tenant-ID (or the token claim) and propagates it downstream; the residency layer resolves the user's home zone and shapes traffic toward their primary data plane unless explicit cross-region consent is present. See Data Architecture and Tenancy for the full tenancy model.

Validation is the Zod contract for the operation (one source per object class, each with a round-trip fixture; see Foundations).

The adapter writes with tombstone semantics — deletions are soft tombstones that propagate and are audit-logged, never silent re-creation — and the persistence layer enforces this at libs/oshun/persistence/. That library renders Prisma from the contracts (prisma-renderer.ts, ~11.8 KB; zod-prisma-introspection.ts, ~5.5 KB) and guards correctness with a quartet of tests that runs in CI: contract-persistence-registry.test.ts (Zod↔persistence drift), index-requirements.test.ts (per-table required indexes), migration-plan.test.ts (idempotent, dry-runnable migrations), and tombstone-semantics.test.ts. It also ships dsar-deletion-cascade.ts and dsar-erasure-runtime.ts for privacy deletions — the runtime side of the cascade flows shown below.

Event and audit complete the write: the event goes to @oshun/event-bus, and the audit record is appended to libs/shared/audit-platform/ (see below).


The partial-failure envelope contract#

When an operation fans out over several items or several domains and some succeed while others fail, the response cannot honestly be a single success or single error. Oshun standardizes this case with a first-class Zod contract at libs/contracts/src/common/partial-failure-envelope.ts — not just an informal shape. The exported surface is:

  • PartialFailureEnvelopeSchema — the concrete schema, shape { results: unknown[], errors: PartialFailureError[], partial: boolean }.
  • PartialFailureErrorSchema / PartialFailureError — each error is { domain: string, stage?: string, message: string } (so a consumer can see which domain and which stage failed, not just a flat message).
  • createPartialFailureEnvelopeSchema(resultSchema, errorSchema?) — a generic factory that produces a typed envelope for a specific result schema (and optionally a richer error schema), so each caller gets a strongly-typed results array instead of unknown[].
  • buildPartialFailureEnvelope({ results, errors, partial? }) — a builder that computes partial from whether errors is non-empty and throws if a caller passes a partial flag that contradicts the actual error count.
  • isPartialFailureEnvelope(value) — a structural type guard.

The contract enforces a cross-field invariant via superRefine, so an inconsistent envelope is rejected at the boundary:

ts
// from partial-failure-envelope.ts (cross-field refinement)
if (value.partial && value.errors.length === 0) {
  // "partial=true requires at least one error entry."
}
if (!value.partial && value.errors.length > 0) {
  // "partial=false requires an empty errors array."
}

In other words: partial === trueerrors.length >= 1. You cannot claim a partial failure with no errors, nor claim full success while reporting errors. This is the contract behind the informal { results[], errors[], partial: true } shape that older copy describes — and it pairs with the retry/dedup/replay/dead-letter contract tests that ride on every job submitter.


Identity, roles, and the platform-foundations package#

Authentication and authorization sit underneath every synchronous and asynchronous path. There are three real homes for this:

  • libs/shared/auth-primitives/ is richer than "JWT and session primitives." It exports jwt.ts, session.ts, api-key.ts, oauth-client.ts, oauth-revoke.ts, token-refresh.ts, token-audit.ts, totp.ts (TOTP / step-up MFA), platform-roles.ts, tenant-isolation.ts, and password.ts — the full set of token, key, MFA, role, and tenant-isolation primitives.
  • libs/oshun/auth/ is published as @oshun/auth-client, the workspace-side identity and session client every Oshun app consumes.
  • libs/shared/identity/ holds RBAC and permission checks.

The @oshun/platform-foundations package#

The platform-level identity, public-API, and operational subsystems are consolidated in one package, libs/oshun/platform-foundations/. Its src/index.ts re-exports exactly nine subsystems, each in its own directory:

Subsystem Concern
service-discovery Locating peer services.
public-api The external API platform, including OAuth 2.1 / PKCE.
shared-contracts Cross-cutting platform contracts.
role-model The canonical role + scope model (below).
step-up Step-up authentication for sensitive operations.
secrets Secret management.
configs Platform configuration.
rollback Rollback controls.
abuse-controls Abuse / rate / anomaly controls.

OAuth 2.1 / PKCE (§27)#

libs/oshun/platform-foundations/src/public-api/oauth.ts is the "§27 Public API platform: OAuth 2.1/PKCE" module. It defines TOKEN_TYPES = ['access', 'refresh'], the PKCE code-challenge pattern PKCE_CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43,128}$/, and a request validator (validateAuthorizationCodeRequest) whose error codes include 'pkce-required' and 'pkce-verification-failed' alongside 'client-mismatch', 'invalid-redirect-uri', and 'scope-not-allowed'. This is contracts / state-machine level: it encodes the token types, PKCE patterns, scope checks, and refresh/revocation surface. It is not a verified live authorization-server deployment — treat the OAuth surface as the validated protocol logic rather than a running auth server.

The canonical role + scope model (§28)#

libs/oshun/platform-foundations/src/role-model/role-model.ts defines CANONICAL_ROLES with ten entries — more numerous, and more precisely named, than the prose elsewhere ("support", "admin leadership") suggests:

Canonical role Sample scopes (scopesFor(role))
customer customer.self, customer.shell
creator customer.self, customer.shell, creator.studio
support-agent support.case.read, support.case.write
reviewer review.queue.read, review.queue.decide
moderator review.queue.read, review.queue.decide, moderation.decide
privacy-operator privacy.dsar.execute, privacy.audit.read, admin.audit.global
model-operator model.registry.read, model.registry.promote
persona-operator persona.registry.read, persona.registry.publish
tenant-admin tenant.console, admin.audit.global
admin-leadership admin.users.act, admin.scope.grant, admin.breakglass.act, admin.audit.global

Note the canonical names are precise (support-agent, not "support") and the set includes roles that looser prose omits, notably the distinct creator and tenant-admin roles. The library also exports SCOPE_KEYS (the 21-entry scope vocabulary above plus admin.policy.write, admin.config.write), scopesFor(role), and filterFieldsByRole() for least-privilege field projection. See Security, Privacy, and Compliance for how these roles gate operator surfaces.


The audit platform#

Every mutation that matters lands in the append-only audit substrate at libs/shared/audit-platform/ — and this is a substantial real library (~40 implementation modules), not a single helper. Its tamper-evidence core is hash-chain.ts, which chains entries so an auditor holding any past entryHash can detect insertion, deletion, or mutation by replaying forward:

text
entryHash    = sha256(previousChainHash || eventHash)
eventHash    = sha256(canonicalSerialize(event))

Around that core sit schema-versioning.ts (versioned audit-event schemas), retention.ts, escalation-rules.ts, compliance-attestation.ts, watermark-verification.ts, synthetic-media-labeling.ts, voice-likeness-consent.ts, and provenance-bundle-validation.ts, among others. The platform backs Iris admin inspection, DSAR review, the deletion workflow, and review packages — see Trust, Safety, and Privacy and Persona, Avatar, and Voice Packs for the synthetic-media and voice-consent surfaces it underpins.


Inbound integration plumbing#

Inbound connectors — the way institutional systems push data into Oshun — live at libs/shared/inbound-integrations/. The src/index.ts barrel exports fifteen connector modules (the customer-facing table below historically under-counted them at nine). The substantial ones are large, real implementations: lms.ts is ~1,425 LOC, oneroster.ts ~1,081 LOC, and identity.ts ~1,048 LOC.

Connector family Module(s) V1 use
LMS lms.ts, lti-verification.ts, scorm-rte.ts, scorm-2004-rte.ts LTI 1.3 / Advantage, LTI launch verification, and two SCORM runtime (RTE) implementations for Metis institutions.
OneRoster oneroster.ts Rostering sync with conflict reporting and dry-run.
Identity identity.ts SAML 2.0, OIDC, SCIM 2.0 for tenant SSO.
Calendar calendar.ts, calendar-google-transport.ts Google/Apple/Outlook two-way sync for Tara/Arete/Nyx/Metis, with a dedicated Google transport.
Payment (fiat) payment.ts Fiat-rail entitlement upgrades (Stripe-class providers; Telegram payments). V1.x optional — the V1 primary path is non-custodial crypto via the Aje domain (libs/aje/) plus libs/oshun/payments-bridge/.
Telemetry telemetry.ts xAPI / cmi5 / Caliper export to institutional sinks.
BYOM byom.ts, byom-model.ts Tenant-provided model endpoints for Metis study; byom-model.ts is a distinct model-description module.
Notification notification.ts Slack / Teams notification sinks for institutional surfaces.
Health health.ts Connector health probes, circuit breakers, version pinning.
(shared) types.ts Shared connector types.

The expansion over the old nine-row view is concrete: lti-verification.ts (LTI launch verification), two SCORM RTEs (scorm-rte.ts and scorm-2004-rte.ts), byom-model.ts separate from byom.ts, and calendar-google-transport.ts separate from calendar.ts. See Support, Entitlements, Billing, and the Aje Entitlement Bridge for the payment path and Messaging Channels for the notification sinks.


Cross-domain event examples (V1)#

The event bus is what lets domains stay isolated while still reacting to each other. These are representative V1 topics, each with its producer and consumers:

Topic Producer Consumer(s)
tara.session.completed Tara Arete (next-practice), Iris memory, analytics
arete.weekly_review.published Arete Shell continuity, assistant, Iris memory
veritas.claim.retracted Veritas Sophia (evidence invalidation), shell banner
nyx.event.upcoming Nyx Calendar sync, shell continuation, Tara cue
nisaba.notebook.entry_added Nisaba Assistant context, search index
metis.assessment.evidence_collected Metis Themis adjudication, Sophia grounding
iris.consent.changed Iris All domains (memory scope re-eval), audit
isis.generation.bundle_ready Isis Studio, Living Scenes, audit, provenance
lilith.crisis_frame.activated Lilith Tara (suppress invitations), assistant, Isis

Each topic name is a versioned entry in the topic registry, and each consumer dedupes on event.id, so a redelivery after a crash is harmless.


Example flow — Veritas claim retraction cascade#

A retraction is a high-blast-radius write: the source-of-truth domain commits the tombstone, the event bus fans out, and downstream subscribers project the retraction into their own surfaces. Producer and subscribers are isolated by the bus; failures replay until each projection succeeds. This is exactly the pattern the partial-failure envelope and tombstone semantics exist to support — the retraction commits atomically in Veritas, and the consequences (Sophia evidence invalidation, the customer banner, the audit record) are independent, idempotent projections.

sequenceDiagram autonumber actor Op as Operator participant Admin as Admin Web participant BFF as Oshun BFF participant V as Veritas participant DB as Veritas DB participant EB as Event Bus<br/>(ioredis pub/sub) participant S as Sophia participant Sh as Shell (Customer) participant A as Audit Platform Op->>Admin: Approve retraction (claim, reason) Admin->>BFF: POST /api/oshun/domains/veritas/claims/:id/retract BFF->>V: retract(claimId, reason, actor) V->>DB: Write tombstone + CorrectionNote<br/>+ RetractionCascade entry V->>EB: publish veritas.claim.retracted<br/>{correlationId, claimId, severity} V-->>BFF: 200 OK BFF-->>Admin: 200 OK par Evidence invalidation EB-->>S: deliver event S->>S: Invalidate evidence packs<br/>citing claimId S->>EB: publish sophia.evidence.invalidated and Customer banner EB-->>Sh: deliver event Sh->>Sh: Render retraction banner on<br/>Story / TopicHub / linked notebooks and Audit EB-->>A: deliver event A->>A: Append immutable hash-chain record<br/>(actor, reason, correlationId) end Note over EB,A: Subscribers idempotent by event.id — failures replay from the TTL-bounded replay key, not native Streams.

The POST route uses the registry base path /api/oshun/domains/veritas; the tombstone is written through the persistence layer's mandatory tombstone semantics; the audit append lands in the hash-chain.ts log; and the bus's replay source is the TTL-bounded replay key and scheduled sorted set, not a native Redis Stream (the older diagram's "Redis Streams" participant label is corrected above).