Architecture Deep Dive

Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source: crates/cheng-core/src/ports/mod.rs, crates/cheng-core/src/ports/executor.rs, crates/cheng-engine/src/executor, chengflow/CLAUDE.md

ChengOS's backend is a hexagonal architecture (ports and adapters) with domain-driven design. That is not a slogan on this project — it is enforced by the crate graph: cheng-core has no database, no HTTP, and no filesystem dependencies, so a domain rule cannot quietly reach for a connection pool.

The layers

┌─────────────────────────────────────────────┐
│              Domain (cheng-core)            │
│  ┌─────────────────────────────────────┐   │
│  │  Business logic & domain models     │   │
│  └──────────────┬──────────────────────┘   │
│                 │ uses                      │
│  ┌──────────────▼──────────────────────┐   │
│  │  Ports (traits)                     │   │
│  │  WorkflowRepository, ExecutionRepo, │   │
│  │  Executor, EventPublisher, …        │   │
│  └─────────────────────────────────────┘   │
└─────────────────────────────────────────────┘
                  ▲
                  │ implements
┌─────────────────┴───────────────────────────┐
│         Adapters (infrastructure)           │
│  DieselWorkflowRepository (cheng-storage)   │
│  WorkflowEngine           (cheng-engine)    │
│  EventBusAdapter / WsManager (cheng-api)    │
└─────────────────────────────────────────────┘
CrateLayerOwns
cheng-coreDomainModels, ports, domain services, domain events. No infrastructure.
cheng-storageInfrastructureDiesel repositories, migrations, domain ↔ row conversion
cheng-engineApplicationWorkflowEngine, DAG analysis, scheduling, lifecycle
cheng-apiPresentationAxum REST, WebSocket, dependency injection in main.rs

Dependency injection happens exactly once, in main.rs. There is no global state, which is why the engine and every service are testable with in-memory fakes and why the same Executor can be handed to a REST handler, the CLI, the scheduler, and a subflow node.

Ports

Ports are traits in cheng-core/src/ports/. The list is long — over forty — and reading their names is the fastest map of the system: WorkflowRepository, ExecutionRepository, ConversationRepository, DocumentRepository, DocumentShadowRepository, TableRepository, KnowledgeBaseRepository, ArtifactStore, ExecutionArchiveStore, TraceSnapshotStore, ScheduledTaskRepository, CredentialRepository, ChannelRepository, ChannelAdapter, AdapterRegistry, SkillRepository, McpServerRepository, ApprovalEventBus, ShadowEventBus, EventPublisher, Executor, NodeRegistry, WorkflowFileImporter, WorkspaceToolBridge, and more.

The Executor port is the one to understand first. execute(workflow, options) starts a run and returns immediately with an ExecutionId; the run proceeds in the background. execute_by_id loads from the repository first, which is what the frontend uses once a workflow is saved.

Execution pipeline

1. REST receives an execution request
2. WorkflowEngine.execute() with the workflow id
3. Load from WorkflowRepository
4. Build a DAG from nodes and edges (petgraph)
5. Create the Execution record
6. Background task:
     topological sort → ready nodes
     TaskScheduler spawns async tasks
     node.execute(ExecutionContext)
     results persisted
     EventPublisher → WebSocket
     repeat until done or failed
7. Final state update
8. WebSocket broadcasts completion

The engine splits cleanly in two: the static half (DAG planner, validation, topological sort, stage selection) is pure and stateless; the dynamic half (task scheduler) runs at runtime. Because the plan is derived rather than stored, changing a workflow can never leave a stale plan behind. Execution Model covers this in depth.

Key domain types

The node system

Nodes are registered by macro at compile time. #[derive(Node)] supplies metadata; register_node! / register_async_node! add the type to an inventory the registry collects at startup. There is no registration list to maintain — adding a file and rebuilding is enough.

Three implementation shapes:

TraitUse for
TypedNodeSynchronous, type-safe pure compute
AsyncTypedNodeAsync, I/O-bound or context-dependent
AsyncNodeRaw HashMap<String, Value> — legacy

The engine fetches the definition from NodeRegistry, validates the inputs against the node's declared spec, calls execute with an ExecutionContext, and stores the outputs for downstream nodes.

Schema-driven UI

The backend emits JSON Schema with x-* UI extensions for every node; the frontend's Field Control Registry turns that into form controls by priority matching. The consequence for development is direct: changing a node's inputs or outputs on the backend surfaces automatically in the UI, with no frontend change.

The priority chain is ordered — connected port status (100), file paths (90), sensitive field names (85), component identifiers such as model_AC (65), enums (45), basic types (40), fallback (0). Understanding it explains why a field renders as it does. See Node Configuration.

Supported extensions include x-control, x-provider-options, x-model-options, x-depends-on, x-allow-custom, x-hidden, and x-port.

Cross-cutting patterns

  1. Event-driven — EventBusAdapter bridges engine events to WebSocket.
  2. Async everywhere — Tokio; all I/O is async.
  3. Streaming — nodes can yield incremental results through the stream publisher.
  4. Errors — anyhow::Result for application errors, thiserror for domain errors.
  5. Credentials — EncryptionService plus CredentialRepository; an OAuth2 background service refreshes tokens.
  6. Channel auth — chk_-prefixed API keys stored as credentials with provider = "channel_adapter", checked by middleware.
  7. Ambient context — workspace_id, conversation_id, document_id injected by the engine, force-overriding model-supplied values in tool calls.

The frontend architecture

chengflow-ui is React 18 + TypeScript + Vite, with TanStack Router (file-based routes), Zustand + Immer for client state, React Query for server state, React Flow for the canvas, and Tailwind + Radix + shadcn/ui for presentation.

State is split across purpose-built stores rather than one global blob: editorStore (node and edge CRUD, viewport, clipboard, validation), workflowTabStore (tabs and backend sync), historyStore (undo/redo), executionStore (WebSocket execution state), workspaceStore (active workspace), shadowStore (reviewable AI edits).

The app is workspace-scoped. Routes come in two shapes — legacy global (/chat, /editor/$id) and workspace-scoped (/w/$workspaceId/...). workspaceStore is authoritative for activeWorkspaceId, and feature modules read it rather than touching localStorage. Resolution order on boot: URL → localStorage cache → backend /me/preferences → first accessible workspace. The store never auto-creates a workspace.

Separately from the editor, src/runtime/ renders PageLayout JSON — the output of workflows — into live pages: a miniature app-rendering engine, with scripts/generate-pages.ts as its build-time counterpart.

Cloud service invariants

chenghub and skill-registry live outside this tree and hold to their own rules: PostgreSQL is the only source of business truth and Redis is an accelerator that both services degrade gracefully without; each has a single binary whose migrate subcommand is the only schema-mutating path; the skill registry never receives ChengFlow credentials, never executes skills, and content-addresses artifacts by SHA-256 with published artifacts immutable.

Next steps