How OpenMAIC Handles DSL Versioning and Migration: A Deep-Dive Guide

OpenMAIC maintains two separate version lines for its DSL: the persistent document version (DSL_VERSION) and the in-memory runtime version (RUNTIME_DSL_VERSION), with pure, side-effect-free migration transforms that upgrade documents through an ordered ladder of changes.

The Domain-Specific Language (DSL) in OpenMAIC defines the shape of serialized slide contracts stored on disk and the structure of runtime session objects. Unlike typical npm packages where a single version number suffices, OpenMAIC decouples the DSL contract version from the package version. This separation allows API refactors and internal improvements without forcing document migrations.

Core Versioning Concepts

Separated Version Lines

OpenMAIC tracks two distinct DSL versions:

  • DSL_VERSION — The version of persisted slide documents, defined in packages/@openmaic/dsl/src/version.ts at line 61
  • RUNTIME_DSL_VERSION — The version of in-memory runtime session objects, defined at line 76

These constants are intentionally independent of the package.json version. A package bump from 2.1.0 to 2.2.0 might add utility functions without touching DSL_VERSION, meaning existing documents require no migration.

Envelope Keys for Version Stamps

Documents and sessions carry their version in envelope fields:

  • DSL_VERSION_KEY (value: 'dslVersion') — stamps persisted documents at line 91
  • RUNTIME_DSL_VERSION_KEY (value: 'runtimeDslVersion') — stamps runtime sessions at line 108

These keys enable the runtime to inspect any object and determine its DSL lineage without parsing the full payload.

The Migration Ladder Architecture

Ordered Transform Arrays

Migrations are declared as pure functions in ordered arrays:

  • DSL_MIGRATIONS — transforms for persisted documents, defined at line 235
  • RUNTIME_DSL_MIGRATIONS — transforms for runtime sessions

Each migration step specifies from version, to version, and a migrate function. The ladder is append-only; new versions extend the array rather than modifying existing entries.

Key Properties of Migration Functions

Every transform in the ladder must be:

  • Pure — no side effects, returns a new object
  • Idempotent — running twice produces the same result
  • Forward-compatible — handles documents already at or beyond its target version

These constraints allow OpenMAIC to safely re-migrate documents during import, sync, or recovery operations.

Version Inspection and Migration Helpers

Reading Versions Safely

The dslVersionOf function at line 407 and runtimeDslVersionOf at line 423 enforce well-formedness during version extraction:

  • Handle legacy documents lacking any version stamp (unversioned epoch)
  • Reject ambiguous cross-line stamps (a document carrying runtimeDslVersion but not dslVersion)
  • Return the current version constant for valid, stamped objects

Runtime sessions have no unversioned epoch — an unstamped runtime object triggers a no-runtime-epoch error, indicating either a mis-routed legacy document or a producer bug.

Detecting Migration Needs

The predicate functions needsMigration (line 507) and needsRuntimeMigration (line 532) compare an object's version against the current constants. They share validation logic with the migrators, ensuring consistent error handling.

Executing Migrations

The migrate function at line 640 and migrateRuntime at line 670 implement the migration runner:

  1. Look up the current version in the ladder
  2. Apply each sequential migrate function
  3. Stamp the new version after each step
  4. Repeat until reaching DSL_VERSION or RUNTIME_DSL_VERSION

If the document is newer than the target version, it returns unchanged. If a migration path is missing or the envelope is malformed, the runner fails loudly rather than silently corrupting data.

Practical DSL Migration Examples

Detecting and Migrating Persisted Slides

import { dslVersionOf, needsMigration, migrate } from '@openmaic/dsl';

// `rawDoc` is the JSON object read from storage
const rawDoc: unknown = fetchFromDatabase();

console.log('Current DSL stamp:', dslVersionOf(rawDoc));

if (needsMigration(rawDoc)) {
  const migrated = migrate(rawDoc);
  console.log('Migrated to', dslVersionOf(migrated));
  // Persist `migrated` back to storage
}

Working with Runtime Sessions

import {
  runtimeDslVersionOf,
  needsRuntimeMigration,
  migrateRuntime,
} from '@openmaic/dsl';

const session: unknown = getRuntimeSession();

console.log('Runtime DSL stamp:', runtimeDslVersionOf(session));

if (needsRuntimeMigration(session)) {
  const upgraded = migrateRuntime(session);
  console.log('Session upgraded to', runtimeDslVersionOf(upgraded));
  // Continue using `upgraded` (e.g., feed it to the agent)
}

Adding a New Migration Step

// 1. Implement a pure transform function:
function addNewField(doc: unknown): unknown {
  if (typeof doc === 'object' && doc !== null) {
    return { ...doc, newField: 'defaultValue' };
  }
  return doc;
}

// 2. Append it to the ladder (in `version.ts`):
export const DSL_MIGRATIONS = [
  ...previousMigrations,
  { from: '0.3.0', to: '0.4.0', migrate: addNewField },
] as const;

// 3. Bump `DSL_VERSION` to `'0.4.0'` and increase the npm package version
//    according to the caret-escape rule (minor → major at 1.0.0 boundary).

Release Discipline and Breaking Changes

The Caret-Escape Rule

Changing DSL_VERSION or RUNTIME_DSL_VERSION must accompany a package version bump that breaks dependents' caret ranges. This rule, enforced by scripts/check-package-version-bumps.mjs, ensures downstream packages (@openmaic/storage, @openmaic/renderer, @openmaic/importer) cannot silently consume incompatible DSL shapes.

For example, if DSL_VERSION increments from 0.3.0 to 0.4.0 while the package is at 1.2.3, the new package version becomes 2.0.0. Caret ranges (^1.2.3) will not match 2.0.0, forcing explicit upgrades.

Summary

  • Separate version lines — DSL_VERSION for persisted documents, RUNTIME_DSL_VERSION for sessions, both independent of npm package version
  • Migration ladders — ordered arrays of pure, idempotent transforms in DSL_MIGRATIONS and RUNTIME_DSL_MIGRATIONS
  • Strict validation — dslVersionOf and runtimeDslVersionOf enforce envelope well-formedness and reject cross-line contamination
  • Loud failures — missing migration paths or malformed envelopes throw rather than corrupt
  • Caret-escape releases — DSL version changes force breaking package bumps to protect downstream consumers

Frequently Asked Questions

What happens if I try to migrate a runtime session using the document migrator?

The version inspection functions enforce cross-line safety. If an object carries runtimeDslVersion but not dslVersion, dslVersionOf throws a cross-line error. This prevents accidental corruption by ensuring document and runtime migrations stay strictly separated.

Can I skip migration steps in the ladder?

No. The migrate and migrateRuntime runners walk the ladder sequentially from the object's current version to the target. Each step must exist; if a version jump lacks an intermediate transform, the runner fails loudly. This design guarantees every schema change is explicitly handled.

Why does the runtime version have no unversioned epoch?

Persisted documents may originate from early prototypes before versioning existed, so dslVersionOf handles the unversioned epoch gracefully. Runtime sessions are always produced by code that should stamp them correctly; an unstamped runtime object indicates a bug or mis-routing that should surface immediately rather than be silently patched.

How do I verify a migration won't break existing documents?

The test suite in tests/document-store/migration.test.ts exercises migration paths with fixtures representing each historical DSL version. Before releasing a new migration, add a fixture at the previous version and assert it upgrades correctly to the new DSL_VERSION.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →