@volter/twin-standard 1.0.103 → 1.0.105

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
@@ -28,8 +28,12 @@ The public `assessPack(packDir, { browser: true, consumer })` API runs inside a
28
28
  kernel dispatch and replays journeys through Chromium when their wire is HTTP. Install Playwright and Chromium for
29
29
  that target. Its report distinguishes API support, operations exercised, journey failures and replay, and does not
30
30
  claim DOM interaction coverage. `consumer` contains absolute `appDir` and `cli` paths and the candidate's
31
- `artifactIntegrity`. Prepare the pack's `journeys/first-use.json` with `prepareConsumerApp(packDir, newAppDir)`,
32
- then install its exact SDK dependencies with scripts disabled before the assessment World starts. The public
31
+ `artifactIntegrity`. Prepare the pack's `journeys/first-use.json` with `prepareConsumerApp(packDir, newAppDir, { cli, artifact })`:
32
+ `artifact` is the absolute unpublished tarball path, or omitted for a registry release. Preparation adds the exact candidate
33
+ and the CLI's kernel peer to the app. Install those and its exact SDK dependencies with scripts disabled before the
34
+ assessment World starts. The journey refuses candidate or kernel imports from evaluator ancestors and checks the
35
+ app's own lockfile against the assessed integrity. Older evidence without this installation identity does not satisfy
36
+ the current completion check. The public
33
37
  `runConsumerWorkflow(packDir, consumer)` lets a builder walk only that entry journey while authoring; it runs no
34
38
  grade or conformance checks. Installed readiness requires ordinary CLI initialization against the exact candidate,
35
39
  SDK assertions, read-only state inspection after stop/resume for stored workflows, and verified teardown. A
@@ -67,6 +67,19 @@ export declare function assessPack(packDir: string, options?: AssessmentOptions)
67
67
  '@volter/world-core': string;
68
68
  '@volter/world-runtime': string;
69
69
  };
70
+ application: {
71
+ candidate: {
72
+ package: any;
73
+ version: string;
74
+ integrity: string;
75
+ };
76
+ kernel: {
77
+ package: string;
78
+ version: string;
79
+ };
80
+ dependencyLockSha256: string;
81
+ appLocal: boolean;
82
+ };
70
83
  sourceInputsSha256: string;
71
84
  recipeSha256: string;
72
85
  fixture: string;
@@ -83,8 +96,12 @@ export declare function assessPack(packDir: string, options?: AssessmentOptions)
83
96
  teardownVerified: boolean;
84
97
  retainedReadback: boolean;
85
98
  clock: {
99
+ beforeStop?: string | null | undefined;
100
+ afterResume?: string | null | undefined;
101
+ retentionVerified?: boolean | undefined;
86
102
  at: string;
87
103
  advanceAfterRun?: string;
104
+ mode?: undefined;
88
105
  } | {
89
106
  mode: string;
90
107
  };
package/dist/src/cli.js CHANGED
@@ -31,7 +31,7 @@ import { standardPackageFile } from "./package-file.js";
31
31
  import { gatePublished } from "./published.js";
32
32
  const [command, ...args] = process.argv.slice(2);
33
33
  const usage = () => {
34
- console.error('usage: twin-standard gate <repo> [units…] | gate-published <name>@<version>… [--registry <url>] | grade <unit>… [--static] [--form] | derive <unit> [--out <dir>] | read-page <url> [--raw] | check-sources <unit> [--recheck] | conform <pack dir> | <name>@<version> [--registry <url>] | create <dir> --init <vendor> --package <@scope/name> | create <unit> --index | facts <pack dir> [--out <file>] | prepare-consumer <pack dir> --app <new dir> --cli <volter executable> --integrity <sha512> --out <json> | assess <pack dir> --prepared-consumer <json> [--browser] [--out <file>] | --version');
34
+ console.error('usage: twin-standard gate <repo> [units…] | gate-published <name>@<version>… [--registry <url>] | grade <unit>… [--static] [--form] | derive <unit> [--out <dir>] | read-page <url> [--raw] | check-sources <unit> [--recheck] | conform <pack dir> | <name>@<version> [--registry <url>] | create <dir> --init <vendor> --package <@scope/name> | create <unit> --index | facts <pack dir> [--out <file>] | prepare-consumer <pack dir> --app <new dir> --cli <volter executable> --integrity <sha512> --out <json> [--candidate <tgz>] | assess <pack dir> --prepared-consumer <json> [--browser] [--out <file>] | --version');
35
35
  process.exit(2);
36
36
  };
37
37
  if (command === '--version') {
@@ -123,10 +123,12 @@ else if (command === 'prepare-consumer') {
123
123
  if (existsSync(out))
124
124
  throw new Error('Prepared consumer output already exists');
125
125
  const { prepareConsumerApp } = await import("./consumer.js");
126
- const fixture = prepareConsumerApp(resolve(args[0]), appDir);
126
+ const candidateAt = args.indexOf('--candidate');
127
+ const artifact = candidateAt >= 0 ? resolve(value('--candidate')) : undefined;
128
+ const fixture = prepareConsumerApp(resolve(args[0]), appDir, { cli, artifact });
127
129
  const { writeFileSync } = await import('node:fs');
128
130
  writeFileSync(out, JSON.stringify({ appDir, cli, artifactIntegrity }, null, 2) + '\n');
129
- console.log(JSON.stringify({ appDir, fixture, preparedConsumer: out, next: 'Install the app SDK dependencies with scripts disabled before assessing inside a World' }));
131
+ console.log(JSON.stringify({ appDir, fixture, preparedConsumer: out, next: 'Install the app SDK, candidate and kernel dependencies with scripts disabled before assessing inside a World' }));
130
132
  }
131
133
  else if (command === 'assess') {
132
134
  if (!args[0])
@@ -19,12 +19,16 @@ export type ConsumerOptions = {
19
19
  cli: string;
20
20
  artifactIntegrity: string;
21
21
  };
22
+ type ConsumerInstallation = {
23
+ cli: string;
24
+ artifact?: string;
25
+ };
22
26
  /** Bind behavior inputs on both source and packed layouts; compiled outputs and score caches are not inputs. */
23
27
  export declare function consumerSourceDigest(pack: string): string;
24
28
  /** A pack supplies its own entry journey; older immutable releases use a versioned standard fixture. */
25
29
  export declare function consumerRecipe(vendor: string, packDir?: string): ConsumerRecipe;
26
30
  /** Materialize only a new caller-owned app. Normal dependency installation happens before offline assessment. */
27
- export declare function prepareConsumerApp(packDir: string, appDir: string): {
31
+ export declare function prepareConsumerApp(packDir: string, appDir: string, installation?: ConsumerInstallation): {
28
32
  recipeSha256: string;
29
33
  dependencies: Record<string, string>;
30
34
  };
@@ -47,6 +51,19 @@ export declare function runConsumerWorkflow(packDir: string, options: ConsumerOp
47
51
  '@volter/world-core': string;
48
52
  '@volter/world-runtime': string;
49
53
  };
54
+ application: {
55
+ candidate: {
56
+ package: any;
57
+ version: string;
58
+ integrity: string;
59
+ };
60
+ kernel: {
61
+ package: string;
62
+ version: string;
63
+ };
64
+ dependencyLockSha256: string;
65
+ appLocal: boolean;
66
+ };
50
67
  sourceInputsSha256: string;
51
68
  recipeSha256: string;
52
69
  fixture: string;
@@ -63,9 +80,14 @@ export declare function runConsumerWorkflow(packDir: string, options: ConsumerOp
63
80
  teardownVerified: boolean;
64
81
  retainedReadback: boolean;
65
82
  clock: {
83
+ beforeStop?: string | null | undefined;
84
+ afterResume?: string | null | undefined;
85
+ retentionVerified?: boolean | undefined;
66
86
  at: string;
67
87
  advanceAfterRun?: string;
88
+ mode?: undefined;
68
89
  } | {
69
90
  mode: string;
70
91
  };
71
92
  }>;
93
+ export {};
@@ -2,10 +2,33 @@
2
2
  import { createHash } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
- import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { standardPackageFile } from "./package-file.js";
9
+ function packageIdentity(entry, name) {
10
+ let directory = dirname(realpathSync(entry));
11
+ while (true) {
12
+ const file = join(directory, 'package.json');
13
+ if (existsSync(file)) {
14
+ const pkg = JSON.parse(readFileSync(file, 'utf8'));
15
+ if (pkg.name === name)
16
+ return { directory, version: pkg.version };
17
+ }
18
+ const parent = dirname(directory);
19
+ if (parent === directory)
20
+ throw new Error(`${name} has no installed package identity`);
21
+ directory = parent;
22
+ }
23
+ }
24
+ function consumerTools(cli) {
25
+ const entry = realpathSync(cli), require = createRequire(entry);
26
+ return {
27
+ '@volter/world': packageIdentity(entry, '@volter/world').version,
28
+ '@volter/world-core': packageIdentity(require.resolve('@volter/world-core/runtime'), '@volter/world-core').version,
29
+ '@volter/world-runtime': packageIdentity(require.resolve('@volter/world-runtime'), '@volter/world-runtime').version,
30
+ };
31
+ }
9
32
  /** Bind behavior inputs on both source and packed layouts; compiled outputs and score caches are not inputs. */
10
33
  export function consumerSourceDigest(pack) {
11
34
  const entries = [];
@@ -49,7 +72,7 @@ export function consumerRecipe(vendor, packDir) {
49
72
  return recipe;
50
73
  }
51
74
  /** Materialize only a new caller-owned app. Normal dependency installation happens before offline assessment. */
52
- export function prepareConsumerApp(packDir, appDir) {
75
+ export function prepareConsumerApp(packDir, appDir, installation) {
53
76
  const facts = JSON.parse(readFileSync(join(packDir, 'generated/pack-facts.json'), 'utf8'));
54
77
  const vendor = Object.keys(facts.packs ?? {})[0];
55
78
  if (!vendor || Object.keys(facts.packs).length !== 1)
@@ -71,6 +94,16 @@ export function prepareConsumerApp(packDir, appDir) {
71
94
  throw new Error('Consumer files must contain text');
72
95
  return [path, content];
73
96
  });
97
+ if (installation) {
98
+ const metadata = JSON.parse(readFileSync(join(packDir, 'package.json'), 'utf8'));
99
+ const tools = consumerTools(installation.cli);
100
+ if (installation.artifact && (!isAbsolute(installation.artifact) || !statSync(installation.artifact).isFile()))
101
+ throw new Error('Consumer candidate must be an absolute prepared archive path');
102
+ if (Object.hasOwn(app.dependencies ?? {}, metadata.name))
103
+ throw new Error('Consumer SDK dependencies must not substitute the candidate package');
104
+ app.devDependencies = { ...app.devDependencies, '@volter/world-core': tools['@volter/world-core'], [metadata.name]: installation.artifact ? `file:${installation.artifact}` : metadata.version };
105
+ files.find(([path]) => path === 'package.json')[1] = JSON.stringify(app, null, 2) + '\n';
106
+ }
74
107
  mkdirSync(appDir, { recursive: true });
75
108
  for (const [path, content] of files) {
76
109
  mkdirSync(dirname(join(appDir, path)), { recursive: true });
@@ -83,29 +116,24 @@ export async function runConsumerWorkflow(packDir, options) {
83
116
  throw new Error('Consumer requires absolute prepared paths and candidate SHA-512 integrity');
84
117
  if (existsSync(join(options.appDir, '.volter/world.json')))
85
118
  throw new Error('Consumer app already has a World; prepare a new app rather than reusing prior state');
86
- const packageVersion = (entry, name) => {
87
- let directory = dirname(entry);
88
- while (true) {
89
- const file = join(directory, 'package.json');
90
- if (existsSync(file)) {
91
- const pkg = JSON.parse(readFileSync(file, 'utf8'));
92
- if (pkg.name === name)
93
- return pkg.version;
94
- }
95
- const parent = dirname(directory);
96
- if (parent === directory)
97
- throw new Error(`${name} has no installed package identity`);
98
- directory = parent;
99
- }
100
- };
101
119
  const cliEntry = realpathSync(options.cli);
102
- const cliRequire = createRequire(cliEntry);
103
- const tools = {
104
- '@volter/world': packageVersion(cliEntry, '@volter/world'),
105
- '@volter/world-core': packageVersion(cliRequire.resolve('@volter/world-core/runtime'), '@volter/world-core'),
106
- '@volter/world-runtime': packageVersion(cliRequire.resolve('@volter/world-runtime'), '@volter/world-runtime'),
107
- };
120
+ const tools = consumerTools(cliEntry);
108
121
  const metadata = JSON.parse(readFileSync(join(packDir, 'package.json'), 'utf8'));
122
+ const appRoot = realpathSync(options.appDir), appRequire = createRequire(join(appRoot, 'package.json'));
123
+ const candidate = packageIdentity(appRequire.resolve(metadata.name), metadata.name);
124
+ const kernel = packageIdentity(appRequire.resolve('@volter/world-core/runtime'), '@volter/world-core');
125
+ for (const [name, identity] of [[metadata.name, candidate], ['@volter/world-core', kernel]]) {
126
+ const location = relative(join(appRoot, 'node_modules'), identity.directory);
127
+ if (location.startsWith('..') || isAbsolute(location))
128
+ throw new Error(`${name}: consumer resolves an evaluator or ancestor dependency; install it in the app`);
129
+ }
130
+ const appLockBytes = readFileSync(join(appRoot, 'package-lock.json'));
131
+ const appLock = JSON.parse(appLockBytes.toString());
132
+ if (candidate.version !== metadata.version || appLock.packages?.[`node_modules/${metadata.name}`]?.integrity !== options.artifactIntegrity)
133
+ throw new Error('Consumer app candidate does not match the exact assessed artifact');
134
+ if (kernel.version !== tools['@volter/world-core'] || appLock.packages?.['node_modules/@volter/world-core']?.version !== kernel.version)
135
+ throw new Error('Consumer app kernel does not match the CLI kernel pin');
136
+ const application = { candidate: { package: metadata.name, version: candidate.version, integrity: options.artifactIntegrity }, kernel: { package: '@volter/world-core', version: kernel.version }, dependencyLockSha256: createHash('sha256').update(appLockBytes).digest('hex'), appLocal: true };
109
137
  const facts = JSON.parse(readFileSync(join(packDir, 'generated/pack-facts.json'), 'utf8'));
110
138
  const vendor = Object.keys(facts.packs ?? {})[0];
111
139
  if (!vendor || Object.keys(facts.packs).length !== 1)
@@ -135,6 +163,8 @@ export async function runConsumerWorkflow(packDir, options) {
135
163
  let upAttempted = false;
136
164
  let retainedReadback = false;
137
165
  let teardownVerified = false;
166
+ let clockBeforeStop = null;
167
+ let clockAfterResume = null;
138
168
  try {
139
169
  if (compiledCli)
140
170
  nodeVersion = (await execute('node-version', 'node', ['--version'])).trim();
@@ -152,9 +182,20 @@ export async function runConsumerWorkflow(packDir, options) {
152
182
  await run('clock-advance', ['clock', 'advance', recipe.clock.advanceAfterRun]);
153
183
  await run('inspect-timed-result', ['run', '--', ...recipe.retention.readback]);
154
184
  }
185
+ if (recipe.clock) {
186
+ clockBeforeStop = (await run('clock-before-stop', ['clock', 'show'])).trim();
187
+ const instant = Date.parse(clockBeforeStop);
188
+ if (!Number.isFinite(instant) || new Date(instant).toISOString() !== clockBeforeStop)
189
+ throw new Error('Declared customer clock is not frozen before stop');
190
+ }
155
191
  const before = JSON.parse(await run('log-before-stop', ['log', '--json']));
156
192
  await run('stop', ['down']);
157
193
  await run('resume', ['up']);
194
+ if (recipe.clock) {
195
+ clockAfterResume = (await run('clock-after-resume', ['clock', 'show'])).trim();
196
+ if (clockAfterResume !== clockBeforeStop)
197
+ throw new Error('Resume lost or changed the declared customer clock');
198
+ }
158
199
  const resumed = JSON.parse(await run('log-after-resume', ['log', '--json']));
159
200
  if (JSON.stringify(before) !== JSON.stringify(resumed))
160
201
  throw new Error('Resume changed recorded application state before readback');
@@ -186,11 +227,11 @@ export async function runConsumerWorkflow(packDir, options) {
186
227
  measured: true, passed: failures.length === 0 && teardownVerified,
187
228
  scope: 'Exact installed artifact; normal CLI initialization, unchanged SDK, result assertions and retained-state read-only resume where applicable',
188
229
  package: metadata.name, version: metadata.version, integrity: options.artifactIntegrity,
189
- tools,
230
+ tools, application,
190
231
  sourceInputsSha256: consumerSourceDigest(packDir), recipeSha256: createHash('sha256').update(JSON.stringify(recipe)).digest('hex'),
191
232
  fixture: existsSync(join(packDir, 'journeys/first-use.json')) ? 'journeys/first-use.json' : `standard/clients/${vendor}.first-use.json`, appDir: resolve(options.appDir), appName: basename(options.appDir),
192
233
  commands, failures, teardownVerified, retainedReadback,
193
- clock: recipe.clock ?? { mode: 'wall' },
234
+ clock: recipe.clock ? { ...recipe.clock, ...('readback' in recipe.retention ? { beforeStop: clockBeforeStop, afterResume: clockAfterResume, retentionVerified: retainedReadback && clockBeforeStop !== null && clockBeforeStop === clockAfterResume } : {}) } : { mode: 'wall' },
194
235
  ...('none' in recipe.retention ? { retentionNotApplicable: recipe.retention.none } : {}),
195
236
  node: nodeVersion,
196
237
  evaluatorRuntime: { nodeCompatibility: process.versions.node, bun: process.versions.bun ?? null },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-standard",
3
- "version": "1.0.103",
3
+ "version": "1.0.105",
4
4
  "description": "The Protocol 3 standard for Volter twin packs: the criteria a pack is graded by, the grader, the gate a pack repository and the twin catalog run on every change and submission, and the spec derivation the grade checks against. Pin a version: every place a pack is judged judges by that standard.",
5
5
  "keywords": [
6
6
  "twin",
@@ -75,7 +75,7 @@
75
75
  "yaml": "2.9.0"
76
76
  },
77
77
  "peerDependencies": {
78
- "@volter/world-core": "3.0.111",
78
+ "@volter/world-core": "3.0.113",
79
79
  "@volter/world-runtime": "*",
80
80
  "playwright": "^1.63.0"
81
81
  },
package/src/cli.ts CHANGED
@@ -24,7 +24,7 @@ import { gatePublished } from './published.ts';
24
24
 
25
25
  const [command, ...args] = process.argv.slice(2);
26
26
  const usage = (): never => {
27
- console.error('usage: twin-standard gate <repo> [units…] | gate-published <name>@<version>… [--registry <url>] | grade <unit>… [--static] [--form] | derive <unit> [--out <dir>] | read-page <url> [--raw] | check-sources <unit> [--recheck] | conform <pack dir> | <name>@<version> [--registry <url>] | create <dir> --init <vendor> --package <@scope/name> | create <unit> --index | facts <pack dir> [--out <file>] | prepare-consumer <pack dir> --app <new dir> --cli <volter executable> --integrity <sha512> --out <json> | assess <pack dir> --prepared-consumer <json> [--browser] [--out <file>] | --version');
27
+ console.error('usage: twin-standard gate <repo> [units…] | gate-published <name>@<version>… [--registry <url>] | grade <unit>… [--static] [--form] | derive <unit> [--out <dir>] | read-page <url> [--raw] | check-sources <unit> [--recheck] | conform <pack dir> | <name>@<version> [--registry <url>] | create <dir> --init <vendor> --package <@scope/name> | create <unit> --index | facts <pack dir> [--out <file>] | prepare-consumer <pack dir> --app <new dir> --cli <volter executable> --integrity <sha512> --out <json> [--candidate <tgz>] | assess <pack dir> --prepared-consumer <json> [--browser] [--out <file>] | --version');
28
28
  process.exit(2);
29
29
  };
30
30
 
@@ -83,10 +83,12 @@ if (command === '--version') {
83
83
  const out = resolve(value('--out'));
84
84
  if (existsSync(out)) throw new Error('Prepared consumer output already exists');
85
85
  const { prepareConsumerApp } = await import('./consumer.ts');
86
- const fixture = prepareConsumerApp(resolve(args[0]!), appDir);
86
+ const candidateAt = args.indexOf('--candidate');
87
+ const artifact = candidateAt >= 0 ? resolve(value('--candidate')) : undefined;
88
+ const fixture = prepareConsumerApp(resolve(args[0]!), appDir, { cli, artifact });
87
89
  const { writeFileSync } = await import('node:fs');
88
90
  writeFileSync(out, JSON.stringify({ appDir, cli, artifactIntegrity }, null, 2) + '\n');
89
- console.log(JSON.stringify({ appDir, fixture, preparedConsumer: out, next: 'Install the app SDK dependencies with scripts disabled before assessing inside a World' }));
91
+ console.log(JSON.stringify({ appDir, fixture, preparedConsumer: out, next: 'Install the app SDK, candidate and kernel dependencies with scripts disabled before assessing inside a World' }));
90
92
  } else if (command === 'assess') {
91
93
  if (!args[0]) usage();
92
94
  const { assessPack } = await import('./assessment.ts');
package/src/consumer.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createHash } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
- import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { standardPackageFile } from './package-file.ts';
@@ -17,6 +17,26 @@ export type ConsumerRecipe = {
17
17
  retention: { readback: string[] } | { none: string };
18
18
  };
19
19
  export type ConsumerOptions = { appDir: string; cli: string; artifactIntegrity: string };
20
+ type ConsumerInstallation = { cli: string; artifact?: string };
21
+
22
+ function packageIdentity(entry: string, name: string): { directory: string; version: string } {
23
+ let directory = dirname(realpathSync(entry));
24
+ while (true) {
25
+ const file = join(directory, 'package.json');
26
+ if (existsSync(file)) { const pkg = JSON.parse(readFileSync(file, 'utf8')); if (pkg.name === name) return { directory, version: pkg.version }; }
27
+ const parent = dirname(directory);
28
+ if (parent === directory) throw new Error(`${name} has no installed package identity`);
29
+ directory = parent;
30
+ }
31
+ }
32
+ function consumerTools(cli: string) {
33
+ const entry = realpathSync(cli), require = createRequire(entry);
34
+ return {
35
+ '@volter/world': packageIdentity(entry, '@volter/world').version,
36
+ '@volter/world-core': packageIdentity(require.resolve('@volter/world-core/runtime'), '@volter/world-core').version,
37
+ '@volter/world-runtime': packageIdentity(require.resolve('@volter/world-runtime'), '@volter/world-runtime').version,
38
+ };
39
+ }
20
40
 
21
41
  /** Bind behavior inputs on both source and packed layouts; compiled outputs and score caches are not inputs. */
22
42
  export function consumerSourceDigest(pack: string): string {
@@ -52,7 +72,7 @@ export function consumerRecipe(vendor: string, packDir?: string): ConsumerRecipe
52
72
  }
53
73
 
54
74
  /** Materialize only a new caller-owned app. Normal dependency installation happens before offline assessment. */
55
- export function prepareConsumerApp(packDir: string, appDir: string): { recipeSha256: string; dependencies: Record<string, string> } {
75
+ export function prepareConsumerApp(packDir: string, appDir: string, installation?: ConsumerInstallation): { recipeSha256: string; dependencies: Record<string, string> } {
56
76
  const facts = JSON.parse(readFileSync(join(packDir, 'generated/pack-facts.json'), 'utf8'));
57
77
  const vendor = Object.keys(facts.packs ?? {})[0];
58
78
  if (!vendor || Object.keys(facts.packs).length !== 1) throw new Error('Consumer preparation requires one artifact vendor identity');
@@ -66,8 +86,16 @@ export function prepareConsumerApp(packDir: string, appDir: string): { recipeSha
66
86
  const files = Object.entries(recipe.files).map(([path, content]) => {
67
87
  if (isAbsolute(path) || path.includes('\\') || path.split('/').some((part) => !part || part === '.' || part === '..') || path.split('/').includes('node_modules') || ['.env', '.npmrc', '.volter/world.json'].includes(path)) throw new Error(`Invalid consumer file: ${path}`);
68
88
  if (typeof content !== 'string') throw new Error('Consumer files must contain text');
69
- return [path, content] as const;
89
+ return [path, content] as [string, string];
70
90
  });
91
+ if (installation) {
92
+ const metadata = JSON.parse(readFileSync(join(packDir, 'package.json'), 'utf8'));
93
+ const tools = consumerTools(installation.cli);
94
+ if (installation.artifact && (!isAbsolute(installation.artifact) || !statSync(installation.artifact).isFile())) throw new Error('Consumer candidate must be an absolute prepared archive path');
95
+ if (Object.hasOwn(app.dependencies ?? {}, metadata.name)) throw new Error('Consumer SDK dependencies must not substitute the candidate package');
96
+ app.devDependencies = { ...app.devDependencies, '@volter/world-core': tools['@volter/world-core'], [metadata.name]: installation.artifact ? `file:${installation.artifact}` : metadata.version };
97
+ files.find(([path]) => path === 'package.json')![1] = JSON.stringify(app, null, 2) + '\n';
98
+ }
71
99
  mkdirSync(appDir, { recursive: true });
72
100
  for (const [path, content] of files) {
73
101
  mkdirSync(dirname(join(appDir, path)), { recursive: true });
@@ -79,24 +107,21 @@ export function prepareConsumerApp(packDir: string, appDir: string): { recipeSha
79
107
  export async function runConsumerWorkflow(packDir: string, options: ConsumerOptions) {
80
108
  if (!isAbsolute(options.appDir) || !isAbsolute(options.cli) || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(options.artifactIntegrity)) throw new Error('Consumer requires absolute prepared paths and candidate SHA-512 integrity');
81
109
  if (existsSync(join(options.appDir, '.volter/world.json'))) throw new Error('Consumer app already has a World; prepare a new app rather than reusing prior state');
82
- const packageVersion = (entry: string, name: string): string => {
83
- let directory = dirname(entry);
84
- while (true) {
85
- const file = join(directory, 'package.json');
86
- if (existsSync(file)) { const pkg = JSON.parse(readFileSync(file, 'utf8')); if (pkg.name === name) return pkg.version; }
87
- const parent = dirname(directory);
88
- if (parent === directory) throw new Error(`${name} has no installed package identity`);
89
- directory = parent;
90
- }
91
- };
92
110
  const cliEntry = realpathSync(options.cli);
93
- const cliRequire = createRequire(cliEntry);
94
- const tools = {
95
- '@volter/world': packageVersion(cliEntry, '@volter/world'),
96
- '@volter/world-core': packageVersion(cliRequire.resolve('@volter/world-core/runtime'), '@volter/world-core'),
97
- '@volter/world-runtime': packageVersion(cliRequire.resolve('@volter/world-runtime'), '@volter/world-runtime'),
98
- };
111
+ const tools = consumerTools(cliEntry);
99
112
  const metadata = JSON.parse(readFileSync(join(packDir, 'package.json'), 'utf8'));
113
+ const appRoot = realpathSync(options.appDir), appRequire = createRequire(join(appRoot, 'package.json'));
114
+ const candidate = packageIdentity(appRequire.resolve(metadata.name), metadata.name);
115
+ const kernel = packageIdentity(appRequire.resolve('@volter/world-core/runtime'), '@volter/world-core');
116
+ for (const [name, identity] of [[metadata.name, candidate], ['@volter/world-core', kernel]] as const) {
117
+ const location = relative(join(appRoot, 'node_modules'), identity.directory);
118
+ if (location.startsWith('..') || isAbsolute(location)) throw new Error(`${name}: consumer resolves an evaluator or ancestor dependency; install it in the app`);
119
+ }
120
+ const appLockBytes = readFileSync(join(appRoot, 'package-lock.json'));
121
+ const appLock = JSON.parse(appLockBytes.toString());
122
+ if (candidate.version !== metadata.version || appLock.packages?.[`node_modules/${metadata.name}`]?.integrity !== options.artifactIntegrity) throw new Error('Consumer app candidate does not match the exact assessed artifact');
123
+ if (kernel.version !== tools['@volter/world-core'] || appLock.packages?.['node_modules/@volter/world-core']?.version !== kernel.version) throw new Error('Consumer app kernel does not match the CLI kernel pin');
124
+ const application = { candidate: { package: metadata.name, version: candidate.version, integrity: options.artifactIntegrity }, kernel: { package: '@volter/world-core', version: kernel.version }, dependencyLockSha256: createHash('sha256').update(appLockBytes).digest('hex'), appLocal: true };
100
125
  const facts = JSON.parse(readFileSync(join(packDir, 'generated/pack-facts.json'), 'utf8'));
101
126
  const vendor = Object.keys(facts.packs ?? {})[0];
102
127
  if (!vendor || Object.keys(facts.packs).length !== 1) throw new Error('Consumer workflow requires one selected artifact identity');
@@ -124,6 +149,8 @@ export async function runConsumerWorkflow(packDir: string, options: ConsumerOpti
124
149
  let upAttempted = false;
125
150
  let retainedReadback = false;
126
151
  let teardownVerified = false;
152
+ let clockBeforeStop: string | null = null;
153
+ let clockAfterResume: string | null = null;
127
154
  try {
128
155
  if (compiledCli) nodeVersion = (await execute('node-version', 'node', ['--version'])).trim();
129
156
  await run('init', ['init', '--twins', vendor]);
@@ -138,9 +165,18 @@ export async function runConsumerWorkflow(packDir: string, options: ConsumerOpti
138
165
  await run('clock-advance', ['clock', 'advance', recipe.clock.advanceAfterRun]);
139
166
  await run('inspect-timed-result', ['run', '--', ...recipe.retention.readback]);
140
167
  }
168
+ if (recipe.clock) {
169
+ clockBeforeStop = (await run('clock-before-stop', ['clock', 'show'])).trim();
170
+ const instant = Date.parse(clockBeforeStop);
171
+ if (!Number.isFinite(instant) || new Date(instant).toISOString() !== clockBeforeStop) throw new Error('Declared customer clock is not frozen before stop');
172
+ }
141
173
  const before = JSON.parse(await run('log-before-stop', ['log', '--json']));
142
174
  await run('stop', ['down']);
143
175
  await run('resume', ['up']);
176
+ if (recipe.clock) {
177
+ clockAfterResume = (await run('clock-after-resume', ['clock', 'show'])).trim();
178
+ if (clockAfterResume !== clockBeforeStop) throw new Error('Resume lost or changed the declared customer clock');
179
+ }
144
180
  const resumed = JSON.parse(await run('log-after-resume', ['log', '--json']));
145
181
  if (JSON.stringify(before) !== JSON.stringify(resumed)) throw new Error('Resume changed recorded application state before readback');
146
182
  await run('retained-readback', ['run', '--', ...recipe.retention.readback]);
@@ -163,11 +199,11 @@ export async function runConsumerWorkflow(packDir: string, options: ConsumerOpti
163
199
  measured: true, passed: failures.length === 0 && teardownVerified,
164
200
  scope: 'Exact installed artifact; normal CLI initialization, unchanged SDK, result assertions and retained-state read-only resume where applicable',
165
201
  package: metadata.name, version: metadata.version, integrity: options.artifactIntegrity,
166
- tools,
202
+ tools, application,
167
203
  sourceInputsSha256: consumerSourceDigest(packDir), recipeSha256: createHash('sha256').update(JSON.stringify(recipe)).digest('hex'),
168
204
  fixture: existsSync(join(packDir, 'journeys/first-use.json')) ? 'journeys/first-use.json' : `standard/clients/${vendor}.first-use.json`, appDir: resolve(options.appDir), appName: basename(options.appDir),
169
205
  commands, failures, teardownVerified, retainedReadback,
170
- clock: recipe.clock ?? { mode: 'wall' },
206
+ clock: recipe.clock ? { ...recipe.clock, ...('readback' in recipe.retention ? { beforeStop: clockBeforeStop, afterResume: clockAfterResume, retentionVerified: retainedReadback && clockBeforeStop !== null && clockBeforeStop === clockAfterResume } : {}) } : { mode: 'wall' },
171
207
  ...('none' in recipe.retention ? { retentionNotApplicable: recipe.retention.none } : {}),
172
208
  node: nodeVersion,
173
209
  evaluatorRuntime: { nodeCompatibility: process.versions.node, bun: process.versions.bun ?? null },