Migrating from 0.13 to 1.0
Summary
1.0 adds durable steps (ctx.step.*, backed by a new WorkflowStep table), injects AI services into every stage context (ctx.ai, ctx.aiLogger, ctx.step.ai), replaces the async-batch stage pattern with one primitive (ctx.step.ai.map with a realtime/batch policy), makes the workflow builder infer the context type from earlier stages, pins runs to a definition version (13-definition-versioning.md), replaces run.rerunFrom with run.redrive (14-redrive.md), spills oversized step results and job payloads to the blob store (15-large-payloads.md), ships an embeddable operational console (16-operational-console.md), and removes everything deprecated for 1.0. It also moves to AI SDK 7. Two things bite at runtime rather than compile time, so do them first: the database needs the workflow_steps table plus the columns and indexes listed below, and every stage context now carries step, ai, aiLogger and abortSignal.
The first real-world runs of the 1.0 alphas also found and fixed behaviour that 0.13 code may rely on: batch results are now validated and repaired, realtime map retries run in-process, and hosts flush the outbox on stop(). See "Behaviour changes".
Does this affect you?
- Every consumer — apply the database checklist and update the peer dependencies. If you construct a
StageContextby hand (custom host, unit tests callingstage.execute(ctx)directly) read "Hand-built contexts". - You use
defineAsyncBatchStage— it is no longer exported. Migrate todefineStagewithctx.step.waitFor(a poll) orctx.step.ai.map(an AI batch); the section below has the before/after.npx workflow-engine-codemod --from 0.13flags every use, withcheckCompletion,requireStageOutput,experimental_outputand the removed model helpers. - You call any API in the removals table — those are compile errors now; each has a one-line replacement.
- You implement
AIAdapter—generateObjectresults are now read fromobject(an alpha bug readoutput), and the repair loop expectsNoObjectGeneratedErrorwithtextset. See "Adapters". - You implement a port yourself (
JobQueue/JobTransport,WorkflowPersistence,StepLedger) — each gained required methods and changed return types; the three checklist items under "Custom port implementations" list them, and the conformance suites in@bratsos/workflow-engine/testingcheck every one. - You dispatch
run.rerunFrom— it still works, but it is deprecated forrun.redrive, which keeps the resumed stage's completed steps and can move a run onto another definition version. See "Behaviour changes" and14-redrive.md.
Database checklist
Verified against git diff of the package's prisma/schema.prisma between 0.13.0 and 1.0.0, plus every column the 1.0 Prisma adapters write. Apply in order; every statement is idempotent on Postgres (IF NOT EXISTS).
-
Add the
workflow_stepstable (new in 1.0; used bycreatePrismaStepLedger).model WorkflowStep {id String @id @default(cuid())stageRecordId Stringstage WorkflowStage @relation(fields: [stageRecordId], references: [id], onDelete: Cascade)stepId Stringseq Intkind Stringstatus Stringattempt Int @default(1)leaseExpiresAt DateTime?deadlineAt DateTime?externalKey String?result Json?error String?waitState Json?createdAt DateTime @default(now())updatedAt DateTime @updatedAt@@unique([stageRecordId, stepId])@@index([stageRecordId])@@map("workflow_steps")}Add the back-relation
steps WorkflowStep[]to yourWorkflowStagemodel.CREATE TABLE IF NOT EXISTS "workflow_steps" ("id" TEXT PRIMARY KEY,"stageRecordId" TEXT NOT NULL,"stepId" TEXT NOT NULL,"seq" INTEGER NOT NULL,"kind" TEXT NOT NULL,"status" TEXT NOT NULL,"attempt" INTEGER NOT NULL DEFAULT 1,"leaseExpiresAt" TIMESTAMP(3),"deadlineAt" TIMESTAMP(3),"externalKey" TEXT,"result" JSONB,"error" TEXT,"waitState" JSONB,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"updatedAt" TIMESTAMP(3) NOT NULL,CONSTRAINT "workflow_steps_stageRecordId_fkey"FOREIGN KEY ("stageRecordId") REFERENCES "workflow_stages"("id")ON DELETE CASCADE ON UPDATE CASCADE);CREATE UNIQUE INDEX IF NOT EXISTS "workflow_steps_stageRecordId_stepId_key"ON "workflow_steps"("stageRecordId", "stepId");CREATE INDEX IF NOT EXISTS "workflow_steps_stageRecordId_idx"ON "workflow_steps"("stageRecordId");stageRecordIdis theWorkflowStage.idof the stage execution. The foreign key cascades: deleting a stage record — or the run above it, throughworkflow_stages' own cascade — removes its ledger rows with it, so a run deleted by hand or byrun.purgeleaves no orphans inworkflow_steps. The kernel still clears the ledger explicitly (StepLedger.clear/clearExcepton a rerun, andrun.purgebefore it deletes the run) because theStepLedgerport is pluggable and a non-Prisma ledger has no cascade to rely on. If you createdworkflow_stepsfrom an earlier 1.0 alpha, add the constraint (rows whose stage record no longer exists must be deleted first, or theADD CONSTRAINTfails its validation):DELETE FROM "workflow_steps" sWHERE NOT EXISTS (SELECT 1 FROM "workflow_stages" st WHERE st."id" = s."stageRecordId");DO $$BEGINIF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workflow_steps_stageRecordId_fkey') THENALTER TABLE "workflow_steps"ADD CONSTRAINT "workflow_steps_stageRecordId_fkey"FOREIGN KEY ("stageRecordId") REFERENCES "workflow_stages"("id")ON DELETE CASCADE ON UPDATE CASCADE;END IF;END $$;resultholds the JSON the step returned (adownloadstep that returns the whole document stores the whole document — return a key or a summary when the payload is large).externalKey(added in 1.0.0-alpha.9) is the deterministic name of the external effect arunbody creates, written before the body runs; it is what lets an operator find a provider-side effect orphaned by a crash. If you createdworkflow_stepsfrom an earlier 1.0 alpha, add the column:ALTER TABLE "workflow_steps" ADD COLUMN IF NOT EXISTS "externalKey" TEXT; -
Add
batchIdandrequestIdtoai_calls. The 0.12→0.13 guide asked for these, but the package's ownprisma/schema.prismadid not carry them until 1.0, so a consumer who copied the package schema is missing them. Both nullable; the unique index is what deduplicates batch cost rows.ALTER TABLE "ai_calls"ADD COLUMN IF NOT EXISTS "batchId" TEXT,ADD COLUMN IF NOT EXISTS "requestId" TEXT;CREATE INDEX IF NOT EXISTS "ai_calls_batchId_idx" ON "ai_calls"("batchId");CREATE UNIQUE INDEX IF NOT EXISTS "ai_calls_batch_request_unique"ON "ai_calls"("batchId", "requestId"); -
Add the cost-accounting fields and columns to
ai_calls. Since 1.0.0-alpha.14 every call row keeps the registry estimate beside the provider's figure, which of the twocostis, the endpoint that served the request, and the cached-input and reasoning token breakdowns. All six are nullable; rows written before this read back with them absent andcostis unchanged, so nothing depends on a backfill. The Prisma logger passes them on every write, so you must update your Prisma schema, apply the SQL migration, and regenerate your Prisma client before deploying:-
Add the six fields to your
AICallmodel inschema.prisma:model AICall {// ... existing fields ...estimatedCost Float?reportedCost Float?costSource String?servedBy String?cachedInputTokens Int?reasoningTokens Int?} -
Add the columns to the database:
ALTER TABLE "ai_calls"ADD COLUMN IF NOT EXISTS "estimatedCost" DOUBLE PRECISION,ADD COLUMN IF NOT EXISTS "reportedCost" DOUBLE PRECISION,ADD COLUMN IF NOT EXISTS "costSource" TEXT,ADD COLUMN IF NOT EXISTS "servedBy" TEXT,ADD COLUMN IF NOT EXISTS "cachedInputTokens" INTEGER,ADD COLUMN IF NOT EXISTS "reasoningTokens" INTEGER; -
Regenerate the Prisma client (
npx prisma generateorpnpm prisma generate) before deploying. If you omit this step, the generated client rejects the logger's write withUnknown argument estimatedCost.
-
-
Confirm the columns the adapters write on every dispatch. These all exist in the 0.13 package schema, but consumers who forked the schema before 0.11 (or applied migrations selectively) have hit each of them as a runtime
Unknown argumentfrom Prisma on the firstdispatch. Each write site is named so you can grep the adapter if you doubt it.Table Column Written by If missing workflow_runsversion INT NOT NULL DEFAULT 1every run update (optimistic concurrency) run.transitionthrowsworkflow_runsconfig JSONB NOT NULL DEFAULT '{}',priority INT DEFAULT 5,metadata JSONBcreateRunrun.createthrowsworkflow_runstotalCost FLOAT DEFAULT 0,totalTokens INT DEFAULT 0run completion run.transitionthrowsworkflow_stagesattempt INT NOT NULL DEFAULT 0createStage/upsertStagefirst job.executethrowsUnknown argument attemptworkflow_stagesversion INT NOT NULL DEFAULT 1upsertStage(version: { increment: 1 }) and every guarded updatesame workflow_stagessuspendedState,resumeData JSONB,nextPollAt TIMESTAMP,pollInterval INT,maxWaitUntil TIMESTAMP,metrics,embeddingInfo JSONB,errorMessage TEXTstage updates suspension / failure paths throw workflow_annotationswhole table (0.8) incl. attempt,scope,scopeId,actorKind,actorId,actorVersion,key,value,payload,idempotencyKeyappendAnnotationsctx.annotatethrowsjob_queueattempt INT DEFAULT 0,maxAttempts INT DEFAULT 3,workerId,lockedAt,startedAt,completedAt,nextPollAt TIMESTAMP,payload JSONB,lastError TEXTenqueue / dequeue / complete / fail job claim throws outbox_eventssequence INT,causationId TEXT,occurredAt TIMESTAMP,publishedAt,retryCount INT DEFAULT 0,dlqAt TIMESTAMPappendEvents/outbox.flushevery command that emits an event throws idempotency_keyscreatedAt TIMESTAMP NOT NULL DEFAULT now()acquireIdempotencyKeywrites it explicitly so an injectedClockis authoritativeevery dispatchfails withUnknown argument createdAtidempotency_keyscommandType TEXT,result JSONBsame same -- The three that 1.0 consumers actually tripped onALTER TABLE "workflow_stages" ADD COLUMN IF NOT EXISTS "attempt" INTEGER NOT NULL DEFAULT 0;ALTER TABLE "workflow_stages" ADD COLUMN IF NOT EXISTS "version" INTEGER NOT NULL DEFAULT 1;ALTER TABLE "idempotency_keys" ADD COLUMN IF NOT EXISTS "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;The reliable check is a diff:
npx prisma migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel node_modules/@bratsos/workflow-engine/prisma/schema.prisma --scriptprints the SQL that separates your database from the package schema (ignore the differences on your own tables). -
Optional: add
workflow_blobsif you want Prisma as the blob store. Stage outputs and every replay'sctx.inputare read from theBlobStoreby every process that executes or polls a run (workers, cron ticks, a web process that kicks orchestration), so the store must be shared — anInMemoryBlobStorein one of them fails the next process withBlob "<key>" ... is not in the blob store.createPrismaBlobStore(prisma)keeps blobs in this table so no object storage is needed; it requires only theworkflowBlobdelegate, so consumers on S3/R2 do not add it.model WorkflowBlob {key String @iddata JsoncreatedAt DateTime @default(now())updatedAt DateTime @updatedAt@@map("workflow_blobs")}CREATE TABLE IF NOT EXISTS "workflow_blobs" ("key" TEXT PRIMARY KEY,"data" JSONB NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"updatedAt" TIMESTAMP(3) NOT NULL); -
job_queuegains a unique on(workflowRunId, stageId). Required as of 1.0.0-alpha.7.run.rerunFromnow retires the job rows of the stages it deletes, and every enqueue path replaces the row already queued for a pair instead of inserting another one — "one job row per stage per run" is the invariant those paths rely on, so declare it.model JobQueue {// ...unchanged columns...@@unique([workflowRunId, stageId])@@index([status, priority(sort: Desc), createdAt]) // replaces @@index([status, priority]); see the index block below@@index([status, createdAt])@@index([nextPollAt])@@map("job_queue")}Collapse duplicate rows first — the index build fails while any pair has more than one row. Runs before the upgrade may have accumulated duplicates (each
run.rerunFromadded one per rerun stage). Keep the newest row per pair; the older ones are finished history of stages that have since been re-enqueued, and nothing reads a job row by id except the host currently holding it:-- 1. Look before you delete.SELECT "workflowRunId", "stageId", count(*)FROM "job_queue" GROUP BY 1, 2 HAVING count(*) > 1;-- 2. Keep the newest row per (run, stage). Run it when no worker is mid-job:-- a RUNNING row that loses is a job whose host will fail its `complete`.DELETE FROM "job_queue" aUSING "job_queue" bWHERE a."workflowRunId" = b."workflowRunId"AND a."stageId" = b."stageId"AND (a."createdAt", a.id) < (b."createdAt", b.id);-- 3. Then add the constraint.CREATE UNIQUE INDEX IF NOT EXISTS "job_queue_workflowRunId_stageId_key"ON "job_queue" ("workflowRunId", "stageId");A deployment that cannot take the constraint yet still gets the fix: the enqueue paths are idempotent regardless, so reruns stop accumulating rows. What the constraint adds is the database refusing a duplicate a custom
JobQueueimplementation might still write. -
Pin runs to a definition version (optional, but it is what makes a rolling deploy safe — see the Core Concepts → Definition Versioning page of the documentation site for what the version identifies and how a fleet drains one). Two columns on
workflow_runs(definitionVersion, nullable, andredriveCount, defaulting to 0), two indexes, and one new table. Entirely additive with no backfill, and skipping it is a supported configuration: the Prisma adapter detects that the generated client has noworkflowDefinitiondelegate (it is optional onEnginePrismaClient), records no versions, leaves claiming unfiltered, and answersrun.listVersionswith{ supported: false }. A client that does carry the model is confirmed against the database once, lazily, through a catalogue read that answers "absent" instead of raising — so a database behind its client (prisma generatebeforemigrate deploy, a rolling deploy that ships code first) still starts, with versioning off.createPrismaWorkflowPersistence(prisma, { definitionVersioning: true | false })skips both checks. Runs created before the migration keep aNULLversion for life and stay claimable by every host whose registry holds their workflow.ALTER TABLE "workflow_runs" ADD COLUMN IF NOT EXISTS "definitionVersion" TEXT;ALTER TABLE "workflow_runs" ADD COLUMN IF NOT EXISTS "redriveCount" INTEGER NOT NULL DEFAULT 0;CREATE TABLE IF NOT EXISTS "workflow_definitions" ("workflowId" TEXT NOT NULL,"version" TEXT NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"snapshot" JSONB NOT NULL,"structureHash" TEXT NOT NULL,CONSTRAINT "workflow_definitions_pkey" PRIMARY KEY ("workflowId", "version"));There is deliberately no
workflow_definitions (workflowId)index: the compound primary key already leads with that column, so a lookup by workflow alone plans identically with and without one (measured: 0.188 ms vs 0.187 ms at 20k rows). An earlier draft of this guide created one — if you already ran it,DROP INDEX CONCURRENTLY IF EXISTS "workflow_definitions_workflowId_idx";is safe.The indexes for these two columns are in the index block below, because they belong to one
workflow_runsindex set rather than to two separate migrations. -
Replace the
workflow_runsandjob_queueindex sets. 1.0 changes which orderings are served, and the changes are stated together here because they touch the same two tables and should go in one migration. Nothing about them is required for correctness — every query still returns the same rows without them — but two of the three replaced indexes were serving sequential scans and sorts on the hottest paths in the system.Build them
CONCURRENTLYon a live database (outside a transaction), then drop the ones they replace.-- workflow_runs: newest-first list orderings, keyset-paged on-- (createdAt, id). Replaces the bare (status) and (workflowId) indexes:-- a composite whose leading column is the same serves every lookup the-- single-column index served. Measured at 500k runs: "recent runs first"-- 244 ms -> 0.03 ms, filtered by status 94 ms -> 0.03 ms, filtered by-- workflow 60 ms -> 0.03 ms. The engine's own hot paths are unchanged-- within noise (claimNextPendingRun 18.4 -> 19.2 ms).CREATE INDEX CONCURRENTLY IF NOT EXISTS "workflow_runs_createdAt_id_idx"ON "workflow_runs" ("createdAt" DESC, "id" DESC);CREATE INDEX CONCURRENTLY IF NOT EXISTS "workflow_runs_status_createdAt_id_idx"ON "workflow_runs" ("status", "createdAt" DESC, "id" DESC);CREATE INDEX CONCURRENTLY IF NOT EXISTS "workflow_runs_workflowId_createdAt_id_idx"ON "workflow_runs" ("workflowId", "createdAt" DESC, "id" DESC);DROP INDEX CONCURRENTLY IF EXISTS "workflow_runs_status_idx";DROP INDEX CONCURRENTLY IF EXISTS "workflow_runs_workflowId_idx";-- workflow_runs: the claim. `claimNextPendingRun` issues the same shape-- against workflow_runs that the dequeue issues against job_queue ---- status = 'PENDING' ORDER BY priority DESC, "createdAt" ASC LIMIT 1 ---- maxClaimsPerTick times per tick per host, and none of the indexes-- above can serve it, because priority is in none of them. Measured on-- Postgres 16 at 300k runs / 60k pending: 11.8-15.1 ms before (top-N-- sort over every PENDING row), 0.008-0.022 ms after, for 9 MB.CREATE INDEX CONCURRENTLY IF NOT EXISTS "workflow_runs_status_priority_createdAt_idx"ON "workflow_runs" ("status", "priority" DESC, "createdAt" ASC);-- workflow_runs: definition versioning. Only if you took the columns-- above. The first serves a lookup narrowed to one version with no-- workflow; the second is the narrow stand-in for the (status) index the-- list orderings replaced (a status count runs index-only off it) and-- narrows the version-filtered claim. Neither covers `run.listVersions`,-- which plans a parallel sequential scan regardless: it passes no status-- filter, and its aggregate asks for a MIN("createdAt") no index here-- carries.CREATE INDEX CONCURRENTLY IF NOT EXISTS "workflow_runs_definitionVersion_idx"ON "workflow_runs" ("definitionVersion");CREATE INDEX CONCURRENTLY IF NOT EXISTS "workflow_runs_status_workflowId_definitionVersion_idx"ON "workflow_runs" ("status", "workflowId", "definitionVersion");-- job_queue: cover the dequeue's createdAt tiebreak. Replaces-- (status, priority), whose leading columns it repeats. Without the-- tiebreak in the index a deep queue reads and sorts every PENDING row on-- every claim: measured on Postgres 16 with flat priorities, 0.55 ms at-- 1,000 ready rows and 26.7 ms at 50,000; with it, 0.03 ms and 0.04 ms,-- flat with depth. It is the one index change here that costs real space —-- (status, priority) is nearly all duplicate keys, which btree-- deduplication collapses, and adding createdAt makes every key distinct:-- 1 MB -> 10 MB at 150,000 rows.CREATE INDEX CONCURRENTLY IF NOT EXISTS "job_queue_status_priority_createdAt_idx"ON "job_queue" ("status", "priority" DESC, "createdAt" ASC);DROP INDEX CONCURRENTLY IF EXISTS "job_queue_status_priority_idx";-- job_queue: queue health reads MIN("createdAt") within a status, and-- completed rows are retained rather than deleted.CREATE INDEX CONCURRENTLY IF NOT EXISTS "job_queue_status_createdAt_idx"ON "job_queue" ("status", "createdAt");-- outbox_events: the dead-letter page. Prisma cannot express a partial-- index; if you write migrations by hand, prefer one — dead letters are a-- small subset of unpublished rows.-- CREATE INDEX CONCURRENTLY ... ON "outbox_events" ("dlqAt") WHERE "dlqAt" IS NOT NULL;CREATE INDEX CONCURRENTLY IF NOT EXISTS "outbox_events_dlqAt_idx"ON "outbox_events" ("dlqAt");
Custom port implementations
-
Custom
JobQueue/JobTransportimplementation? The port changed in several places;jobQueueConformanceSuitecovers all of them.enqueueis gone from the port — the kernel callsenqueueParallel([job])(the built-in queues keepenqueueas a plain method).enqueueParallelmust be idempotent on(workflowRunId, stageId): replace any row already queued for a pair, resettingattempt,status,workerId,lockedAt,lastErrorandnextPollAt.deleteByRunAndStages(workflowRunId, stageIds)is a new required method (delete every row for those stages of that run, any status, return the count).dequeue(options?)returnsstartedAt— the attempt stamp of that claim — and takesDequeueOptions.serves, naming the definition versions the calling host presents: filter on the payload's_definitionVersion/_workflowIdif you can, and ignore it if your transport cannot select (the kernel's version-ghost handling is the backstop).complete,failandsuspendreturn"acknowledged" | "superseded"(JobAckOutcome) instead ofvoidand take an optional trailingJobAckFence({ startedAt, attempt }); a fenced write must be conditioned on the job still being the RUNNING attempt the fence describes, and a fenced call naming a job row that no longer exists must return"superseded"rather than throw (unfenced, it stays an error). A decorator around a transport must forward the fence explicitly — dropping the optional parameter still typechecks and silently turns every fenced acknowledgement back into an unconditional write.fail(jobId, error, true)must re-queue the job with backoff: the kernel has already recorded the stage asPENDINGon that promise.- Optional new methods:
defer(jobId, nextPollAt, reason, fence?)puts a claimed job backPENDINGwithout spending its attempt, which is how a host declines a job pinned to a version it does not serve (without it the host falls back tofail(..., true)and a deploy exhausts the retry budget);expireRunawayJobs(absoluteTimeoutMs)is the absolute lease tier (LEASE_ABSOLUTE_CAP; without it there is no absolute tier);adoptWorkerId(workerId)lets a host stamp its own id on the rows; and the readonly propertyfairnessGroupByis whatcreateSpillingJobTransportreads to keep a spilled payload's fairness group.
-
Custom
WorkflowPersistenceimplementation? Beyond the removals in the table below,PersistenceCoregained required methods, all inpersistenceConformanceSuite:claimUnpublishedOutboxEvents(limit)andreleaseOutboxEvents(ids)— the outbox flush claims rows (stampingpublishedAt) before it emits them.supportsDefinitionVersioning(),insertDefinitionIfAbsent(input),getDefinition(workflowId, version)andcountRunsByDefinitionVersion(filter?); an adapter without the schema returnsfalse/null/null/[].ensureDefinitionVersioningDetected()is optional.listRunsForPurge(cutoff, statuses, limit)anddeleteRun(id)forrun.purge.claimNextPendingRun({ now?, serves? }): a run is claimable only when it is pinned to one of theservespairs, or unpinned and one of the pairs names its workflow; an emptyservesclaims nothing; omitted, it is the pre-1.0 predicate. An adapter with nodefinitionVersioncolumn may ignore it.getSuspendedStages(beforeDate, { limit?, serves? })must return rows oldestnextPollAtfirst, capped atlimit, and filtered byserves(ignorable on a schema with nodefinitionVersioncolumn).WorkflowRunRecordgainsdefinitionVersion: string | nullandredriveCount: number;CreateRunInput/UpdateRunInputcarrydefinitionVersion,UpdateRunInputcarriesredriveCount, andUpdateStageInput.completedAt/duration/errorMessageacceptnullsorun.redrivecan reopen a stage record in place.RunCreateResultgainsdefinitionVersion.
-
Custom
StepLedgerimplementation?StepRecordExpectation.attemptis now optional andcompareAndSetmust match any attempt when it is omitted;clearExcept(stageRecordId, keepStepIds)is optional (without it the kernel falls back toclearand logs the external keys it drops); andStepRecordPatchhas one rule every field follows: a key that is absent, or present holdingundefined, leaves the column alone, and every other value —nullincluded — is written. A ledger that skipped nullish values records "completed with no value" as "unchanged" and replays the previous attempt's result forever.StepRecordgainsexternalKey. The newstepLedgerConformanceSuite(name, factory, api)holds a ledger to all of it. -
Running the kernel inside one Prisma transaction per tick? The 1.0 adapters no longer rely on a caught unique violation for any insert-if-absent on Postgres (
PrismaStepLedger.claim,acquireIdempotencyKeyusecreateMany({ skipDuplicates: true })+ read-back), so a replay that re-claims completed steps no longer aborts the enclosing transaction with25P02. PasscreatePrismaStepLedger(prisma, { databaseType: "sqlite" })on SQLite, which has noskipDuplicates. The run claim binds a JSDate(UTC) instead ofNOW(); passnow: () => clock.now()tocreatePrismaWorkflowPersistenceto make it follow your clock. The Postgres job lease (lockedAt,startedAt, the stale-lease sweep) runs on the database clock, soPrismaJobQueueOptions.nowno longer affects it — it still drives the SQLite dequeue and the timestamps written outside the raw statement. -
If your Prisma
Statusenum has another name, pass it:createPrismaWorkflowPersistence(prisma, { statusEnumName: "WorkflowStatus" }). The raw-SQL claim paths cast with::"Status"(since 0.11) and fail with42704 type "Status" does not existotherwise. See05-persistence-setup.md. -
Check the delegates on your
PrismaClient.EnginePrismaClientrequiresworkflowRun,workflowStage,workflowStep,workflowLog,workflowArtifact,workflowAnnotation,aICall,jobQueue,outboxEventandidempotencyKey;workflowDefinitionis optional (its absence turns definition versioning off), and$transaction,$queryRaw,$queryRawUnsafeand$executeRaware optional with guarded fallbacks. A wall ofPrismaClient is not assignable to EnginePrismaClienterrors means one of the required delegates is missing from your schema (in 1.0 almost alwaysworkflowStep) — add the model and regenerate the client.createPrismaBlobStoreneeds onlyworkflowBlob.
Required code changes
-
Remove the APIs that were deprecated for 1.0.
Removed Use instead ModelStatsTracker,ModelWithRecorderAICallLoggerrows (createPrismaAICallLogger)getModelById,getRegisteredModel,listRegisteredModels,getDefaultModel,printAvailableModelsgetModel(key),registerModels()and your own registry listingmodelSupportsBatch(key)getModel(key).supportsAsyncBatchrecordCall(modelKey, prompt, response, tokens, options)(positional)the object form recordCall({ ... })requireStageOutputctx.require("stageId")SuspendedStateSchema.apiKeyBatchOptions.apiKeydefineWorkflow({ output })nothing — the workflow output is always the last stage's output schema (or the merged object of the last parallel group) AnthropicBatchProvider& co. (already gone in 0.13)ai.batch()/ctx.step.ai.mapdefineAsyncBatchStage(root and/clientexports)defineStagewithctx.step.waitFor/ctx.step.ai.map; the async-batch mode itself (defineStage({ mode: "async-batch", checkCompletion })) still runs for hosts that keep itKernelConfig.scheduler,NoopSchedulernothing — the kernel never scheduled anything; drop the option RunCreateCommand.metadataannotationsonrun.createArtifactPersistence(saveArtifact,loadArtifact,hasArtifact,deleteArtifact,listArtifacts,getStageIdForArtifact,saveStageOutput,loadStageOutput) on theWorkflowPersistenceportthe BlobStoreport (createPrismaBlobStore); the built-in adapters keep the methods as plain class methodsgetRunsByStatus,claimPendingRun,updateStageByRunAndStageId,getStageById,getFirstSuspendedStageReadyToResume,getFirstFailedStage,getLastCompletedStage,getLastCompletedStageBeforeon the portgetStagesByRun(runId, { status, orderBy }),getStage(runId, stageId),updateStage(stage.id, ...); the built-in adapters keep the methodsJobQueue.enqueue/JobTransport.enqueueon the portsenqueueParallel([job]); the built-in queues keepenqueueConformance suites persistenceConformanceSuite(name, factory)take a third argument { describe, it, expect, beforeEach }(thetestingentry no longer imports vitest) -
Provide
step,ai,aiLoggerandabortSignalon hand-built stage contexts.StageContext.stepis required (it was optional),StageContext.abortSignal: AbortSignalis new and required, andCheckCompletionContextgainedstep,aiandaiLoggertoo. Code that builds a context by hand must supply them; a stage that never touches them still runs (the wrapper only probesctx.stepwhen present).// Before (0.13)const ctx = { input, config, require, log, storage, ... };await stage.execute(ctx);// After (1.0) — ledger-less step API + mock AI for testsimport { neverAbortingSignal } from "@bratsos/workflow-engine";import { createStepApi } from "@bratsos/workflow-engine/kernel";import { createMockAIHelperFactory, InMemoryAICallLogger } from "@bratsos/workflow-engine/testing";const aiLogger = new InMemoryAICallLogger();const ctx = {input, config, require, log, storage, ...,step: createStepApi({ clock: { now: () => new Date() } }), // throws StepLedgerNotConfiguredError if usedai: createMockAIHelperFactory()("test", aiLogger),aiLogger,abortSignal: neverAbortingSignal(), // or new AbortController().signal}; -
ctx.step.runbodies receive aStepRunContext.ctx.step.run(id, fn)now callsfn({ stepId, externalKey, attempt, isReclaim, heartbeat, abortSignal }). Zero-argument bodies are unaffected. While you are there:leaseandretryDelayare the canonicalStepRunOptionsnames (milliseconds or a duration string);leaseMsandretryDelayMsstill work as deprecated aliases and lose to the canonical name when both are given.Prefer
createTestHarness()from@bratsos/workflow-engine/testing, which builds all of it. -
Wire the kernel services.
createKerneltakesstepLedger(forctx.step) andservices(forctx.ai). Withoutservices.aiLogger,ctx.aithrowsAIServicesNotConfiguredError; withoutstepLedger,ctx.stepthrowsStepLedgerNotConfiguredError.// Before (0.13)const kernel = createKernel({ persistence, jobTransport, blobStore, eventSink, clock, registry });// After (1.0)import { createPrismaAICallLogger, createPrismaStepLedger } from "@bratsos/workflow-engine";const kernel = createKernel({persistence, jobTransport, blobStore, eventSink, clock, registry,stepLedger: createPrismaStepLedger(prisma),services: {aiLogger: createPrismaAICallLogger(prisma),// ai: (topic, logger, logContext, providerResolver, options) =>// createAIHelper(topic, logger, logContext, providerResolver, { ...options, adapter }),},});services.aiis optional; the default buildscreateAIHelperper stage. Pass your own factory to install anAIAdapter(local CLI, proxy, recorded fixtures) — it is the only way an adapter reachesctx.step.ai.createKernelalso takesspillThresholdBytes(default 64 KiB) for the step-result claim check; see15-large-payloads.md. -
Update the AI SDK peer range. 1.0 targets AI SDK 7.
ai@^7,@ai-sdk/google@^4and@openrouter/ai-sdk-provider@^3are regular dependencies of the package, so they come with it — but anyaiyour own code imports (anOutput.object(...), a hand-builtstreamText) must be on^7too, or two copies coexist. The optional peers are@ai-sdk/anthropic@>=4.0.46/@ai-sdk/openai@>=4.0.53(only if you batch natively against them; without them the helper falls back to OpenRouter with a WARN), andzod@^4.1.12/@prisma/client@>=6are unchanged.npm install ai@^7 zod@^4# optional, only what you batch againstnpm install @ai-sdk/anthropic @ai-sdk/openai -
Use the builder's inference instead of hand-typed contexts.
defineWorkflow(id, { input }).stage(id, definition)infers the context from earlier stages soctx.require()is typed anddependenciesonly accepts earlier ids..stage(prebuilt)/.pipe(prebuilt)now check a prebuilt stage's declared context against the accumulated one; a stage that requires a key no earlier stage produces no longer compiles (the parameter resolves to{ __error: "stage requires context keys not produced by earlier stages: ..." }). The object formdefineWorkflow({ id, name, description, input })still works.// Before (0.13) — context typed by hand on every stageconst summarize = defineStage<typeof In, typeof Out, typeof Cfg, { extract: ExtractOut }>({ ... });export const wf = defineWorkflow({ id: "docs", input: In, output: Out }).pipe(extract).pipe(summarize).build();// After (1.0) — inferred; `output` is gone (it is the last stage's output)export const wf = defineWorkflow("docs", { input: In }).stage("extract", { schemas: { input: In, output: ExtractOut, config: Cfg }, async execute(ctx) { ... } }).stage("summarize", {dependencies: ["extract"],schemas: { input: "none", output: Out, config: Cfg },async execute(ctx) {const { text } = ctx.require("extract"); // typed...},}).build();type Ctx = InferWorkflowContext<typeof wf>; -
ModelKeyis open. The zod schema isz.string().min(1); aschemas.configfield typed with it no longer rejects an unregistered key atrun.create. Validation happens atgetModel()— register every model you reference. -
ctx.log/ctx.onLogreturnvoid. Awaiting them still compiles; drop theawaitwhen you touch the code. -
AIStreamResult.rawResultisundefinedwhen the stream came from an adapter. Guard before reading it.
defineAsyncBatchStage → ctx.step.ai.map
defineAsyncBatchStage is not exported any more. The replacement is one linear stage body; the full before/after is in 12-durable-steps.md ("Migrating an async-batch stage to steps").
// Before (0.13)
const extract = defineAsyncBatchStage({
id: "extract", name: "Extract", schemas: { input: In, output: Out, config: Cfg },
async execute(ctx) {
const batch = ctx.ai.batch("gemini-2.5-flash");
const handle = await batch.submit(buildRequests(ctx.input.docs));
return { suspended: true, state: { batchId: handle.id, metadata: { batchRefs: handle.refs, requestIds } } };
},
async checkCompletion(state, ctx) {
const batch = ctx.ai.batch("gemini-2.5-flash");
const status = await batch.getStatus(state.batchId, state.metadata);
if (status.status !== "completed") return { ready: false };
const results = await batch.getResults(state.batchId, { ...state.metadata, schemas });
return { ready: true, output: toOutput(results) };
},
});
// After (1.0)
const extract = defineStage({
id: "extract", name: "Extract", schemas: { input: In, output: Out, config: Cfg },
async execute(ctx) {
const results = await ctx.step.ai.map("extract", ctx.input.docs, {
model: "gemini-2.5-flash",
schema: SectionSchema,
prompt: (doc) => `Extract sections from:\n${doc.text}`,
itemId: (doc) => doc.id,
policy: "auto", // batch at >= 20 items, realtime below
batch: { pollEvery: "60s", timeout: "24h", onExpiry: "fail" },
realtime: { concurrency: 8, retries: 2, retryDelayMs: "10s" },
repair: { attempts: 1 },
});
return { output: toOutput(results) };
},
});
What changes: submit happens exactly once (${id}:submit step), polling is a stored-deadline wait (${id}:poll), results are validated against schema and repaired realtime when they do not validate, a crash between submit and collect resumes from the ledger, and small inputs run realtime through the same code. Every ctx.step.* id must be stable and unique within the stage; derive itemId from data.
Adapters
AdapterObjectResponse.objectis the structured result (the 1.0.0-alpha.0 engine readoutput; fixed in alpha.1). Do not return both.- For
ctx.step.ai.maprepair to quote a bad answer back to the model, throwNoObjectGeneratedError(re-exported from@bratsos/workflow-engine) withtextset to the raw output andcauseset to the parse/validation error. Any error carryingtext(orcause.text), aZodError, or a JSONSyntaxErroris also treated as repairable. Any other thrown error consumes arealtime.retriesattempt. AdapterTextResponse.objectis whatgenerateTextwithoutputreturns asresult.output.
Behaviour changes
-
A reclaimed batch submit no longer creates a second provider batch (1.0.0-alpha.9). A worker that died between the provider accepting a
ctx.step.ai.mapbatch and the ledger recording it left the steprunning; the replay after the lease expired submitted the whole batch again, and the first was orphaned and still billed. Everyrunstep now carries a deterministic external key (workflow_steps.externalKey, written before the body runs),ctx.step.run(id, fn)passes it to the body asfn({ stepId, externalKey, attempt, isReclaim }), and the OpenAI and Google batch adapters stamp it into the provider fields they can search (metadataanddisplayName) so a reclaimed submit adopts the existing batch. Add the column (ALTER TABLE "workflow_steps" ADD COLUMN IF NOT EXISTS "externalKey" TEXT;). One behaviour change: on Anthropic and OpenRouter, which offer no searchable field, a reclaimed submit now throwsBatchNotAdoptableErrorinstead of duplicating — passbatch: { onReclaim: "resubmit" }on the map to accept the duplicate cost. A step whose body cannot be recovered at all can declarectx.step.run(id, fn, { onReclaim: "fail" }), which fails withStepNotReplaySafeErrorrather than re-executing; the default stays"rerun". -
Timestamps written by raw statements are explicitly UTC (1.0.0-alpha.8). The
FOR UPDATE SKIP LOCKEDclaim and dequeue and the outbox claim now write$n::timestamptz AT TIME ZONE 'UTC'instead of a bare boundDate, which Postgres converted through the session timezone on the way into the naivetimestampcolumns. On a non-UTC session that putjob_queue.lockedAthours in the future and stale-lease recovery never fired — a crashed worker's job stayedRUNNINGforever, in 0.13 and in the 1.0 alphas alike. No schema change is required, but check that you did not map any engine timestamp column to@db.Timestamptz: the engine's columns must stay plain PrismaDateTime(naivetimestampholding UTC), asprisma/schema.prismadeclares them. If you did map one, revert it withALTER TABLE "job_queue" ALTER COLUMN "lockedAt" TYPE timestamp(3) USING "lockedAt" AT TIME ZONE 'UTC';(same shape for the other columns). -
A racing job is re-delivered instead of discarded (1.0.0-alpha.8).
run.claimPendingenqueues a claimed run's first-stage job after the claim transaction commits, so a job loop can no longer dequeue a job whose run is stillPENDING— a race that wedged the majority of runs at a shortjobPollIntervalMsin 0.13 and in the 1.0 alphas alike.JobExecuteResultgainsghostReason("race"|"orphan"|"version") next toghost: true; the built-in hosts re-deliver a"race", defer a"version"(a run pinned to a definition version this build does not serve — throughJobTransport.deferwhen the transport has it, so no attempt is spent) and still fail an"orphan"terminally. Nothing to change unless you wrote your own host loop againstghost: it keeps working, but readghostReasonto pick the recovery up. -
run.rerunFromis deprecated forrun.redrive.run.redrive({ workflowRunId, from?, definitionVersion?, idempotencyKey? })takesfrom: { kind: "lastFailure" } | { kind: "start" } | { kind: "stage", stageId }(defaultlastFailure). ForlastFailureandstagethe resumed stage record is reopened in place — back toPENDING,attemptincremented, error/output/timings cleared — so its durable step ledger survives: everycompletedstep row is answered from the ledger, rows naming an external effect are re-opened, and waits, sleeps and failed rows without an external key are dropped throughStepLedger.clearExcept. Stages after it are archived and deleted;startreplaces everything. Every superseded record is archived first as a stage-scopedrun.supersededAttemptannotation (withabandonedStepswhen a dropped row named an external effect), anddefinitionVersion: "latest"re-pins the run onto the version this build serves — the remedy for a run stranded at a version nothing serves. The result is{ workflowRunId, fromStageId, supersededStages, redriveCount, definitionVersion }.run.rerunFromkeeps its result shape and now delegates, so it gets the same behaviour; move torun.redrivewhen you touch the call. See14-redrive.md. -
Runs can be pinned to a definition version. With the columns above,
run.createstamps each run with a content-addressed version (workflow.definitionVersion,sha256-…, or one you declare withdefineWorkflow(...).version("...")) and stores the structure as aworkflow_definitionssnapshot. Claiming, job dequeue and suspended-stage polling then take only the runs this build serves when the registry is built withcreateWorkflowRegistry(workflows); a hand-written{ getWorkflow }registry cannot enumerate and keeps the old predicate, andserves: "all"on either host restores it explicitly. Two changes in the default: an unpinned run is claimable only by a host whose served definitions name its workflow, andrun.claimPendingno longer adopts-and-fails a run whose workflow is missing from an enumerating registry — it leaves itPENDINGandrun.listVersionsreports it underunservedHere. Re-registering an explicit version with a different structure throwsDefinitionVersionConflictError. See13-definition-versioning.md. -
Retention:
run.purge. New command{ type: "run.purge", olderThan, statuses?, limit? }→{ purged, workflowRunIds }clears the step ledger, blobs and job rows and deletes the run with everything under it. Both hosts run it on the maintenance tick when givenretention: { olderThanMs, statuses?, limit? }(off by default);MaintenanceTickCountsgainspurged. -
Cancellation reaches a running body.
ctx.abortSignal(the same object asstep.abortSignalinside actx.step.runbody) is aborted from the host's job lease heartbeat, which now dispatches the newjob.heartbeatcommand: the reason is aStageAbortedErrorwithreason: "cancelled"or"lease-lost"(stageAbortReason(signal)reads it). After a"cancelled"abort arunbody that finishes is recorded asfailed, no retry is spent, andwaitForchecks the signal before it polls. A directjob.executedispatch withoutabortSignalgets a signal that never fires. -
Two-tier job lease expiry. Beside
staleLeaseThresholdMs(heartbeat lost,LEASE_HEARTBEAT_LOST, requeued) both hosts takejobAbsoluteTimeoutMs(default one hour,0disables): a job whose claim is older than that is failed terminally withLEASE_ABSOLUTE_CAP, since a worker that is alive but wedged keeps heartbeating.lease.reapStalereturns{ released, expired }andMaintenanceTickCountsgainsstaleExpired. On Postgres the lease stamps now come from the database clock. -
A failing event sink is a named state.
outbox.flushreturns{ published, failed, deadLettered, eventSinkStatus, eventSinkError? }andMaintenanceTickCountsgainseventsFailed,eventsDeadLettered,eventSinkStatusandeventSinkError;failedanddeadLetteredare disjoint. The Node host exposesgetStats().eventSink;createEventSinkMonitor()is exported for a custom loop. See03-runtime-setup.md. -
Large values spill to the blob store. A
workflow_steps.resultabovespillThresholdBytes(64 KiB by default) is written to theblobStoreand the row keeps a{ "$wfSpill": 1, key, bytes }reference, resolved before the value reaches your stage; job payloads spill only when you wrap the transport withcreateSpillingJobTransport. The blob store must therefore be shared by every process that executes or polls a run (it already had to be, for stage outputs); reading a spilled value through a different store throwsSpilledPayloadUnavailableError. See15-large-payloads.md. -
No default
temperatureis sent. 0.13 sent0ongenerateObjectand0.7ongenerateText; 1.0 sendstemperatureonly when the caller sets it, on every path (generateText,generateObject,streamText,ctx.step.ai.*,map, both batch bodies). Set it explicitly where a fixed value was relied upon. -
Suspended events are emitted once per wait. A replay no longer re-emits
stage:suspended/workflow:suspendedon every poll while the stage is waiting on the same step with the same deadline; they fire when the wait starts and when the stage moves to a different wait. -
stage.pollSuspendedclaims a stage before replaying it. A version-guardednextPollAtlease (max(pollInterval, 60s)) stops two orchestrating processes replaying the same suspended stage; a stage this build cannot serve is handed back to its existing deadline. No schema change. -
Batch results are validated and repaired. In 0.13 batch results were never validated after a resume. In 1.0 every
mapitem is validated againstschema; items that fail go through the realtime repair pass (repair.attempts, default 1), which costs a realtime call per failed item. A WARN naming the batch id, the failure class (provider error or schema validation) and the first error is logged when more than half of a batch fails, and the poll logs a WARN when the provider reports failed requests. The Google batch path sends the engine's own union-preserving conversion of the JSON Schema asresponseSchema(Gemini's batch endpoint does not honourresponseJsonSchema, and the provider's conversion drops discriminated unions). -
Structured-output schemas are made portable per target. A
z.discriminatedUnionemitsoneOf, which OpenAI's strict structured outputs (native and via OpenRouter) reject and Gemini drops. Every JSONresponseFormatsent to an OpenAI, OpenRouter or Google model —generateObject,generateText+Output.object,streamText,ctx.step.ai.*, batch bodies — is rewritten at the model boundary (oneOf→anyOf;additionalProperties: falseand every property required-but-nullable for OpenAI; az.record()sent to OpenAI as an array of{ key, value }pairs and rebuilt before validation, since strict mode has no map type); validation still runs against your Zod schema. A keyword strict mode cannot express (patternProperties,if/then/else, ...) throwsUnportableSchemaErrorbefore the request instead of a provider 400. Nothing to change unless you relied on sendingoneOf,propertyNamesor an openadditionalPropertiesverbatim to one of these providers. -
Failed steps are re-executed on the next job attempt. A retryable failure keeps the stage's ledger rows; on the retry, completed steps are replayed from the ledger while every
runstep and everymapitem that endedfailedis re-opened and executed again (so a${id}:submitthat hit a 503, or an item whose repair budget ran out, gets a fresh call). A replay of the same attempt (a poll) still answers failures from the ledger. The map's:submitstep has no retry of its own — the job's attempt budget is its retry. A reopened row keeps counting:workflow_steps.attemptis the number of executions of that step across job attempts.WorkflowStage.attemptcounts job retries as well asrun.rerunFromreruns (0 on the first execution), and a retried stage that completes — directly or after a suspension — clears itserrorMessage. -
Outbox delivery is once per outbox.
outbox.flushclaims rows (stampspublishedAt) before emitting, withFOR UPDATE SKIP LOCKEDon Postgres, so two hosts ticking the same outbox no longer both deliverworkflow:created. A customWorkflowPersistencegains two required methods,claimUnpublishedOutboxEvents(limit)andreleaseOutboxEvents(ids)(see 05-persistence-setup.md); the conformance suite exercises them. -
stage:retrying. A retried attempt emitsstage:retrying(attempt,maxAttempts,error) instead ofstage:failed;stage:failednow means the stage row isFAILED. Event consumers that alerted on everystage:failedsee one alert per terminal failure. -
Host job results carry the retry contract.
executeJobWithHeartbeat(and the serverless host'shandleJob) returnwillRetry,attempt,maxAttemptsandretryDelayMs; a push transport whosefail()cannot re-enqueue must retry the message afterretryDelayMswhenwillRetryis true and acknowledge it otherwise. Malformed job messages (nopayload, noworkflowId) are failed and acknowledged as dead jobs instead of throwing. -
Realtime map retries are in-process.
realtime.retriesre-calls the model inside the sameexecute()afterretryDelayMs, bumping the ledger row'sattempt; the stage no longer suspends and replays per failed item.ctx.step.runretries still suspend. -
The host stamps its
workerIdon the job queue (host-node 0.4.4).createNodeHost(...).start()hands itsworkerIdto the transport, sojob_queue.workerIdmatches the host instead of the queue's generatedworker-<pid>-<timestamp>. ChangecreatePrismaJobQueue(prisma, { workerId })tocreatePrismaJobQueue(prisma)under a host; a transport that keeps an explicit id gets a one-lineworkerId mismatchwarning at startup. Existing job rows are not rewritten. -
A multi-stage run is no longer pinned to one worker (host-node 0.4.4). The host that completes a job enqueues the next execution group in-process, and used to win it back straight away while every other worker was still parked in its poll timer. The job loop now pauses for a uniform draw over
[0, postJobYieldMs)after a completed job — defaultjobPollIntervalMs,0to disable — skipped while it is draining jobs from other runs. Expect a single-worker deployment's sequential pipeline to take up tojobPollIntervalMslonger per stage hand-off unless you setpostJobYieldMs: 0; see 03-runtime-setup.md. -
Hosts flush the outbox when they finish work.
NodeHost.stop()waits for the in-flight job (bounded byshutdownTimeoutMs, default 10s) and then runs a finaloutbox.flush(flushOutboxOnStop: falseopts out), soworkflow:completedfor a run finished by that process is published before it exits instead of by whichever process ticks next. The serverless host has no lifecycle, so it flushes after eachhandleJob(flushOutboxAfterJob, default true, bounded byoutboxFlushTimeoutMs).runToCompletionfrom@bratsos/workflow-engine-host-serverlessis the supported way to create a run and drive it to a terminal state inside one request; see03-runtime-setup.md. -
A failed stage transitions the run immediately on every host. With retries remaining the job is re-enqueued with backoff and the stage row is not
FAILED; with none remainingrun.transitionruns at once with the stage error on the run. -
Batch accounting rows store the item prompt, the model's reply (its raw text when it failed validation) and
metadata.batchDurationMs(the batch wall time). There is no per-rowdurationMson batch rows: providers report no per-item latency. -
Run totals on failed runs.
WorkflowRun.totalCost/totalTokensare rolled up onFAILEDruns too, and a failedgenerateObjectcall logs the tokens (and cost) itsNoObjectGeneratedErrorcarried instead of 0/0. -
Step order warnings. Each
ctx.step.*id consumes a sequence number on every replay, including items answered from the ledger. Keep the item list of amapidentical across replays (filter through an outside cache inside a step, or not at all) or every step after the map logsnon-deterministic step order.
New features
See the 1.0 changeset and 12-durable-steps.md: ctx.step.run/waitFor/waitForSignal/sleep (with heartbeat, retryBackoff, onReclaim and keepalive), ctx.step.ai.generateText/generateObject/streamText/map, createTestHarness (with harness.steps mocks, start/tickUntil and cancel; 07-testing-patterns.md), the builder inference types (InferWorkflowContext, InferWorkflowInput, InferWorkflowOutput, InferWorkflowStageIds, InferStageOutputById), AIHelperOptions.adapter, per-call timeouts (AICallTimeoutError), createKernel({ services, stepLedger, spillThresholdBytes }), the step.signal, job.heartbeat, run.redrive, run.listVersions and run.purge commands, createPrismaStepLedger / createPrismaBlobStore, workflow_engine_enqueue (sql/enqueue.sql, applied by your own migration after the tables exist; 05-persistence-setup.md), per-group dequeue fairness (createPrismaJobQueue(prisma, { fairness })), shadowRuns / shadowVersions / assertShadowCompatible in the testing entry, and the @bratsos/workflow-engine-console package (16-operational-console.md).