@cflow/core
v0.2.1
Published
CFIS — Case Flow Instruction Set. A YAML-driven state machine engine for workflow orchestration.
Maintainers
Readme
CFIS Engine
Case Flow Instruction Set — A YAML-driven, domain-agnostic state machine engine for workflow orchestration.
Define your workflow as YAML. Write handlers in TypeScript. Let the engine manage state transitions, permissions, events, modules, and scheduling.
Table of Contents
- Installation
- Quick Start
- Core Concepts
- Workflow YAML Schema
- Engine API
- Handlers
- Condition Evaluator
- Modules (Sub-workflows)
- Plugins
- Store Interface
- Testing
- Project Structure
- Scripts
- License
Installation
npm install @cflow/coreRequirements: Node.js >= 18, TypeScript >= 5.7 (recommended)
Peer dependencies: None. Ships with yaml and reflect-metadata.
Quick Start
1. Define a workflow in YAML
version: "1.0"
entry: OPEN
metadata_keys:
- name: assigned_to
type: string
- name: priority
type: number
actions:
- name: assign
handler: AssignHandler
- name: close
handler: CloseHandler
states:
- code: OPEN
name: Open
actions:
- name: assign
on_success:
state: IN_PROGRESS
- code: IN_PROGRESS
name: In Progress
actions:
- name: close
on_success:
state: CLOSED
- code: CLOSED
name: Closed
actions: []2. Write handlers
import { BaseHandler, ActionResult } from '@cflow/core';
class AssignHandler extends BaseHandler {
async execute(payload: { assigneeId: string }): Promise<ActionResult> {
await this.setString('assigned_to', payload.assigneeId);
return { success: true };
}
}
class CloseHandler extends BaseHandler {
async execute(): Promise<ActionResult> {
await this.setString('resolution', 'completed');
return { success: true };
}
}3. Create an engine and run it
import { Engine, InMemoryStore, silentLogger } from '@cflow/core';
import fs from 'fs';
const workflow = fs.readFileSync('./workflow.yaml', 'utf8');
const engine = new Engine({
definition: workflow,
store: new InMemoryStore(),
handlers: { AssignHandler, CloseHandler },
logger: silentLogger,
});
// Create a workflow instance
await engine.createInstance('ticket-1');
// Execute actions
await engine.executeAction('ticket-1', 'assign', { userId: 'u1' }, { assigneeId: 'u2' });
// State is now IN_PROGRESS
await engine.executeAction('ticket-1', 'close', { userId: 'u1' });
// State is now CLOSEDCore Concepts
| Concept | Description |
|---|---|
| Workflow | A YAML document defining states, actions, transitions, and metadata |
| Instance | A running instance of a workflow (e.g. "case-42", "ticket-7") |
| State | A named step in the workflow (e.g. OPEN, IN_REVIEW, CLOSED) |
| Action | Something a user or system can do in a state (e.g. assign, approve) |
| Handler | TypeScript class that executes the logic for an action |
| Transition | Rule that determines the next state after an action succeeds/fails |
| Module | A separate sub-workflow that can be entered and exited from the main flow |
| Metadata | Key-value data attached to each instance (stored via MetadataStore) |
| Auto Instruction | Timed/scheduled action that fires automatically in a state |
Workflow YAML Schema
Top-level structure
version: "1.0" # Required. Schema version.
entry: ST001 # Optional. Initial state code. Defaults to first state.
metadata_keys: [...] # Optional. Declared metadata fields.
actions: [...] # Required. Global action definitions.
states: [...] # Required. At least one state.
imports: [...] # Optional. Module imports.
global_permissions: [...] # Optional. Role-based permission declarations.States
states:
- code: ST001 # Required. Unique state identifier.
name: Initial Review # Optional. Human-readable name.
description: "..." # Optional. Description.
actions: # Actions available in this state.
- name: approve
required_permissions:
- reviewer
on_success:
state: ST002
on_failure:
state: ERROR
auto: # Optional. Scheduled auto-instructions.
- job_name: reminder
time: "48h"
function: send_reminderActions
Actions are defined globally and referenced by name in states:
actions:
- name: approve
handler: ApproveHandler # Maps to a registered handler class.
permissions: # Optional. Roles required to execute.
- reviewer
- adminTransitions
Transitions are declared on state actions via on_success, on_failure, and on_complete. Three equivalent YAML shapes are supported — all are normalized internally to TransitionRule[]:
Shape 1 — Direct (unconditional):
on_success:
state: ST002Shape 2 — Array of conditional rules:
on_success:
- if: "approved == true"
then:
state: ST002
- if: "true"
then:
state: ST003Shape 3 — Conditions wrapper:
on_success:
conditions:
- if: "doc_count >= 3"
then:
state: ST002
- if: "true"
then:
state: REJECTEDTransition targets can be:
| Target | Description |
|---|---|
| state: "ST002" | Move to a new state in the current scope |
| module: "review" | Enter a sub-workflow module |
| state: ":ST004" | Exit the current module and return to state ST004 in the parent |
Metadata Keys
Declare the metadata fields your workflow uses. Defaults are initialized automatically on createInstance():
metadata_keys:
- name: status
type: string # Default: ""
- name: count
type: number # Default: 0
- name: approved
type: boolean # Default: false
- name: config
type: json # Default: null
- name: tags
type: list # Default: []
- name: votes
type: vote # Default: []Auto Instructions
Schedule actions or transitions to fire automatically when an instance enters a state:
auto:
- job_name: escalation_timer # Optional. Identifier for cancellation.
time: "48h" # One-shot delay (supports s/m/h/d).
function: escalate # Action to execute.
condition: "priority >= 3" # Optional. Only fires if true.
- job_name: heartbeat
repeat: "12h" # Repeating interval.
function: send_ping
- time: "0s" # Fires immediately.
next_state: ST002 # Direct transition (no handler).Module Imports
imports:
- name: review
path: ./modules/review.yaml
- name: translation
path: ./modules/translation.yamlPermissions
global_permissions:
- role: admin
can_view_all_cases: true
can_perform_actions:
- approve
- reject
- role: viewer
can_view_all_cases: trueEngine API
Constructor (EngineConfig)
import { Engine } from '@cflow/core';
const engine = new Engine({
// Required
definition: yamlString, // YAML string, WorkflowDefinition object, or NormalizedWorkflow
store: myMetadataStore, // MetadataStore implementation
// Optional
handlers: { // Custom handler classes
MyHandler: MyHandlerClass,
},
modules: { // Sub-workflow modules
review: reviewYaml,
},
eventStore: myEventStore, // EventStore for audit trail
permissions: myPermHook, // PermissionHook function
logger: myLogger, // Logger implementation
registerBuiltins: true, // Register built-in handlers (default: true)
// Event callbacks
onTransition: (e) => { ... },
onActionExecuted: (e) => { ... },
onError: (e) => { ... },
onModuleEnter: (e) => { ... },
onModuleExit: (e) => { ... },
onInstanceCreated: (e) => { ... },
});createInstance()
await engine.createInstance('case-42', {
initialState: 'CUSTOM_START', // Optional. Override entry state.
createdBy: 'user-1', // Optional. Tracked in events.
metadata: { priority: 5 }, // Optional. Pre-set metadata.
});- Sets the initial state
- Initializes the module stack
- Sets caller-provided metadata
- Initializes declared
metadata_keyswith type-appropriate defaults - Emits
instance:createdevent - Schedules any auto-actions for the initial state
executeAction()
const result = await engine.executeAction(
'case-42', // Instance ID
'approve', // Action name
{ userId: 'u1', roles: ['reviewer'] }, // User context
{ comment: 'Looks good' }, // Optional payload
);
// result: { success: true, data?: any, error?: string }Execution flow:
- Resolves current scope (root or active module)
- Validates the action exists in the current state
- Checks permissions via the configured hook
- Creates a scoped metadata accessor for the handler
- Instantiates and executes the handler
- Evaluates
on_success/on_failuretransition rules - Applies the first matching transition
Query Methods
// Read state
const state = await engine.getCurrentState('case-42');
const module = await engine.getCurrentModule('case-42');
// Introspect the definition
const allStates = engine.getAllStates();
const actions = engine.getStateActions('ST001');
const adminActions = engine.getStateActions('ST001', ['admin']);
const keys = engine.getMetadataKeys();
const def = engine.getDefinition(); // Root definition
const modDef = engine.getDefinition('review'); // Module definition
// Validate configuration
const missing = engine.validateHandlers();
// Returns: ["actionName → HandlerName"] for any unregistered handlers
// Access the handler registry
const registry = engine.getHandlerRegistry();Events
The engine emits events through three channels simultaneously:
- EventEmitter —
engine.on('state:transition', handler) - EventStore — persisted via
eventStore.log(event)if configured - Config callbacks —
onTransition,onActionExecuted, etc.
Event types:
| Event | Fired when |
|---|---|
| instance:created | createInstance() completes |
| action:executed | A handler finishes (success or failure) |
| state:transition | The instance moves to a new state |
| module:entered | A sub-workflow module is entered |
| module:exited | A sub-workflow module is exited |
| engine:error | An internal error occurs |
| auto:triggered | An auto-scheduled action fires |
// EventEmitter style
engine.on('state:transition', (event) => {
console.log(`${event.instanceId}: ${event.from} → ${event.to}`);
});
// Callback style (via config)
const engine = new Engine({
...config,
onTransition: (event) => {
console.log(`${event.from} → ${event.to}`);
},
});Handlers
Writing a Handler
Extend BaseHandler and implement execute():
import { BaseHandler, ActionResult } from '@cflow/core';
class AssignCaseHandler extends BaseHandler {
async execute(payload: { assigneeId: string }): Promise<ActionResult> {
const previous = await this.getString('assigned_to');
await this.setString('assigned_to', payload.assigneeId);
this.logger.info(`Reassigned from ${previous} to ${payload.assigneeId}`);
return {
success: true,
data: { previous, current: payload.assigneeId },
};
}
}Every handler receives an injected HandlerContext with:
| Property | Type | Description |
|---|---|---|
| this.instanceId | InstanceId | The workflow instance being acted on |
| this.user | UserContext | The user performing the action |
| this.store | MetadataAccessor | Scoped metadata read/write |
| this.logger | Logger | Logger instance |
BaseHandler Convenience Methods
All methods are protected and async:
| Method | Description |
|---|---|
| getString(key) | Get a string value |
| setString(key, value) | Set a string value |
| getNumber(key) | Get a number value |
| setNumber(key, value) | Set a number value |
| incrementNumber(key, by?) | Increment a number (default +1), returns new value |
| getBoolean(key) | Get a boolean value |
| setBoolean(key, value) | Set a boolean value |
| getArray(key) | Get an array (returns [] if missing) |
| appendToArray(key, item) | Append to an array |
| removeFromArray(key, item) | Remove from an array |
| getJson(key) | Get a JSON/object value |
| setJson(key, value) | Set a JSON/object value |
| mergeJson(key, partial) | Shallow-merge into an existing object |
| get(key) | Generic get |
| set(key, value) | Generic set |
| getAll() | Get all metadata in current scope |
Built-in Handlers
Registered automatically unless registerBuiltins: false:
| Registry Name | Class | Payload | Description |
|---|---|---|---|
| SetMetaKeyAction | SetMetaKeyHandler | { metaKey, value } | Set any metadata key |
| AppendToMetaArrayAction | AppendToMetaArrayHandler | { metaKey, value } | Append to array key |
| IncrementMetaKeyAction | IncrementMetaKeyHandler | { metaKey, by? } | Increment numeric key |
| VoteAction | VoteHandler | { metaKey, userId, voteValue } | Record a vote |
Handler Registry
The registry is instance-level (per engine), not global:
const registry = engine.getHandlerRegistry();
registry.register('MyHandler', MyHandlerClass);
registry.has('MyHandler'); // true
registry.get('MyHandler'); // MyHandlerClass
registry.list(); // ['SetMetaKeyAction', ..., 'MyHandler']
registry.getMetadata('MyHandler'); // Decorator metadata (see below)
registry.clear();Decorators
Attach UI metadata to handlers for dynamic form generation:
import { BaseHandler, HandlerMeta, UIField } from '@cflow/core';
@HandlerMeta({
name: 'Assign Case',
description: 'Assigns a case to a team member',
category: 'Admin',
})
class AssignCaseHandler extends BaseHandler {
@UIField({ label: 'Assignee', name: 'assigneeId', type: 'assign', required: true })
assigneeId!: string;
@UIField({ label: 'Priority', name: 'priority', type: 'select', options: ['low', 'medium', 'high'] })
priority!: string;
async execute(payload: any) {
await this.setString('assigned_to', payload.assigneeId);
return { success: true };
}
}Read metadata at runtime:
import { getHandlerMetadata } from '@cflow/core';
const meta = getHandlerMetadata(AssignCaseHandler);
// meta.handler → { name: 'Assign Case', description: '...', category: 'Admin' }
// meta.fields → { assigneeId: { label: 'Assignee', type: 'assign', ... }, ... }Supported UIField types: text, number, select, checkbox, date, textarea, assign, file, document.
Condition Evaluator
The engine uses a safe expression evaluator (no eval(), no Function()).
Format: left operator right
Operators: ==, !=, >, <, >=, <=
Value types:
- Numbers:
42,3.14,-5 - Booleans:
true,false - Null:
null - Strings:
"hello"or'hello' - Data paths:
status,user.level,doc_count
Special: The literal string "true" evaluates to true (used for unconditional/fallback transitions).
Examples:
# Boolean check
if: "approved == true"
# Numeric comparison
if: "doc_count >= 3"
# String comparison
if: "status == 'completed'"
# Nested path
if: "review.score > 7"
# Unconditional fallback (always matches)
if: "true"Standalone usage:
import { evaluateCondition } from '@cflow/core';
evaluateCondition('count >= 5', { count: 10 }); // true
evaluateCondition('status == "done"', { status: 'done' }); // trueModules (Sub-workflows)
Modules allow you to compose workflows from reusable sub-flows. When a module is entered, the engine pushes onto a module stack and switches to the module's states. When the module exits, it pops back to the parent.
Defining a module
# modules/review.yaml
version: "1.0"
entry: REV_START
actions:
- name: complete_review
handler: CompleteReviewHandler
states:
- code: REV_START
name: Review In Progress
actions:
- name: complete_review
on_success:
state: ":ST003" # Exit module, return to ST003 in parentEntering a module from root
# In the root workflow:
states:
- code: ST002
actions:
- name: start_review
on_success:
module: review # Enter the "review" module
expose_global_keys: # Copy these keys into module scope
- case_status
- priorityWiring it up
const engine = new Engine({
definition: mainWorkflowYaml,
store: myStore,
modules: {
review: reviewModuleYaml, // YAML string, object, or NormalizedWorkflow
},
handlers: { CompleteReviewHandler },
});Module stack
The engine supports nested modules (module A enters module B). The stack is persisted in metadata as _cfis.stack.
const currentModule = await engine.getCurrentModule('case-1'); // 'review' | nullScoped metadata
Inside a module, handler metadata keys are prefixed with the module name (e.g. review:my_key). Global keys exposed via expose_global_keys are copied into the module scope on entry.
Plugins
Permissions Plugin
Two built-in permission hooks:
import { allowAll, roleBasedPermissions } from '@cflow/core';
// Allow everything (development/public workflows)
const engine = new Engine({ permissions: allowAll, ... });
// Role-based: user must have at least one required role
const engine = new Engine({ permissions: roleBasedPermissions, ... });Custom hook:
const engine = new Engine({
permissions: async (user, requiredPermissions, actionName, instanceId) => {
// Your custom logic
return user.roles?.includes('superadmin') ?? false;
},
...
});ACL
Per-instance access control backed by metadata:
import { AccessControlList, InMemoryStore } from '@cflow/core';
const store = new InMemoryStore();
const acl = new AccessControlList(store);
// User access
await acl.addUser('user-42', 'case-1');
await acl.hasAccess('user-42', 'case-1'); // true
await acl.removeUser('user-42', 'case-1');
// Org access
await acl.addOrg('org-1', 'case-1');
await acl.hasAccess('org:org-1', 'case-1'); // true
// Owner
await acl.updateOwner('case-1', 'user-1', 'user');
await acl.hasAccess('user-1', 'case-1'); // true (owner has implicit access)
await acl.getOwnerType('case-1'); // 'user'
// Cache management
acl.clearCache('case-1'); // Clear cache for one instance
acl.clearCache(); // Clear all cachesLogging
import { ConsoleLogger, VerboseLogger, silentLogger } from '@cflow/core';
// Console (default) — debug is silent, info/warn/error go to console
const logger = new ConsoleLogger('[myapp]');
// Verbose — includes debug output
const logger = new VerboseLogger('[myapp]');
// Silent — suppresses all output (ideal for tests)
const engine = new Engine({ logger: silentLogger, ... });Custom logger: Implement the Logger interface:
interface Logger {
debug(message: string, ...args: any[]): void;
info(message: string, ...args: any[]): void;
warn(message: string, ...args: any[]): void;
error(message: string, ...args: any[]): void;
}Store Interface
The engine is storage-agnostic. You provide a MetadataStore implementation:
interface MetadataStore {
getValue(instanceId: InstanceId, key: string): Promise<any>;
setValue(instanceId: InstanceId, key: string, value: any): Promise<void>;
getAllMetadata(instanceId: InstanceId): Promise<Record<string, any>>;
appendToArray(instanceId: InstanceId, key: string, item: any): Promise<void>;
removeFromArray(instanceId: InstanceId, key: string, item: any): Promise<void>;
}Built-in: InMemoryStore (for testing and prototyping).
Production examples: PostgreSQL adapter, Redis adapter, MongoDB adapter — implement the 5 methods above.
Optional EventStore for audit trail:
interface EventStore {
log(event: EngineEvent): Promise<void>;
getEvents(instanceId: InstanceId): Promise<EngineEvent[]>;
getRecentEvents(instanceId: InstanceId, limit: number): Promise<EngineEvent[]>;
}Built-in: InMemoryEventStore (for testing).
Testing
TestHarness
A batteries-included helper for writing workflow tests:
import { TestHarness } from '@cflow/core';
const harness = new TestHarness({
workflow: myYaml,
handlers: { MyHandler: MyHandlerClass },
modules: { review: reviewYaml }, // Optional
verbose: false, // Optional. Default: false (silent).
});
// Create an instance (default ID: 1)
await harness.createInstance();
// Execute actions (default user: admin)
await harness.execute('approve', { comment: 'ok' });
// Assertions
expect(await harness.getState()).toBe('APPROVED');
expect(await harness.getMeta('assigned_to')).toBe('u1');
// Introspection
harness.dumpStore(); // All metadata as plain object
harness.getEvents(); // All emitted events
harness.getEventsOfType('state:transition'); // Filter by type
// Reset between tests
harness.reset();InMemoryStore helpers
const store = new InMemoryStore();
store.dump('case-1'); // All metadata as plain object
store.hasInstance('case-1'); // boolean
store.clear(); // Wipe everythingInMemoryEventStore helpers
const events = new InMemoryEventStore();
events.all(); // All events
events.ofType('state:transition'); // Filtered by type
events.count; // Total event count
events.clear(); // Wipe everythingProject Structure
@cflow/core - project structure:
├── src/
│ ├── index.ts # Public API exports
│ ├── types.ts # All type definitions
│ ├── schema/
│ │ ├── parse.ts # YAML parsing
│ │ ├── validate.ts # Structural validation
│ │ └── normalize.ts # Transition normalization
│ ├── runtime/
│ │ ├── Engine.ts # Core engine (extends EventEmitter)
│ │ ├── ConditionEvaluator.ts # Safe expression evaluator
│ │ ├── ModuleStack.ts # Module nesting stack
│ │ ├── ScopedAccessor.ts # Scoped metadata access
│ │ └── Scheduler.ts # Auto-action scheduling
│ ├── handlers/
│ │ ├── BaseHandler.ts # Abstract handler base class
│ │ ├── HandlerRegistry.ts # Per-engine handler map
│ │ ├── builtins.ts # SetMetaKey, Append, Increment, Vote
│ │ └── decorators.ts # @HandlerMeta, @UIField
│ ├── plugins/
│ │ ├── permissions.ts # allowAll, roleBasedPermissions
│ │ ├── acl.ts # AccessControlList
│ │ └── logging.ts # Console, Verbose, silent loggers
│ └── testing/
│ ├── InMemoryStore.ts # Map-based MetadataStore
│ ├── InMemoryEventStore.ts # Array-based EventStore
│ └── TestHarness.ts # Quick-start test helper
├── tests/
│ ├── fixtures.ts # Shared YAML fixtures
│ ├── schema.parse.test.ts
│ ├── schema.validate.test.ts
│ ├── schema.normalize.test.ts
│ ├── condition-evaluator.test.ts
│ ├── engine.test.ts
│ ├── handlers.test.ts
│ ├── decorators.test.ts
│ ├── plugins.test.ts
│ ├── runtime-utils.test.ts
│ ├── test-harness.test.ts
│ └── e2e.test.ts
├── package.json
├── tsconfig.json
└── vitest.config.tsScripts
npm run build # Compile TypeScript → dist/
npm run dev # Compile in watch mode
npm run clean # Remove dist/
npm test # Run all tests (vitest)
npm run test:watch # Run tests in watch modeLicense
MIT
