Skip to content
Merged
84 changes: 84 additions & 0 deletions docs/decisions/0020-zod-runtime-schema-library.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# ADR-0020: Zod as the runtime schema and validation library

- **Status**: Accepted
- **Date**: 2026-06-04
- **Related**: [0011-internal-llm-abstraction.md](0011-internal-llm-abstraction.md), [0003-pure-ts-engine-not-langgraph-python.md](0003-pure-ts-engine-not-langgraph-python.md), [../tech-stack.md](../tech-stack.md), [../reference/contracts/workflow-yaml-spec.md](../reference/contracts/workflow-yaml-spec.md), [../project-structure.md](../project-structure.md)

## Context

`@relavium/shared` is the single source of truth for every contract — workflow/agent
YAML, the run-event union, the run record, and config ([project-structure.md](../project-structure.md)).
These contracts must do double duty: be **inferred TypeScript types** the whole monorepo
codes against, *and* be **runtime validators** at every trust boundary the engine ingests
untrusted data from (a parsed `.relavium.yaml`, an IPC payload, a provider response, a
config file). CLAUDE.md rule 1 forbids `any` and mandates `unknown` + a guard at
boundaries; the standards repeatedly require "parse with a Zod schema at the edge, then
trust inside the core."

Doing this by hand — keeping a TypeScript `interface` and a separate runtime validator in
sync for ~30 schemas — is exactly the drift the project's "one canonical home" rule exists
to prevent. So the contract layer needs **one** library that derives the type *from* the
validator (or vice versa). [tech-stack.md](../tech-stack.md) already lists Zod under
"Schemas / types", and the Phase-0 plan names it as `@relavium/shared`'s sole runtime
dependency — but a runtime dependency requires an ADR (CLAUDE.md rule 2), and none existed.
This ADR records and authorizes that choice and its drivers.

## Decision

**`@relavium/shared` uses Zod (`zod`, pinned in [tech-stack.md](../tech-stack.md) via the
pnpm catalog) as its schema-and-validation library, and it is the package's only runtime
dependency.** Schemas are authored in Zod; the inferred TS types are derived with
`z.infer`, so the type and the validator can never diverge. Untrusted input is parsed with
the relevant schema at the boundary and trusted thereafter.

Considered options:

1. **Zod** — TypeScript-first, type *inferred from* the schema (no drift), zero runtime
dependencies of its own, discriminated unions + refinements for the cross-field rules
the contracts need, MIT, very widely adopted and actively maintained. *Chosen.*
2. **Hand-rolled validators + separate `interface`s** — no dependency, but doubles every
contract and invites exactly the type↔validator drift the package exists to eliminate.
*Rejected.*
3. **TypeBox / `io-ts` / `valibot`** — all viable schema libraries. TypeBox centres on
JSON-Schema/AJV (useful later for MCP tool schemas, but heavier for the inferred-type
ergonomics we want everywhere); `io-ts` carries an `fp-ts` idiom that is foreign to the
rest of the codebase; `valibot` is leaner but less battle-tested and ecosystem-thin for
our needs. *Rejected for the contract layer*, though nothing here precludes using a
JSON-Schema tool **inside** a specific seam (e.g. validating MCP tool schemas) where
that format is the native one.

This is consistent with the engineering principles ([0003](0003-pure-ts-engine-not-langgraph-python.md),
[0011](0011-internal-llm-abstraction.md)): one language (TypeScript), a small vetted
dependency we wrap behind our own canonical schemas rather than a framework that owns our
control flow. Zod is a *library* (data in, validated data out), not a framework, so it
does not compromise the build-in-house posture.

**Compatibility / maintenance.** Pinned to Zod 3.x in [tech-stack.md](../tech-stack.md); a
move to Zod 4 is a deliberate, tested version bump (the schemas already prefer the
forward-compatible two-argument `z.record(key, value)` form). Zod has no transitive runtime
dependencies, so it adds no supply-chain surface beyond itself. It runs in every host the
engine runs in (Node, the Tauri WebView, the VS Code extension host, the Phase-2 Bun API),
preserving the engine's zero-platform-imports guarantee.

## Consequences

### Positive

- One artifact per contract: the Zod schema **is** the validator and the source of the
inferred type — no hand-maintained `interface` to drift from the runtime check.
- Real boundary safety: YAML, IPC, provider responses, and config are parsed with a schema
at the edge, satisfying the `unknown`-at-boundaries rule without `any`.
- Cross-field contract rules (discriminated node/trigger unions, `merge_fn`-requires-custom,
id uniqueness, transport-specific MCP fields) are expressed and enforced in the schema.
- `@relavium/shared` stays minimal — `zod` is its **only** runtime dependency.

### Negative

- A runtime dependency in the contract package that every other package transitively
pulls in; mitigated by Zod being dependency-free, MIT, and widely maintained, and by the
schemas being the package's entire reason to exist.
- Zod's inference has compile-time cost on very large schemas and some sharp edges (e.g.
discriminated-union members may not carry refinements); handled by keeping cross-object
rules in a parent `superRefine` rather than per-variant.
- A future major (Zod 4) is a coordinated bump; bounded by pinning the version centrally in
[tech-stack.md](../tech-stack.md) and by the conformance the schema test-suite provides.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ flowchart TD
| 0017 | [Bun as the `apps/api` runtime](0017-cloud-runtime-bun.md) | Accepted | 2026-06-04 |
| 0018 | [Desktop execution model — engine in WebView, Rust-delegated LLM egress](0018-desktop-execution-and-rust-egress.md) | Accepted | 2026-06-04 |
| 0019 | [Node-side OS-keychain access for the CLI — a maintained library, not the archived keytar](0019-cli-node-keychain-library.md) | Accepted | 2026-06-04 |
| 0020 | [Zod as the runtime schema and validation library](0020-zod-runtime-schema-library.md) | Accepted | 2026-06-04 |

## Creating a new ADR

Expand Down
13 changes: 10 additions & 3 deletions docs/reference/contracts/sse-event-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,25 @@ export type RunEvent =
| `run:started` | A run began. | `workflowId`, `inputs` (secret-typed inputs **masked** — see [Security](#security-event-payloads-never-carry-secrets)), `executionMode: 'local' \| 'cloud' \| 'managed'` |
| `node:started` | A node began executing. | `nodeId`, `nodeType` |
| `agent:token` | A streaming LLM token from an agent node. | `nodeId`, `token`, `model` |
| `agent:tool_call` | An agent invoked a tool. | `nodeId`, `toolId`, `toolInput` (sanitized — no secrets) |
| `agent:tool_call` | An agent invoked a tool. | `nodeId`, `model` (the invoking model — so a tool call is attributable across a failover), `toolId`, `toolInput` (sanitized — no secrets) |
| `agent:tool_result` | A tool returned. | `nodeId`, `toolId`, `success`, `outputSummary` (truncated for UI) |
| `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage)) |
| `cost:updated` | A node's token cost was tallied (drives the cost waterfall). | `nodeId`, `model`, `inputTokens`, `outputTokens`, `costMicrocents`, `cumulativeCostMicrocents` (integer micro-cents — canonical unit in [llm-provider-seam.md](../shared-core/llm-provider-seam.md#6-usage)), `attemptNumber?` (1-based retry attempt this cost belongs to, so per-attempt cost is reconstructable) |
| `node:completed` | A node finished successfully. | `nodeId`, `output`, `tokensUsed: {input, output, model}`, `durationMs` |
| `node:failed` | A node failed. | `nodeId`, `error: {code, message, retryable}` |
| `human_gate:paused` | Execution suspended at a human gate. | `nodeId`, `gateId`, `gateType: 'approval' \| 'input' \| 'review'`, `message`, `assignee?`, `timeoutMs?`, `expiresAt?` |
| `human_gate:resumed` | A gate decision was applied; execution continues. | `nodeId`, `decision: 'approved' \| 'rejected' \| 'input_provided'`, `decidedBy`, `payload?` |
| `run:completed` | The run finished. | `outputs`, `totalTokensUsed`, `durationMs` |
| `run:completed` | The run finished. | `outputs`, `totalTokensUsed`, `totalCostMicrocents` (integer micro-cents closing total for the whole run), `durationMs` |
| `run:failed` | The run failed. | `error: {code, message, nodeId?}`, `partialOutputs` |
| `run:cancelled` | The run was cancelled. | (base only) |

### Selected definitions

> These TypeScript shapes are **illustrative**. The enforced, runtime-validated source
> of truth is the Zod schema set in `@relavium/shared` (`run-event.ts`), from which the
> TS types are inferred ([ADR-0020](../../decisions/0020-zod-runtime-schema-library.md)).
> This document remains the canonical **contract** (the human-readable spec the schema
> implements); if the two ever diverge, this spec wins and the schema is corrected to it.

```ts
export interface AgentTokenEvent extends BaseEvent {
type: 'agent:token';
Expand All @@ -87,6 +93,7 @@ export interface CostUpdatedEvent extends BaseEvent {
outputTokens: number;
costMicrocents: number; // integer micro-cents (canonical unit defined in llm-provider-seam.md); this attempt, from Relavium's pricing table (never the provider)
cumulativeCostMicrocents: number; // integer micro-cents running total for the whole run
attemptNumber?: number; // 1-based retry attempt this cost belongs to (per-attempt cost attribution)
}

export interface NodeCompletedEvent extends BaseEvent {
Expand Down
5 changes: 4 additions & 1 deletion docs/reference/desktop/database-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ CREATE INDEX idx_workflows_active ON workflows (is_active, updated_at DESC)

One row per workflow execution. `workflow_definition_snapshot` freezes the exact graph that ran, so a run can be replayed or inspected even after the YAML file changes. Cost is stored as integer micro-cents.

> **Logical `Run` vs persisted `RunRow`.** `@relavium/shared` exports `RunSchema` — the **narrow, engine-/surface-facing** view of a run (status, trigger, inputs/outputs, token + cost totals, timestamps). This `runs` table is the **persistence** shape and carries additional columns that are a database concern, modeled by `@relavium/db` as a distinct `RunRow` mirroring the DDL below: `workflow_definition_snapshot` (the frozen graph for replay/resume), `trigger_metadata`, `workflow_path`/`project_root`, and the `deleted_at` soft-delete cursor. Those are intentionally absent from the logical `RunSchema`; a consumer that needs them reads the `RunRow`. The split keeps the engine view free of storage details while `@relavium/db` owns the row ↔ column mapping.

| Column | Type | Constraints |
|--------|------|-------------|
| `id` | TEXT | PRIMARY KEY (UUID) |
Expand All @@ -182,7 +184,8 @@ One row per workflow execution. `workflow_definition_snapshot` freezes the exact
| `project_root` | TEXT | NULL — workspace that owned the run |
| `workflow_definition_snapshot` | TEXT (JSON) | NOT NULL |
| `status` | TEXT | NOT NULL DEFAULT `'pending'` — `CHECK (status IN ('pending','running','paused','completed','failed','cancelled'))` |
| `trigger_type` | TEXT | NOT NULL DEFAULT `'manual'` (`manual`, `file_change`; `webhook`/`schedule` are Phase 2) |
| `execution_mode` | TEXT | NOT NULL DEFAULT `'local'` — `CHECK (execution_mode IN ('local','cloud','managed'))`; which mode the run used (cost/billing attribution + history) |
| `trigger_type` | TEXT | NOT NULL DEFAULT `'manual'` (`manual`, `file_change`, `mcp_call`; `webhook`/`schedule` are Phase 2) |
| `trigger_metadata` | TEXT (JSON) | NOT NULL DEFAULT `'{}'` |
| `input_json` | TEXT (JSON) | NOT NULL DEFAULT `'{}'` |
| `output_json` | TEXT (JSON) | NULL |
Expand Down
33 changes: 19 additions & 14 deletions docs/roadmap/current.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ actions. The full phase plan and the global milestone spine are in

## Where we are

**Documentation and design: complete. The monorepo scaffold (Phase 0 workstreams
0.A–0.C) has landed and is green.** The repository (`github.com/HodeTech/Relavium`)
now holds the `docs/` tree **plus** the Turborepo + pnpm workspace, the strict
`tsconfig` bases, the root ESLint/Prettier/Vitest spine, and a buildable
`@relavium/shared` scaffold — `pnpm install && pnpm turbo run lint typecheck test build`
is green and `format:check` is clean. The remaining Phase-0 workstreams (0.D/0.E
schemas, 0.F seam fence, 0.G CI, 0.H docs, 0.I `@relavium/db`) are still open before
**Documentation and design: complete. Phase 0 workstreams 0.A–0.E have landed and are
green — the toolchain (M1) and the shared schemas (M2).** The repository
(`github.com/HodeTech/Relavium`) now holds the `docs/` tree **plus** the Turborepo +
pnpm workspace, the strict `tsconfig` bases, the root ESLint/Prettier/Vitest spine, and
`@relavium/shared` with the **full Zod schema set** (`WorkflowSchema`, `AgentSchema`,
`NodeSchema`, `EdgeSchema`, the colon-namespaced `RunEvent` union, `RunSchema`, config —
114 tests, reference round-trip with no drift). `pnpm install && pnpm turbo run lint
typecheck test build` is green and `format:check` is clean. The remaining Phase-0
workstreams (0.F seam fence, 0.G CI, 0.H docs, 0.I `@relavium/db`) are still open before
**M0**. The foundation is settled and recorded:

- Product vision, UVP, and hard constraints (desktop is agent-management, not an
Expand Down Expand Up @@ -55,10 +57,11 @@ schemas, 0.F seam fence, 0.G CI, 0.H docs, 0.I `@relavium/db`) are still open be

## What is active now

The project is in **build-order step 1: scaffolding the monorepo** — the toolchain
half (0.A–0.C) is **done and green**, and the next work is the critical-path schemas
(0.D/0.E). This is **[Phase 0 — foundations](phases/phase-0-foundations.md)** (Product
Phase 1). Phase 0 ships **types and tooling, not features**; its job is to make
The project is in **build-order step 1: foundations** — the toolchain (0.A–0.C, M1) and
the critical-path shared schemas (0.D–0.E, M2) are **done and green**; the next work is
the **M0 close-out** (0.F seam fence, 0.G CI, 0.H docs, 0.I `@relavium/db`). This is
**[Phase 0 — foundations](phases/phase-0-foundations.md)** (Product Phase 1). Phase 0
ships **types and tooling, not features**; its job is to make
[Phase 1 — the engine critical path](phases/phase-1-engine-and-llm.md) safe to start
against a frozen contract and a green CI gate. Until Phase 0's
[exit criteria](phases/phase-0-foundations.md#exit-criteria-go--no-go) pass, no
Expand All @@ -74,9 +77,11 @@ The first workstreams of Phase 0, in order. `0.A → 0.B → 0.C → 0.D` are se
task lists and acceptance criteria are in
[phase-0-foundations.md](phases/phase-0-foundations.md#work-breakdown).

> **✅ 0.A / 0.B / 0.C are landed and green** (the monorepo + toolchain spine). The
> active focus is now **[0.D → 0.E]** — `@relavium/shared`'s Zod schemas. Items 1–3
> below are kept for the record with their acceptance met.
> **✅ 0.A–0.E are landed and green** (the monorepo + toolchain spine **and** the full
> `@relavium/shared` Zod schema set with reference round-trip + event-name pins). The
> active focus is now **[0.F → 0.I]** — the seam-fence lint zone, GitHub Actions CI, docs
> wiring, and the `@relavium/db` scaffold — to close out **M0**. Items 1–4 below are kept
> for the record with their acceptance met.

1. **[0.A] Scaffold the Turborepo + pnpm workspace** per
[../project-structure.md](../project-structure.md): a `private` root
Expand Down
26 changes: 24 additions & 2 deletions docs/roadmap/phases/phase-0-foundations.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ flowchart TD

### 0.A — Turborepo + pnpm workspace skeleton

> **✅ Done** — landed in PR #1 (merged 2026-06-04).

Create the empty-but-correct monorepo so every later package has a home and a
consistent toolchain. Tooling-only at the root; no app/package logic yet.

Expand All @@ -138,6 +140,10 @@ peer-dep errors.

### 0.B — Shared `tsconfig` bases

> **✅ Done** — landed in PR #1 (merged 2026-06-04). Base is **`NodeNext`** so ESM
> relative imports must carry explicit `.js` extensions (Vite surfaces override to
> `bundler` at their phase).

One strict TypeScript base every package extends, so strictness can never silently
drift per package ([../../standards/code-style-typescript.md](../../standards/code-style-typescript.md#strictness)).

Expand All @@ -160,6 +166,10 @@ cosmetic).

### 0.C — ESLint + Prettier + Vitest (root, shared)

> **✅ Done** — landed in PR #1 (merged 2026-06-04). `format:check` runs as a turbo
> root task; a `coverage` script wires V8 branch coverage; dev-tool versions are a
> single-source pnpm `catalog`.

Configure formatting, linting, and the test runner **once** at the root and share
them across every package — Prettier owns formatting, ESLint owns correctness.

Expand All @@ -182,6 +192,10 @@ introducing an `any` or a floating promise fails lint locally and would fail CI.

### 0.D — `packages/shared` package scaffold

> **✅ Done** — `@relavium/shared` ships `zod` (catalog, the sole runtime dep) and a
> `src/` laid out by contract (`constants`, `common`, `node`, `edge`, `agent`,
> `workflow`, `run-event`, `run`, `config`, curated `index`).

Create the `@relavium/shared` package shell — the first real, fully built-out
package and the dependency root of the whole graph.

Expand All @@ -201,6 +215,14 @@ workspace package with full types; its only runtime dependency is `zod`.

### 0.E — Shared Zod schemas + round-trip tests

> **✅ Done** — `WorkflowSchema`, `AgentSchema`, `NodeSchema`, `EdgeSchema`, the 13-variant
> colon-namespaced `RunEvent` union (+ `CostUpdatedEvent`, gate events, `GateDecision`),
> `RunSchema`, and the config schemas, with inferred types. **114 tests** cover accept +
> reject per schema, the canonical reference workflow/agent round-trip with no drift, and
> a type-level + runtime pin of the event names and the `cost:updated` payload.
> The reference example is round-tripped as a parsed **object** (YAML→object parsing is
> `@relavium/core`'s job, Phase 1), so shared's only runtime dep stays `zod`.

The heart of the phase: encode the frozen reference contracts as Zod schemas and
prove they round-trip the canonical example YAML with zero drift. **Critical path.**

Expand Down Expand Up @@ -342,8 +364,8 @@ spine milestone **M0** for this phase.

| In-phase milestone | Means | Completed by |
|--------------------|-------|--------------|
| 0.M1 — Toolchain green | `pnpm install` + `turbo run lint typecheck test` pass on the empty scaffold | 0.A, 0.B, 0.C |
| 0.M2 — Schemas round-trip | `@relavium/shared` exports the full schema set and round-trips the reference YAML with no drift; run-event names pinned | 0.D, 0.E |
| **0.M1 — Toolchain green ✅** | `pnpm install` + `turbo run lint typecheck test` pass on the empty scaffold | 0.A, 0.B, 0.C *(done, PR #1)* |
| **0.M2 — Schemas round-trip ✅** | `@relavium/shared` exports the full schema set and round-trips the reference example with no drift; run-event names pinned | 0.D, 0.E *(done)* |
| 0.M3 — **M0: Foundations green** | CI green on push with remote cache; the seam lint fence and the standards are enforced; docs wired; `@relavium/db` scaffolded (schema + migrations + SQLite client) | 0.F, 0.G, 0.H, 0.I |

## Dependencies
Expand Down
2 changes: 1 addition & 1 deletion docs/tech-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ locked.
- **Monorepo**: Turborepo + pnpm workspaces (see [project-structure.md](project-structure.md))
- **UI**: shadcn/ui + Radix on Tailwind, shared via `packages/ui`
- **Canvas**: ReactFlow (custom node types in `packages/ui`)
- **Schemas / types**: Zod (shared via `packages/shared`)
- **Schemas / types**: Zod (shared via `packages/shared` — `@relavium/shared`'s only runtime dependency) — see [ADR-0020](decisions/0020-zod-runtime-schema-library.md)
- **Testing**: Vitest (unit), Playwright (e2e)

> Phase-2-only rows (PostgreSQL/Redis/BullMQ, Better Auth) are marked explicitly.
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"lint": "eslint src",
"test": "vitest run"
},
"dependencies": {
"zod": "catalog:"
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"devDependencies": {
"eslint": "catalog:",
"typescript": "catalog:",
Expand Down
Loading