Domain · Architecture

Contracts Domain — Architecture

exactly one runtime dependency:

5sections12 minread1diagrams

On this page

Architectural overview of @oshun/contracts and its sibling contract packages: their role in the monorepo, the zero-upstream-dependency invariant, the event envelope and two-tier validation registry, the per-domain canonical surfaces, and the honest split between implemented and scaffold packages.


The Oshun platform is built from dozens of independent domain libraries — Isis, Sophia, Hathor, Yemaya, Lilith, Nyx, Veritas, Psyche, Concordia and more. Each domain owns its own business logic, database, and API. But domains constantly exchange data: an Asset generated by Isis crosses into Yemaya's project canvas; a tara.ritual.completed event fans out to Arete, Nisaba, and Nyx; a ConsentRecord collected anywhere must satisfy the same lifecycle rules everywhere. Without a shared vocabulary, every domain would invent its own slightly-different User, its own event envelope, and integration would degrade into a perpetual mapping exercise.

@oshun/contracts (libs/contracts/) is the single canonical source of truth for those cross-domain shapes. It is the lowest software layer in the TypeScript half of the stack — the mirror of what Neith is for the Rust half. It depends on nothing inside the platform (only on zod), and nearly everything above it depends on it. A new engineer can treat it as the platform's type system: the agreement that makes one domain's output compatible with another domain's expectations, validated at runtime as well as compile time.

The directory libs/contracts/ is not one package but eleven. The primary package @oshun/contracts lives in src/; alongside it sit ten separate Nx libraries — each its own project.json, and (except the scaffold @maat/contracts, which has only a project.json) its own package.json — that own a specific domain's contract surface (@iris/contracts, @psyche/contracts, @concordia/contracts, @freya/contracts, @contracts/brigid, @contracts/cybele, @contracts/saraswati, @contracts/annapurna, @contracts/athena, and the scaffold @maat/contracts).


Position in the Dependency Graph#

libs/contracts/project.json tags the package scope:shared, layer:contracts, type:lib, and libs/contracts/package.json declares exactly one runtime dependency:

json
"dependencies": { "zod": "catalog:" }

This zero-upstream-dependency rule is the keystone invariant. Adding any @oshun/* import to contracts would risk a circular dependency that could ripple through every consumer in the monorepo at once. The rule applies to all eleven packages in the directory: each one depends only on zod (plus the test toolchain). The arrow always points toward contracts and never back out.

flowchart TB subgraph consumers["Consumers (domain services, BFF, admin console, event bus)"] isis["Isis"] yemaya["Yemaya"] veritas["Veritas"] bff["OSHUN BFF"] bus["event-bus (@oshun/shared)"] end subgraph contracts["@oshun/contracts (libs/contracts/src)"] common["common/ — ~160 entity & governance schemas"] events["events/ — envelope + 12 domain modules"] surfaces["per-domain surfaces — llm, aja, tara, nyx,\nmetis, nisaba, veritas, oya, v3, v6, v9, iris, agent"] bffmodels["BFF-canonical — atelier, identity,\nlibrary, messaging, studio"] end subgraph siblings["Sibling Nx packages (own package.json)"] iris["@iris/contracts"] concordia["@concordia/contracts"] psyche["@psyche/contracts"] industrial["@freya · @contracts/brigid · cybele ·\nsaraswati · annapurna · athena"] maat["@maat/contracts (scaffold)"] end isis --> common isis --> events yemaya --> common veritas --> surfaces bff --> bffmodels bus --> events consumers --> siblings common --> zod["zod (only runtime dep)"] events --> zod surfaces --> zod bffmodels --> zod siblings --> zod

@oshun/contracts Internal Structure#

src/index.ts is the root barrel; libs/contracts/package.json defines the subpath exports map. The two surfaces are deliberately not identical, which is an important detail when importing:

  • Root barrel re-exports (import { … } from '@oshun/contracts'): common, events, llm, aja, arete, tara, nyx, living-scene, nisaba, metis, veritas, iris, agent, oya, plus the namespaced V3Contracts, V6Contracts, and V9Contracts.
  • Subpath-only surfaces (reachable only via an explicit subpath, never the root barrel): ./atelier, ./identity, ./library, ./messaging, ./studio, and ./tts (with ./tts/providers, ./tts/reference-client, ./tts/ssml-emotion). These are excluded from the root barrel to keep the default import surface — and its name collisions — manageable.
  • Asymmetries worth knowing: v9 is reachable through the root barrel but has no ./v9 subpath export; ./tts has a subpath export but is not in the root barrel. Import from the most specific subpath you can.

Consumers should prefer the narrowest subpath (@oshun/contracts/events over the root) so they pull in only the schemas they actually use.

common/ — platform entities and governance#

src/common/index.ts re-exports roughly 160 source modules (≈285 files including co-located tests) — by far the largest area. primitives.ts is the foundation every other module imports: UUIDSchema, SlugSchema, TimestampSchema, the offset/cursor pagination schemas, ErrorSchema + ErrorCodes, and MetadataSchema. Using these shared primitives — rather than each module re-deriving z.string().uuid() — guarantees the platform represents identity, time, paging, and errors identically everywhere.

On top of the primitives sit the canonical platform entities (user.ts, asset.ts, project.ts, persona.ts, voice-profile.ts, avatar-pack.ts, model-card.ts, consent-record.ts, claim.ts, evidence-pack.ts, and many more) and the much larger body of admin-console, privacy/DSAR, safety/moderation, and grounded-research contracts. The specifications.md page documents these field-by-field; the architectural point is that consent, audit, and provenance are treated as first-class cross-domain concerns — every domain's consent flow produces a ConsentRecord of the same shape, whose Zod superRefine validators encode the lifecycle rules (timestamp ordering, verified verification for high-risk categories) so they are enforced at parse time rather than scattered across service logic.

Per-domain canonical surfaces#

Some domains have contract requirements rich enough to warrant their own organized module while still living inside the zero-upstream package. Each is an independently-importable subpath:

  • ./llm — the canonical LLM gateway contract: the IsisLLMClient interface plus primitives/messages/tools/request/response/stream/ errors/client modules and a ./llm/test-utils gateway double. The header comment is explicit that gateway implementations must depend on @oshun/contracts/llm only — never on @isis/llm-providers internals — so the backend can be swapped behind the interface.
  • ./aja (motion-AI / embodied instruction), ./arete (habits/goals), ./tara (contemplative-practice taxonomies), ./nyx (sky-event canonical contracts — distinct from events/nyx.ts), ./metis (learning/tutoring), ./nisaba (manuscript/scholarship), ./veritas (fact-check canonical contracts — distinct from events/veritas.ts).
  • ./oya — the §3.2 embodied-hive surface (drones/ground robots sharing one world model and safety envelope). Its index.ts states the contracts are kept coherent with the Rust engine types in libs/oya/engine (oya-types, oya-fleet, oya-safety, …) so payloads validate identically on both sides. oya/fleet.ts:121 encodes the hard capability gate in code — bidIsCapabilityValid(agent, bid) returns agent.capabilities.includes(bid.requiredCapability) — the contract-layer mirror of the engine's safety invariant.
  • Versioned product lines./v3 (Lilith metaverse / Tara Studio / Saraswati Stage), ./v6 (the Egbe/Ori identity contracts: ori-identity-core, ori-passport, personality-model, relationship-edge, plus a generated openapi.ts and a registry.ts/fixtures.ts), and v9 (Metis: the Atlas concept-graph, the Hephaestus explorable, and the lesson artifact).
  • Root re-exportsliving-scene (score + cinematographic-technique catalog), iris (memory-entry + continuation contracts, also a sibling package — see below), and agent (the §18.11/§18.12 tool-catalog and grants contracts).

BFF-canonical models#

Five subpath-only surfaces — ./atelier, ./identity, ./library, ./messaging, ./studio — are a newer category: canonical models for tables the OSHUN BFF currently writes to under goal3_stub_* names. Each promotes an ad-hoc BFF table into a typed, versioned contract with an explicit state machine and superRefine invariants. For example, studio/index.ts models a StudioScene whose drafting → review → policy-cleared → published machine requires the lilith-persona-policy adapter to have stamped a decision before publish; library/index.ts models a polymorphic LibraryCollection whose items are kind-prefixed slugs so the canonical table needs no cross-domain foreign keys; identity/index.ts models a SCIM-provisioned ScimIdentity with a replay changeLog; messaging/index.ts and atelier/index.ts model channel links and one-to-one shareable scenes with a cue-privacy hash. These exist so cross-tenant analytics, the erasure pipeline, and routing all share one lookup.


The Event System#

The event subsystem (src/events/) is the most architecturally load-bearing part of the library, because it is the contract that lets twelve domains publish and consume each other's events over the bus without sharing code.

Envelope and typed-schema factory (events/envelope.ts)#

Every event on the bus is wrapped in EventEnvelopeSchema. On the base schema payload is z.unknown(), so infrastructure code can deserialize and route an event using only envelope fields (id, type, source, timestamp, version, priority, metadata, aggregate) without knowing the payload type. The type is a dotted domain.action string validated by /^[a-z0-9]+(\.[a-z0-9_]+)+$/; source is the 13-value EventSourceSchema (tara, isis, sophia, hathor, bellona, yemaya, lilith, aphrodite, nyx, psyche, veritas, concordia, system). Correlation and causation IDs live inside metadata, not on the envelope root, and id is a UUID v4 — not a ULID.

Typed per-event schemas come from the createEventSchema(eventType, source, payloadSchema) factory, which .extend()s the envelope with a literal type/source and a concrete payload. Every domain module builds its events this way. The same file also defines the publish/consume infrastructure contracts — EventPublishOptionsSchema (routing, idempotency, retry), EventPublishResultSchema, EventConsumerConfigSchema (batch/retry policy), and DeadLetterEventSchema for failed-processing capture.

Two-tier registry (events/index.ts + events/validation.ts)#

There are deliberately two registries, and the difference between them is a real failure mode to understand:

  1. AllEventTypes (in events/index.ts) is a const map of every event-type string — 181 strings across 12 domains (isis 10, sophia 9, hathor 7, bellona 8, yemaya 12, lilith 10, tara 1, aphrodite 24, nyx 24, psyche 37, veritas 27, concordia 12). It is compile-time documentation and the typo-proof way to reference an event by name. Adding a string here does not make the event runtime-validatable.
  2. EventSchemaRegistry (a Map<string, EventSchemaEntry> in events/validation.ts) maps a type string to its full Zod schema and payload schema, so validateEvent can check envelope and payload in one call. This registry is partial by design: it currently registers only the Isis, Tara, Sophia, Hathor, Bellona, Yemaya, and Lilith schemas — 57 of the 181 types. Aphrodite, Nyx, Psyche, Veritas, and Concordia events are defined (their *EventSchema exists in the domain module) but are not wired into the runtime registry.

The consequence: validateEvent validates the envelope of any event, but only runs payload validation for the 57 registered types; for the rest it returns { success: true } after envelope-only checks (if (!schemaEntry) return { success: true, data: envelopeResult.data }). When publishing, createPublishValidator warns rather than throws on an unregistered type. This is an honest fail-open seam, not a bug, but it means registering a new event in the Map is the step that actually turns runtime validation on — and is recommended for any event crossing a trust boundary.

Validation pipeline and middleware#

events/validation.ts exposes the runtime API: validateEvent, validatePayload, their *OrThrow variants (which raise the structured EventValidationError carrying simplified ValidationIssue[]), isEventTypeRegistered, getEventSchema/getPayloadSchema, getRegisteredEventTypes, and getEventTypesByDomain. Two higher-level helpers wrap these for the bus: createValidationMiddleware wraps consumers (strict = throw, non-strict = warn-and-continue) and createPublishValidator wraps producers. A typical flow is: producer builds a payload → createPublishValidator checks it against the registry → envelope is published → consumer's createValidationMiddleware re-validates on receipt before the handler runs.

The Concordia module is a partial exception that points at the future direction: it ships its own ConcordiaEventSchemaRegistry (every Concordia event keyed by type) inside the domain module, demonstrating per-domain registries that the central EventSchemaRegistry does not yet aggregate.


Sibling Nx Packages#

Ten further packages share the directory but are independent libraries — each (except the @maat/contracts scaffold, which carries only a project.json) versioned on its own package.json cadence, each still bound by the zero-upstream rule.

  • @iris/contracts (iris/) — Iris AI-assistant contracts: conversation (message content blocks, streaming events), a four-tier memory model (core / working / archival / episodic) with operations and MemoryState, and agent contracts (tool definitions, agent config/execution, MEMORY_TOOLS). Subpaths ./common, ./conversation, ./memory, ./agent.
  • @concordia/contracts (concordia/) — the largest sibling: ~100 feature directories (case model, parties, issues, agreements + agreement DSL, preference/utility models, settlement lifecycle, access/authority/consent, escalation, search kernels, oversight, and cross-domain integration modules). Its index.ts barrel is honest that it is partial — it currently exports the use-case-classification surface (Phase 179.1.2.1), with the case/party/ issue/agreement/preference schemas slated for follow-on Phase 179.2.* tasks. The full source tree exists; the barrel is the gate.
  • @psyche/contracts (psyche/) — Psyche video-conferencing AI contracts. Currently exports only its common/ module; avatar, voice, behavior, perception, conferencing, knowledge, and persona modules are commented as to be added as services are migrated — a real, declared in-progress state.
  • Industrial / commerce packages@freya/contracts (luxury goods), @contracts/brigid (industrial), @contracts/cybele (construction), @contracts/saraswati (cross-domain integration adapters to Brigid/Asase/Freya/ Cybele/Maat), and @contracts/annapurna + @contracts/athena (commerce / food-service domains). The latter group follows a uniform six-module shape — api-schemas.ts, events.ts, integration.ts, graphql.ts, grpc.ts, openapi.ts — providing real Zod request/response schemas (e.g. Annapurna's AnnapurnaOrderCreateSchema, AnnapurnaMoneySchema with a currency enum), though these are smaller surfaces than the platform core.
  • @maat/contracts (maat/) — a scaffold only. src/index.ts exports just the MaatContractEnvelope<TPayload> interface; the subdirectories (agents/, compliance/, finance/, supply-chain/, …) hold only .gitkeep files. The Maat contract surface is not yet implemented.

Note: libs/contracts/veritas/ is an empty placeholder (only .gitkeep). The implemented Veritas canonical contracts are the @oshun/contracts/veritas subpath (libs/contracts/src/veritas/), not this sibling directory.


Invariants, Failure Modes, and Extension Points#

Zero-upstream dependency. The hard invariant. Any PR that adds an @oshun/* import to any package under libs/contracts/ must be rejected — primitives both sides need (like a UserId) are expressed as plain Zod string schemas rather than imported.

Compile-time and runtime in lockstep. Every type is z.infer-ed from its schema, so a schema change propagates to TypeScript types and runtime validators together. Zod was chosen over JSON Schema or bare interfaces precisely because events arrive from the bus and HTTP bodies as raw JSON where types are erased.

Adding an event (the four-step lifecycle). (1) Define a *PayloadSchema and *EventSchema (via createEventSchema) in the domain's events/*.ts. (2) It surfaces automatically through export * in events/index.ts; add the type string to AllEventTypes. (3) To enable runtime validation, register the schema in EventSchemaRegistry in events/validation.ts — the step most easily forgotten, and the difference between a discoverable type and a validated one. (4) Bump the envelope version (semver, default 1.0.0) on a breaking payload change, keeping the old schema for in-flight consumers during rolling deploys.

The registry-coverage gap is the main subtle failure mode. Because EventSchemaRegistry covers only 57 of 181 types, a malformed Aphrodite/Nyx/ Psyche/Veritas/Concordia payload will pass validateEvent (envelope-only) and only fail deeper in the consumer. Treat envelope-passing as necessary, not sufficient, until the type is registered.

Contract ↔ Prisma alignment. common/contract-prisma-alignment.ts defines the alignment-checking schema, exercised by common/contract-prisma-alignment.test.ts and contract-prisma-migrations.test.ts. These guard against the class of bug where a migration adds a non-nullable column but the contract still treats the field as optional, which would surface as a runtime deserialization failure on contract-validated payloads. contracts.spec.ts provides self-consistency tests over the common and event schemas.

Cross-domain integration boundaries. Contracts owns shape, never behavior. Product logic stays in each domain's own libraries; the oya surface stays byte-coherent with the Rust libs/oya/engine types; the LLM surface stays implementation-agnostic behind IsisLLMClient; the BFF-canonical surfaces promote ad-hoc tables into typed contracts. Where a real integration is absent — @maat/contracts, the partial @psyche/contracts, the gated @concordia/contracts barrel — the package says so explicitly rather than faking coverage. That honesty is the contract: a fail-loud scaffold beats a fabricated surface.