@sublang/playbook 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,14 +70,15 @@ playbook run @sublang/playbook/code/registry "add a test for parseArgs" --json
70
70
  - **[docs/embedding.md](docs/embedding.md)** — the six-port runtime
71
71
  contract for hosts other than `tmux-play`.
72
72
 
73
- > **Current release:** 3.0.0. The composed system — the compiled default
73
+ > **Current release:** 3.1.0. The composed system — the compiled default
74
74
  > Captain, CODE and DISCUSS, nested playbook calls, script actors and the
75
75
  > GEARS optimize pass, the semver-stable six-port runtime contract, and
76
76
  > non-interactive `playbook run` with parked-session resume — landed in
77
77
  > 1.0.0. Since then, `playbook run` gained defaults in the user config,
78
- > and 3.0.0 replaces the top-level `profiles` map with
79
- > inline agent settings (existing configs migrate themselves on the next
80
- > launch). See the [CHANGELOG](CHANGELOG.md).
78
+ > 3.0.0 replaced the top-level `profiles` map with inline agent settings
79
+ > (existing configs migrate themselves on the next launch), and 3.1.0
80
+ > added the linked-artifact/engine compatibility check. See the
81
+ > [CHANGELOG](CHANGELOG.md).
81
82
 
82
83
  ## How it compiles
83
84
 
package/docs/cli.md CHANGED
@@ -67,6 +67,7 @@ intents the same way you would to `claude -p` or `codex exec`.
67
67
  | `--option <key>=<value>` | a playbook option (CODE's `committer`) |
68
68
  | `--cwd <dir>` | the agents' working directory |
69
69
  | `--json` | one envelope: `outcome`, `sessionId`, output or questions |
70
+ | `--no-provision` | never create engine links beside a filesystem `<from>` |
70
71
 
71
72
  `<agent>` is `<adapter>[:<model>][@<effort>]` — `codex:gpt-5.5@xhigh`,
72
73
  or `claude@high` for the default model at high reasoning effort. The
@@ -79,6 +80,26 @@ Exit codes: `0` terminal, `1` bad argument or module, `2` failure, `3`
79
80
  the playbook needs a Boss reply
80
81
  ([PBCLI-18](../specs/user/playbook-cli.md#pbcli-18)).
81
82
 
83
+ ### Engine provisioning
84
+
85
+ A compiled playbook module imports `xstate` and
86
+ `@sublang/playbook/xstate-runtime` from its own directory. When a
87
+ filesystem `<from>` cannot resolve them — typically under a global
88
+ `npm install -g @sublang/playbook` with no project-local packages —
89
+ `playbook run` provisions them automatically before loading: it creates
90
+ `node_modules/xstate` and `node_modules/@sublang/playbook` beside the
91
+ module as symlinks to the running host's own packages and prints one
92
+ line naming what it linked
93
+ ([PBCLI-36](../specs/user/playbook-cli.md#pbcli-36),
94
+ [DR-024](../specs/decisions/024-runtime-engine-provisioning.md)).
95
+ A directory where the imports already resolve is never touched — a
96
+ project-local install always wins — and `--no-provision` disables the
97
+ mechanism entirely.
98
+
99
+ If the module's directory is a git repository, add `node_modules/` to
100
+ its `.gitignore` so the provisioned links never land in commits made by
101
+ player agents working there.
102
+
82
103
  ### Resuming a parked run
83
104
 
84
105
  When the playbook stops to ask something, the run is parked, not lost:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "description": "Composable XState v5 playbook runtime with compiled Captain, CODE, and DISCUSS workflows driven by GEARS specs.",
6
6
  "license": "Apache-2.0",
@@ -67,6 +67,7 @@
67
67
  "reference/sdlc/code.playbook/playbook.config.template.yaml",
68
68
  "reference/sdlc/code.playbook/bin/playbook.js",
69
69
  "reference/sdlc/code.playbook/bin/run.js",
70
+ "reference/sdlc/code.playbook/bin/provision.js",
70
71
  "reference/sdlc/discuss.playbook/discuss.gears.md",
71
72
  "reference/sdlc/discuss.playbook/discuss.fsm.ts",
72
73
  "reference/sdlc/discuss.playbook/discuss.fsm.js",
@@ -67,6 +67,7 @@ export async function runPlaybookCli(options = {}) {
67
67
  ...(options.createAgent ? { createAgent: options.createAgent } : {}),
68
68
  ...(options.readStdin ? { readStdin: options.readStdin } : {}),
69
69
  ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
70
+ ...(options.hostRoots ? { hostRoots: options.hostRoots } : {}),
70
71
  });
71
72
  }
72
73
 
@@ -0,0 +1,228 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ // PBCLI-36/37 (DR-024): probe-first engine provisioning for filesystem
5
+ // registry modules. A compiled thin artifact imports `xstate` and
6
+ // `@sublang/playbook/xstate-runtime`, which Node resolves by walking up
7
+ // from the artifact's own directory; a globally installed host therefore
8
+ // fails at artifact load in a bare directory. Before importing such a
9
+ // module, `playbook run` probes both specifiers with the module's path as
10
+ // resolution parent and, only when a probe fails, symlinks the running
11
+ // host's own installed package roots beside the module. It never shells
12
+ // out to `npm link` and never installs from the registry.
13
+
14
+ import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs';
15
+ import { mkdir, symlink, unlink } from 'node:fs/promises';
16
+ import { createRequire } from 'node:module';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ // PBCLI-37: probe specifier → the package name provisioning may link.
21
+ const ENGINE_LINKS = new Map([
22
+ ['xstate', 'xstate'],
23
+ ['@sublang/playbook/xstate-runtime', '@sublang/playbook'],
24
+ ]);
25
+
26
+ const DEPENDENCY_FIELDS = [
27
+ 'dependencies',
28
+ 'devDependencies',
29
+ 'peerDependencies',
30
+ 'optionalDependencies',
31
+ ];
32
+
33
+ // PBCLI-37: package names whose probes fail with the module as resolution
34
+ // parent. `createRequire` follows the same walk-up Node uses for the
35
+ // module's own imports, so an empty result means a project-local (or
36
+ // already provisioned) engine wins and provisioning must touch nothing.
37
+ function missingEngineLinks(modulePath) {
38
+ const req = createRequire(modulePath);
39
+ const missing = [];
40
+ for (const [specifier, name] of ENGINE_LINKS) {
41
+ try {
42
+ req.resolve(specifier);
43
+ } catch {
44
+ missing.push(name);
45
+ }
46
+ }
47
+ return missing;
48
+ }
49
+
50
+ // PBCLI-36: a manifest at or above the module declaring @sublang/playbook
51
+ // means a project chose a dependency and its install is broken or absent;
52
+ // shadow-provisioning would mask the real fix.
53
+ function declaringManifest(startDir) {
54
+ for (let dir = startDir; ; ) {
55
+ const manifestPath = join(dir, 'package.json');
56
+ if (existsSync(manifestPath)) {
57
+ try {
58
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
59
+ for (const field of DEPENDENCY_FIELDS) {
60
+ const block = manifest?.[field];
61
+ if (
62
+ block !== null &&
63
+ typeof block === 'object' &&
64
+ Object.prototype.hasOwnProperty.call(block, '@sublang/playbook')
65
+ ) {
66
+ return manifestPath;
67
+ }
68
+ }
69
+ } catch {
70
+ // An unreadable manifest cannot declare the dependency.
71
+ }
72
+ }
73
+ const parent = dirname(dir);
74
+ if (parent === dir) return undefined;
75
+ dir = parent;
76
+ }
77
+ }
78
+
79
+ function packageRootUpward(startDir, name) {
80
+ for (let dir = startDir; ; ) {
81
+ const manifestPath = join(dir, 'package.json');
82
+ if (existsSync(manifestPath)) {
83
+ try {
84
+ if (JSON.parse(readFileSync(manifestPath, 'utf8')).name === name) {
85
+ return dir;
86
+ }
87
+ } catch {
88
+ // Keep walking past an unreadable manifest.
89
+ }
90
+ }
91
+ const parent = dirname(dir);
92
+ if (parent === dir) return undefined;
93
+ dir = parent;
94
+ }
95
+ }
96
+
97
+ // PBCLI-37: the running host's own installed package roots, resolved from
98
+ // the host's module scope — this file lives inside @sublang/playbook, and
99
+ // xstate resolves from that root's own dependency tree.
100
+ function defaultHostRoots() {
101
+ const here = dirname(fileURLToPath(import.meta.url));
102
+ const playbookRoot = packageRootUpward(here, '@sublang/playbook');
103
+ if (playbookRoot === undefined) {
104
+ throw new Error(
105
+ 'cannot locate the running @sublang/playbook package root for provisioning',
106
+ );
107
+ }
108
+ const req = createRequire(join(playbookRoot, 'package.json'));
109
+ let xstateRoot;
110
+ try {
111
+ xstateRoot = packageRootUpward(dirname(req.resolve('xstate')), 'xstate');
112
+ } catch {
113
+ xstateRoot = undefined;
114
+ }
115
+ if (xstateRoot === undefined) {
116
+ throw new Error(
117
+ "cannot locate the running host's own xstate package for provisioning",
118
+ );
119
+ }
120
+ return { xstate: xstateRoot, '@sublang/playbook': playbookRoot };
121
+ }
122
+
123
+ // 'absent' | 'dangling' | 'live' (a symlink with an existing target) |
124
+ // 'occupied' (a real file or directory, never removed).
125
+ function linkState(linkPath) {
126
+ let stat;
127
+ try {
128
+ stat = lstatSync(linkPath);
129
+ } catch {
130
+ return 'absent';
131
+ }
132
+ if (!stat.isSymbolicLink()) return 'occupied';
133
+ return existsSync(linkPath) ? 'live' : 'dangling';
134
+ }
135
+
136
+ // PBCLI-36/37: probe, then provision the missing engine links beside a
137
+ // filesystem registry module. Returns {} when the run may proceed (either
138
+ // nothing was needed or links were created and logged) or { code: 1 }
139
+ // after writing one `playbook run: <message>` diagnostic to stderr.
140
+ export async function provisionEngine({
141
+ modulePath,
142
+ stderr,
143
+ enabled = true,
144
+ hostRoots,
145
+ }) {
146
+ const missing = missingEngineLinks(modulePath);
147
+ if (missing.length === 0) return {};
148
+
149
+ const moduleDir = dirname(modulePath);
150
+ if (!enabled) {
151
+ // PBCLI-36: --no-provision still owes the dangling-link diagnostic —
152
+ // a stale link we (or a prior host) created must not surface as a raw
153
+ // module-not-found error.
154
+ for (const name of missing) {
155
+ const linkPath = join(moduleDir, 'node_modules', name);
156
+ if (linkState(linkPath) === 'dangling') {
157
+ stderr.write(
158
+ `playbook run: ${linkPath} is a stale engine link to missing ` +
159
+ `${readlinkSync(linkPath)}; rerun without --no-provision to relink\n`,
160
+ );
161
+ return { code: 1 };
162
+ }
163
+ }
164
+ return {};
165
+ }
166
+
167
+ const manifestPath = declaringManifest(moduleDir);
168
+ if (manifestPath !== undefined) {
169
+ stderr.write(
170
+ `playbook run: ${manifestPath} declares @sublang/playbook; ` +
171
+ 'provisioning would shadow the project install — run the ' +
172
+ "project's dependency install (e.g. npm install) instead\n",
173
+ );
174
+ return { code: 1 };
175
+ }
176
+
177
+ let roots;
178
+ try {
179
+ roots = hostRoots ?? defaultHostRoots();
180
+ } catch (error) {
181
+ stderr.write(
182
+ `playbook run: ${error instanceof Error ? error.message : String(error)}\n`,
183
+ );
184
+ return { code: 1 };
185
+ }
186
+
187
+ // PBCLI-37: validate every destination before mutating any, so an
188
+ // occupied-path refusal leaves the module directory unchanged rather
189
+ // than half-provisioned.
190
+ const plans = [];
191
+ for (const name of missing) {
192
+ const linkPath = join(moduleDir, 'node_modules', name);
193
+ const state = linkState(linkPath);
194
+ if (state === 'occupied' || state === 'live') {
195
+ // A live-but-unresolvable link is as foreign as a real directory:
196
+ // neither is a link this host may replace.
197
+ stderr.write(
198
+ `playbook run: cannot provision ${linkPath}: the path is already ` +
199
+ `occupied${state === 'live' ? ' by a foreign symbolic link' : ''}\n`,
200
+ );
201
+ return { code: 1 };
202
+ }
203
+ plans.push({
204
+ linkPath,
205
+ target: roots[name],
206
+ dangling: state === 'dangling',
207
+ });
208
+ }
209
+
210
+ const created = [];
211
+ try {
212
+ for (const { linkPath, target, dangling } of plans) {
213
+ if (dangling) await unlink(linkPath);
214
+ await mkdir(dirname(linkPath), { recursive: true });
215
+ await symlink(target, linkPath, 'dir');
216
+ created.push(`${linkPath} -> ${target}`);
217
+ }
218
+ } catch (error) {
219
+ // PBCLI-37: a filesystem failure is a load fault, not a raw crash.
220
+ stderr.write(
221
+ 'playbook run: cannot provision engine links: ' +
222
+ `${error instanceof Error ? error.message : String(error)}\n`,
223
+ );
224
+ return { code: 1 };
225
+ }
226
+ stderr.write(`playbook run: provisioned ${created.join(', ')}\n`);
227
+ return {};
228
+ }
@@ -21,7 +21,7 @@ import {
21
21
  } from 'node:fs/promises';
22
22
  import { homedir } from 'node:os';
23
23
  import { isAbsolute, join, resolve } from 'node:path';
24
- import { pathToFileURL } from 'node:url';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
25
  import {
26
26
  Cligent,
27
27
  isEffortSupported,
@@ -29,6 +29,7 @@ import {
29
29
  } from '@sublang/cligent';
30
30
  import { parse as parseYaml } from 'yaml';
31
31
  import { hiddenControlEnvelope } from '../../../../src/xstate-runtime.js';
32
+ import { provisionEngine } from './provision.js';
32
33
 
33
34
  // PBCLI-19: adapter shorthands the run host can construct.
34
35
  const ADAPTER_LOADERS = {
@@ -76,6 +77,9 @@ export async function runPlaybookRun(options = {}) {
76
77
  readStdin,
77
78
  sessionsDir,
78
79
  userConfigPath,
80
+ // PBCLI-37: injected host package roots let tests provision against
81
+ // synthetic trees, like the injected session store.
82
+ hostRoots: options.hostRoots,
79
83
  };
80
84
 
81
85
  let args;
@@ -101,6 +105,11 @@ async function runFirst(args, ctx) {
101
105
  return { code: EXIT.arg };
102
106
  }
103
107
 
108
+ // PBCLI-36/37 (DR-024): provision engine links for a filesystem module
109
+ // before importing it; a resolvable engine is never touched.
110
+ const provisioned = await maybeProvision(args.from, args, ctx);
111
+ if (provisioned.code !== undefined) return provisioned;
112
+
104
113
  const loaded = await loadRegistryEntry(args.from, ctx);
105
114
  if (loaded.code !== undefined) return loaded;
106
115
  const { entry } = loaded;
@@ -246,6 +255,11 @@ async function runResume(args, ctx) {
246
255
  return { code: EXIT.arg };
247
256
  }
248
257
 
258
+ // PBCLI-37: a stored filesystem `from` (a file: URL) is probed and
259
+ // provisioned on resume exactly as on a first run.
260
+ const provisioned = await maybeProvision(record.from, args, ctx);
261
+ if (provisioned.code !== undefined) return provisioned;
262
+
249
263
  const loaded = await loadRegistryEntry(record.from, ctx);
250
264
  if (loaded.code !== undefined) return loaded;
251
265
  const { entry } = loaded;
@@ -876,6 +890,7 @@ export function parseRunArgs(argv) {
876
890
  cwd: undefined,
877
891
  json: false,
878
892
  verbose: false,
893
+ noProvision: false,
879
894
  help: false,
880
895
  };
881
896
  const positionals = [];
@@ -884,6 +899,7 @@ export function parseRunArgs(argv) {
884
899
  if (arg === '--help' || arg === '-h') args.help = true;
885
900
  else if (arg === '--json') args.json = true;
886
901
  else if (arg === '--verbose') args.verbose = true;
902
+ else if (arg === '--no-provision') args.noProvision = true;
887
903
  else if (arg === '--last') args.last = true;
888
904
  else if (arg === '--cwd') args.cwd = takeValue(argv, (i += 1), '--cwd');
889
905
  else if (arg === '--captain')
@@ -961,6 +977,36 @@ function registryImportSpecifier(specifier, cwd) {
961
977
  return specifier;
962
978
  }
963
979
 
980
+ // PBCLI-37: the absolute file path of a filesystem `<from>` (path or
981
+ // file: URL), or undefined for a bare package specifier — those resolve
982
+ // from the host's own module tree and are neither probed nor provisioned.
983
+ function moduleFilePath(specifier, cwd) {
984
+ if (specifier.startsWith('file:')) return fileURLToPath(specifier);
985
+ if (
986
+ isAbsolute(specifier) ||
987
+ specifier.startsWith('./') ||
988
+ specifier.startsWith('../') ||
989
+ specifier.startsWith('.\\') ||
990
+ specifier.startsWith('..\\')
991
+ ) {
992
+ return resolve(cwd, specifier);
993
+ }
994
+ return undefined;
995
+ }
996
+
997
+ // PBCLI-36/37: probe-and-provision for a filesystem registry module.
998
+ // Returns {} to proceed or { code } after a reported provisioning fault.
999
+ async function maybeProvision(specifier, args, ctx) {
1000
+ const modulePath = moduleFilePath(specifier, ctx.cwdDefault);
1001
+ if (modulePath === undefined) return {};
1002
+ return provisionEngine({
1003
+ modulePath,
1004
+ stderr: ctx.stderr,
1005
+ enabled: !args.noProvision,
1006
+ hostRoots: ctx.hostRoots,
1007
+ });
1008
+ }
1009
+
964
1010
  function isValidRegistryEntry(value) {
965
1011
  return (
966
1012
  typeof value === 'object' &&
@@ -1000,6 +1046,8 @@ function runHelpText() {
1000
1046
  ' output or questions) instead of plain text',
1001
1047
  ' --last resume the most recently parked session',
1002
1048
  ' --verbose forward telemetry topics to stderr',
1049
+ ' --no-provision never create engine links beside a',
1050
+ ' filesystem <from> module',
1003
1051
  ' -h, --help print this help',
1004
1052
  '',
1005
1053
  ' <agent> is <adapter>[:<model>][@<effort>] over the shorthands',
package/slc/link.md CHANGED
@@ -1335,6 +1335,20 @@ The emitted module:
1335
1335
  type; supplying one is a construction error, so a linker that judges a
1336
1336
  runtime-owned arm to have lost payload detail under erasure shall report
1337
1337
  that gap rather than emit the entry.
1338
+ - Supplies `spec.compat` with the compatibility values current at link time:
1339
+ `{ artifactSchema, runtimeAbi }`, where `artifactSchema` is `1` — the
1340
+ schema number of the thin-module format this §Output defines — and
1341
+ `runtimeAbi` is the installed shared engine's `RUNTIME_ABI` self-report.
1342
+ The linker shall verify that the installed engine lists the emitted
1343
+ schema in `SUPPORTED_ARTIFACT_SCHEMAS` and treat its absence as a
1344
+ link-time error; it shall not stamp a different member (such as the
1345
+ highest) merely because that engine also supports a newer artifact
1346
+ format — the declaration names the format of the emitted module, not
1347
+ the capability of the emitting engine. The factory checks the
1348
+ declaration against the engine instance that actually loads the emitted
1349
+ module and fails construction on a mismatch, so an artifact linked under
1350
+ one engine cannot run silently skewed under another. Modules emitted
1351
+ before this contract carry no `compat` member and remain loadable.
1338
1352
  - Default-exports the factory call as `createPlaybookRuntime`, typed
1339
1353
  `PlaybookRuntimeFactory<PlaybookRuntimeOptions>`.
1340
1354
  - Exposes, under an `_internal` export, the pure helpers verification
@@ -70,9 +70,26 @@ export declare const BOSS_REPLY_ERRORS: {
70
70
  readonly missingQuestion: "needsBossReply outcome missing 'question' field";
71
71
  readonly unregisteredState: (stateId: string) => string;
72
72
  };
73
+ /** The runtime ABI this engine implements (DR-022). */
74
+ export declare const RUNTIME_ABI = 1;
75
+ /** The linked-artifact schema versions this engine accepts (DR-022). */
76
+ export declare const SUPPORTED_ARTIFACT_SCHEMAS: readonly number[];
77
+ /** A linked artifact's declared link-time compatibility values (DR-022). */
78
+ export interface XStatePlaybookRuntimeCompat {
79
+ /** The artifact schema version the linker emitted. */
80
+ artifactSchema: number;
81
+ /** The engine ABI the artifact was linked against. */
82
+ runtimeAbi: number;
83
+ }
73
84
  export interface XStatePlaybookRuntimeSpec<TOptions> {
74
85
  /** Diagnostic label used in internal invariant errors. Default 'playbook'. */
75
86
  label?: string;
87
+ /**
88
+ * Link-time compatibility declaration checked at construction against the
89
+ * loaded engine's self-report (DR-022). Absent: a legacy artifact emitted
90
+ * before the contract — constructed with no compatibility check.
91
+ */
92
+ compat?: XStatePlaybookRuntimeCompat;
76
93
  /** Validate and JSON-snapshot the caller's per-run options. */
77
94
  snapshotOptions: (value: unknown) => TOptions;
78
95
  /** Derive the FSM machine input from validated options. Default: identity. */
@@ -42,6 +42,48 @@ function isFsmResultFailure(error) {
42
42
  fsmResultFailures.has(error));
43
43
  }
44
44
  // ---------------------------------------------------------------------------
45
+ // DR-022: the engine's compatibility self-report. A linked thin module
46
+ // records the values current at link time in `spec.compat`; the factory
47
+ // checks that declaration against this very module — the engine instance
48
+ // that will interpret the FSM, so the check can never consult a different
49
+ // engine copy than the one executing — and fails construction on a mismatch
50
+ // instead of misbehaving deep in a session. Raising RUNTIME_ABI or removing
51
+ // a member of SUPPORTED_ARTIFACT_SCHEMAS is a breaking change (RELEASE-15).
52
+ // ---------------------------------------------------------------------------
53
+ /** The runtime ABI this engine implements (DR-022). */
54
+ export const RUNTIME_ABI = 1;
55
+ /** The linked-artifact schema versions this engine accepts (DR-022). */
56
+ export const SUPPORTED_ARTIFACT_SCHEMAS = Object.freeze([
57
+ 1,
58
+ ]);
59
+ // PBRT-50: validate a declaration against the loaded engine, schema first,
60
+ // so one clear diagnostic covers a fully skewed artifact. Absent means a
61
+ // legacy artifact emitted before the DR-022 contract; those must keep
62
+ // loading unchanged (DR-019 §4), so there is nothing to check.
63
+ function assertRuntimeCompat(compat, label) {
64
+ if (compat === undefined)
65
+ return;
66
+ if (compat === null || typeof compat !== 'object') {
67
+ throw new TypeError(`${label} spec.compat must be an object`);
68
+ }
69
+ const { artifactSchema, runtimeAbi } = compat;
70
+ if (!Number.isSafeInteger(artifactSchema)) {
71
+ throw new TypeError(`${label} spec.compat.artifactSchema must be an integer`);
72
+ }
73
+ if (!Number.isSafeInteger(runtimeAbi)) {
74
+ throw new TypeError(`${label} spec.compat.runtimeAbi must be an integer`);
75
+ }
76
+ if (!SUPPORTED_ARTIFACT_SCHEMAS.includes(artifactSchema)) {
77
+ throw new TypeError(`${label} artifact declares schema ${artifactSchema}, but this ` +
78
+ `@sublang/playbook/xstate-runtime engine supports ` +
79
+ `[${SUPPORTED_ARTIFACT_SCHEMAS.join(', ')}]`);
80
+ }
81
+ if (runtimeAbi !== RUNTIME_ABI) {
82
+ throw new TypeError(`${label} artifact declares runtime ABI ${runtimeAbi}, but this ` +
83
+ `@sublang/playbook/xstate-runtime engine implements ${RUNTIME_ABI}`);
84
+ }
85
+ }
86
+ // ---------------------------------------------------------------------------
45
87
  // Tolerant judge-JSON recovery (slc/link.md §Boss-event mapping).
46
88
  // ---------------------------------------------------------------------------
47
89
  function isPlainObject(value) {
@@ -857,6 +899,9 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
857
899
  */
858
900
  export function createXStatePlaybookRuntime(machine, spec) {
859
901
  const label = spec.label ?? 'playbook';
902
+ // DR-022 / PBRT-50: reject an incompatible artifact declaration before any
903
+ // machine interpretation, against this loaded engine's own self-report.
904
+ assertRuntimeCompat(spec.compat, label);
860
905
  const declaredActors = collectInvokeSources(machine);
861
906
  const resumableStateIds = spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
862
907
  const resolvePlayerIdSpec = spec.resolvePlayerId;
@@ -177,18 +177,86 @@ function isFsmResultFailure(error: unknown): boolean {
177
177
  );
178
178
  }
179
179
 
180
+ // ---------------------------------------------------------------------------
181
+ // DR-022: the engine's compatibility self-report. A linked thin module
182
+ // records the values current at link time in `spec.compat`; the factory
183
+ // checks that declaration against this very module — the engine instance
184
+ // that will interpret the FSM, so the check can never consult a different
185
+ // engine copy than the one executing — and fails construction on a mismatch
186
+ // instead of misbehaving deep in a session. Raising RUNTIME_ABI or removing
187
+ // a member of SUPPORTED_ARTIFACT_SCHEMAS is a breaking change (RELEASE-15).
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /** The runtime ABI this engine implements (DR-022). */
191
+ export const RUNTIME_ABI = 1;
192
+
193
+ /** The linked-artifact schema versions this engine accepts (DR-022). */
194
+ export const SUPPORTED_ARTIFACT_SCHEMAS: readonly number[] = Object.freeze([
195
+ 1,
196
+ ]);
197
+
198
+ /** A linked artifact's declared link-time compatibility values (DR-022). */
199
+ export interface XStatePlaybookRuntimeCompat {
200
+ /** The artifact schema version the linker emitted. */
201
+ artifactSchema: number;
202
+ /** The engine ABI the artifact was linked against. */
203
+ runtimeAbi: number;
204
+ }
205
+
206
+ // PBRT-50: validate a declaration against the loaded engine, schema first,
207
+ // so one clear diagnostic covers a fully skewed artifact. Absent means a
208
+ // legacy artifact emitted before the DR-022 contract; those must keep
209
+ // loading unchanged (DR-019 §4), so there is nothing to check.
210
+ function assertRuntimeCompat(
211
+ compat: XStatePlaybookRuntimeCompat | undefined,
212
+ label: string,
213
+ ): void {
214
+ if (compat === undefined) return;
215
+ if (compat === null || typeof compat !== 'object') {
216
+ throw new TypeError(`${label} spec.compat must be an object`);
217
+ }
218
+ const { artifactSchema, runtimeAbi } = compat;
219
+ if (!Number.isSafeInteger(artifactSchema)) {
220
+ throw new TypeError(
221
+ `${label} spec.compat.artifactSchema must be an integer`,
222
+ );
223
+ }
224
+ if (!Number.isSafeInteger(runtimeAbi)) {
225
+ throw new TypeError(`${label} spec.compat.runtimeAbi must be an integer`);
226
+ }
227
+ if (!SUPPORTED_ARTIFACT_SCHEMAS.includes(artifactSchema)) {
228
+ throw new TypeError(
229
+ `${label} artifact declares schema ${artifactSchema}, but this ` +
230
+ `@sublang/playbook/xstate-runtime engine supports ` +
231
+ `[${SUPPORTED_ARTIFACT_SCHEMAS.join(', ')}]`,
232
+ );
233
+ }
234
+ if (runtimeAbi !== RUNTIME_ABI) {
235
+ throw new TypeError(
236
+ `${label} artifact declares runtime ABI ${runtimeAbi}, but this ` +
237
+ `@sublang/playbook/xstate-runtime engine implements ${RUNTIME_ABI}`,
238
+ );
239
+ }
240
+ }
241
+
180
242
  // ---------------------------------------------------------------------------
181
243
  // The per-workflow spec. Every strategy member has a generic default derived
182
244
  // from the FSM artifact's own data, so a linker-emitted thin module normally
183
- // supplies only `snapshotOptions` and, where applicable, `entryEvent`, erased
184
- // Boss-event field metadata, placeholder exceptions, and transition-event
185
- // fields. Hand-maintained artifacts may override any member to preserve their
186
- // existing observable behavior exactly.
245
+ // supplies only `snapshotOptions` and, where applicable, `compat`,
246
+ // `entryEvent`, erased Boss-event field metadata, placeholder exceptions, and
247
+ // transition-event fields. Hand-maintained artifacts may override any member
248
+ // to preserve their existing observable behavior exactly.
187
249
  // ---------------------------------------------------------------------------
188
250
 
189
251
  export interface XStatePlaybookRuntimeSpec<TOptions> {
190
252
  /** Diagnostic label used in internal invariant errors. Default 'playbook'. */
191
253
  label?: string;
254
+ /**
255
+ * Link-time compatibility declaration checked at construction against the
256
+ * loaded engine's self-report (DR-022). Absent: a legacy artifact emitted
257
+ * before the contract — constructed with no compatibility check.
258
+ */
259
+ compat?: XStatePlaybookRuntimeCompat;
192
260
  /** Validate and JSON-snapshot the caller's per-run options. */
193
261
  snapshotOptions: (value: unknown) => TOptions;
194
262
  /** Derive the FSM machine input from validated options. Default: identity. */
@@ -1337,6 +1405,9 @@ export function createXStatePlaybookRuntime<TOptions>(
1337
1405
  spec: XStatePlaybookRuntimeSpec<TOptions>,
1338
1406
  ): PlaybookRuntimeFactory<TOptions> {
1339
1407
  const label = spec.label ?? 'playbook';
1408
+ // DR-022 / PBRT-50: reject an incompatible artifact declaration before any
1409
+ // machine interpretation, against this loaded engine's own self-report.
1410
+ assertRuntimeCompat(spec.compat, label);
1340
1411
  const declaredActors = collectInvokeSources(machine);
1341
1412
  const resumableStateIds =
1342
1413
  spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);