@volter/world-runtime 2.0.49 → 2.0.51

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.
@@ -48,7 +48,9 @@ export declare function createWorldChangeset(world: string, name: string, option
48
48
  verifiers?: ChangesetVerifier[];
49
49
  now?: Date;
50
50
  overwrite?: boolean;
51
- message?: string;
51
+ message?: string; /** who cuts it and the person it is owned by, from the cutting grant (a served World's door) */
52
+ cutBy?: string;
53
+ owner?: string;
52
54
  }): Changeset;
53
55
  /** Every changeset in every world under `root` (or one world with `world`), newest first. */
54
56
  export declare function listWorldChangesets(options?: WorldRootOptions & {
@@ -1,3 +1,4 @@
1
+ import { worldChangesetRules } from "./configs.js";
1
2
  import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, getActiveWorldStore, withAncestryLock, worldPaths } from '@volter/world-core';
2
3
  // `volter-world mark` / `diff` / `changeset` — the WORLD-side verbs over the changeset
3
4
  // primitive (docs/concepts/the-model.md v0). Thin, exactly like `covers` and `tail` are thin: all
@@ -183,6 +184,8 @@ export function createWorldChangeset(world, name, options = {}) {
183
184
  ...(options.verifiers ? { verifiers: options.verifiers } : {}),
184
185
  ...(options.now ? { createdAt: options.now.toISOString() } : {}),
185
186
  ...(options.message === undefined ? {} : { message: options.message }),
187
+ ...(options.cutBy === undefined ? {} : { cutBy: options.cutBy }),
188
+ ...(options.owner === undefined ? {} : { owner: options.owner }),
186
189
  });
187
190
  getActiveWorldStore().write(path, `${JSON.stringify(changeset, null, 2)}\n`);
188
191
  // a changeset records where the log was cut (contract "The model"): the next one starts here
@@ -373,7 +376,7 @@ export function approveWorldChangeset(name, options) {
373
376
  export function statusWorldChangeset(name, options = {}) {
374
377
  const root = resolveRoot(options.root);
375
378
  const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
376
- return changesetReadiness(located.changeset);
379
+ return changesetReadiness(located.changeset, worldChangesetRules(root));
377
380
  }
378
381
  export async function pushWorldChangeset(name, options) {
379
382
  // PUSH (docs/concepts/the-model.md): the changeset goes to the served world's push door; its
@@ -541,7 +544,8 @@ export async function pushWorldChangeset(name, options) {
541
544
  const receipts = body.receipts.map((r) => ({ actionId: r.actionId, service: r.twin, stateService: located.changeset.actions.find((e) => e.service === r.twin)?.stateService ?? r.twin, status: statusOf(r.status), ...(r.externalId ? { externalId: r.externalId } : {}), ...(r.reason ? { error: r.reason } : {}) }));
542
545
  const outcome = receipts.every((r) => r.status !== 'failed') ? 'applied' : receipts.some((r) => r.status === 'confirmed') ? 'partial' : 'refused';
543
546
  const application = { at, contentHash: located.changeset.contentHash, outcome, receipts, compensation: [], ...(outcome === 'refused' ? { refusal: receipts.filter((r) => r.error).map((r) => r.error) } : {}) };
544
- const changeset = withApplication(located.changeset, application);
547
+ // the remote's record of who pushed it and who owns it, kept on this copy too (outside the hash)
548
+ const changeset = { ...withApplication(located.changeset, application), ...(body.owner && !located.changeset.owner ? { owner: body.owner } : {}), ...(body.pushedBy ? { pushedBy: body.pushedBy } : {}) };
545
549
  writeChangesetFile(located.path, changeset);
546
550
  const unsent = namedFailure ? `records the landed entries name did not reach the origin (${namedFailure instanceof Error ? namedFailure.message : String(namedFailure)}); the next push from this branch that touches ${twins.map(([t]) => t).join(', ')} sends them` : undefined;
547
551
  return { ...(unsent ? { unsent } : {}), application, changeset, world: located.world, path: located.path, remote: { url, namespace } };
@@ -1,4 +1,4 @@
1
- import { type WorldConfig } from './schema.js';
1
+ import { type WorldChangesetRules, type WorldConfig } from './schema.js';
2
2
  export declare function resolveConfigPath(config: string, root?: string): string;
3
3
  export declare function loadWorldConfig(config: string, root?: string): {
4
4
  path: string;
@@ -16,3 +16,6 @@ export type WorldConfigMigration = {
16
16
  };
17
17
  /** Explicit, backed-up, atomic format-1 → format-2 migration. Ordinary reads never call this. */
18
18
  export declare function migrateWorldConfig(config: string, root?: string): WorldConfigMigration;
19
+ /** The changeset rules of the World rooted at `root` (its committed `.volter/world.json`, `changesets`); none when it
20
+ * has no config or names none, which readiness reads as one approval and a required summary. */
21
+ export declare function worldChangesetRules(root: string): WorldChangesetRules;
@@ -1,6 +1,6 @@
1
1
  import { copyFileSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { basename, dirname, resolve } from 'node:path';
3
- import { getActiveWorldStore } from '@volter/world-core';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { getActiveWorldStore, stateDirName } from '@volter/world-core';
4
4
  import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument } from "./schema.js";
5
5
  export function resolveConfigPath(config, root = process.cwd()) {
6
6
  const candidates = config.includes('/') || config.endsWith('.json')
@@ -117,3 +117,16 @@ export function migrateWorldConfig(config, root = process.cwd()) {
117
117
  dropped: normalized.resources === undefined ? [] : ['resources (deprecated; retained in backup)'],
118
118
  };
119
119
  }
120
+ /** The changeset rules of the World rooted at `root` (its committed `.volter/world.json`, `changesets`); none when it
121
+ * has no config or names none, which readiness reads as one approval and a required summary. */
122
+ export function worldChangesetRules(root) {
123
+ const path = join(root, stateDirName(), 'world.json');
124
+ if (!getActiveWorldStore().exists(path) && !existsSync(path))
125
+ return {};
126
+ try {
127
+ return loadWorldConfig(path, root).config.changesets ?? {};
128
+ }
129
+ catch {
130
+ return {};
131
+ }
132
+ }
@@ -194,6 +194,10 @@ const CREDENTIAL_SHAPES = [
194
194
  { match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
195
195
  { match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
196
196
  { match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
197
+ // The slack twin answers a bearer that is none of Slack's token kinds with `invalid_auth`, as Slack does; any `xoxb-`
198
+ // acts as the workspace bot, and an app-level token is `xapp-`.
199
+ { match: /(^|_)SLACK_[A-Z0-9_]*APP_TOKEN$/, value: () => 'xapp-1-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/)' },
200
+ { match: /(^|_)SLACK_([A-Z0-9_]*_)?BOT_TOKEN$|(^|_)SLACK_TOKEN$/, value: () => 'xoxb-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/); GET /twin identity (any other xoxb-* is the workspace bot)' },
197
201
  // tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
198
202
  { match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
199
203
  { match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
@@ -1,6 +1,7 @@
1
1
  export type { WorldConfig, WorldInstance, WorldIsolation, WorldMode, WorldServiceConfig, WorldServiceType, WorldExternalServiceConfig, WorldExternalDiscover, WorldExternalDiscoverSource, WorldExternalReadyWhen, WorldRunOutcome, WorldRunRecord, WorldResourceRequirements, WorldManifestV2, WorldSelection, WorldSelectionSelector, WorldUsage, } from './schema.js';
2
2
  export { DEFAULT_WORLD_SELECTION, LEGACY_WORLD_SELECTION, WORLD_SCHEMA_VERSION, WORLD_USAGES, worldConfigDocument, worldSelectionIncludes } from './schema.js';
3
- export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, writeWorldConfig } from './configs.js';
3
+ export type { WorldChangesetRules } from './schema.js';
4
+ export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, worldChangesetRules, writeWorldConfig } from './configs.js';
4
5
  export type { WorldConfigMigration } from './configs.js';
5
6
  export { upWorld, downWorld, pruneWorlds, listWorlds, statusWorld, doctorWorld, runWithWorldEnv, attachWorld, retireWorldConsumers, activateScript, shellWorld, worldShellEnv, ensureWorldProxy, runProxyDaemon, runWorld, shareWorld, shareWorldServices, unshareWorld, urlsWorld, urlWorld, } from './runtime.js';
6
7
  export type { WorldCleanupEntry } from './runtime.js';
package/dist/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { DEFAULT_WORLD_SELECTION, LEGACY_WORLD_SELECTION, WORLD_SCHEMA_VERSION, WORLD_USAGES, worldConfigDocument, worldSelectionIncludes } from "./schema.js";
2
- export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, writeWorldConfig } from "./configs.js";
2
+ export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, worldChangesetRules, writeWorldConfig } from "./configs.js";
3
3
  export { upWorld, downWorld, pruneWorlds, listWorlds, statusWorld, doctorWorld, runWithWorldEnv, attachWorld, retireWorldConsumers, activateScript, shellWorld, worldShellEnv, ensureWorldProxy, runProxyDaemon, runWorld, shareWorld, shareWorldServices, unshareWorld, urlsWorld, urlWorld, } from "./runtime.js";
4
4
  export { worldResourceReport } from "./resources.js";
5
5
  // `volter-world app-url` — the recorded, queryable app endpoint of an instance (see app-url.ts).
package/dist/src/root.js CHANGED
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { dirname, join, relative, resolve } from 'node:path';
14
14
  import { authStrategyFor, currentSealer, destinationOf, sealsThroughVault, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, readRoot, registerStateSystem, stateDirName, stateSystemFor, validateRemoteOrigin, writeRoot, } from '@volter/world-core';
15
- import { loadWorldConfig, writeWorldConfig } from "./configs.js";
15
+ import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from "./configs.js";
16
16
  import { changesetReadiness, collectObservations, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding } from '@volter/world-core';
17
17
  import { findWorldChangeset, listWorldChangesets } from "./changeset.js";
18
18
  import { importModule } from "./import-module.js";
@@ -577,7 +577,7 @@ export async function deployWorld(name, opts = {}) {
577
577
  const out = [];
578
578
  if (!opts.changeset) {
579
579
  // no name: every ready changeset, by name, in order — so each outcome says which it was for
580
- const ready = listWorldChangesets({ root: worldRoot, world: name }).filter((l) => changesetReadiness(l.changeset).ready && !l.changeset.actions.every((e) => { const r = rootForControlRoot(join(inst.dirs.data, e.service), e.service); return r === null; })).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
580
+ const ready = listWorldChangesets({ root: worldRoot, world: name }).filter((l) => changesetReadiness(l.changeset, worldChangesetRules(worldRoot)).ready && !l.changeset.actions.every((e) => { const r = rootForControlRoot(join(inst.dirs.data, e.service), e.service); return r === null; })).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
581
581
  const anyGated = Object.keys(inst.services).some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
582
582
  if (anyGated || ready.length) {
583
583
  for (const l of ready)
@@ -589,7 +589,7 @@ export async function deployWorld(name, opts = {}) {
589
589
  const located = opts.changeset ? [findWorldChangeset(opts.changeset, { root: worldRoot, world: name })] : listWorldChangesets({ root: worldRoot, world: name });
590
590
  const ready = new Map();
591
591
  for (const l of located) {
592
- const readiness = changesetReadiness(l.changeset);
592
+ const readiness = changesetReadiness(l.changeset, worldChangesetRules(worldRoot));
593
593
  if (!readiness.ready) {
594
594
  const gated = [...new Set(l.changeset.actions.map((e) => e.service))].some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
595
595
  if (opts.changeset && gated)
@@ -1342,7 +1342,7 @@ function versionSatisfies(actual, pin) {
1342
1342
  */
1343
1343
  async function startColocatedServices(config, colocated, isolation, worldEnv, paths, allocatedPorts, boot) {
1344
1344
  if (colocated.length === 0)
1345
- return [];
1345
+ return { services: [], ready: Promise.resolve() };
1346
1346
  const hostCli = siblingScript(import.meta.url, 'host-cli');
1347
1347
  const readyFile = join(paths.instance, `host-ready-${boot.bootId}.json`);
1348
1348
  const args = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker', '--ready-file', readyFile];
@@ -1381,55 +1381,10 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1381
1381
  exitState.value = { code, signal };
1382
1382
  });
1383
1383
  const command = [process.execPath, ...args];
1384
- const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
1385
- await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
1386
- if (undeadPids([child.pid]).length)
1387
- recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
1388
- try {
1389
- process.kill(-child.pid, 'SIGTERM');
1390
- }
1391
- catch { /* best effort cleanup */ }
1392
- throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1393
- });
1394
- await parallelStartup(allocated, async ({ service, port }) => {
1395
- const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1396
- await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
1397
- if (undeadPids([child.pid]).length)
1398
- recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1399
- try {
1400
- process.kill(-child.pid, 'SIGTERM');
1401
- }
1402
- catch { /* best effort cleanup */ }
1403
- throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1404
- });
1405
- });
1406
- let hostSettleTimer;
1407
- const hostSettle = new Promise((resolveAlive) => { hostSettleTimer = setTimeout(() => resolveAlive('alive'), 300); });
1408
- try {
1409
- await parallelStartup(allocated, async ({ service, port }) => {
1410
- // The host has completed every start. Each matching boot answer can now
1411
- // end its own settle; legacy twins share one full host guard.
1412
- await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
1413
- if (undeadPids([child.pid]).length)
1414
- recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1415
- try {
1416
- process.kill(-child.pid, 'SIGTERM');
1417
- }
1418
- catch { /* best effort cleanup */ }
1419
- throw error;
1420
- });
1421
- });
1422
- }
1423
- finally {
1424
- if (hostSettleTimer !== undefined)
1425
- clearTimeout(hostSettleTimer);
1426
- }
1427
- if (exitState.value !== null) {
1428
- throw new Error(`The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
1429
- + `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`);
1430
- }
1431
1384
  const host = '127.0.0.1';
1432
- return allocated.map(({ service, port, mounted }) => {
1385
+ // Every twin's address is known once its port is allocated: the records and
1386
+ // the environment they inject exist before the host reports its starts.
1387
+ const services = allocated.map(({ service, port, mounted }) => {
1433
1388
  const url = `http://${host}:${port}`;
1434
1389
  return {
1435
1390
  id: service.id,
@@ -1448,6 +1403,55 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
1448
1403
  },
1449
1404
  };
1450
1405
  });
1406
+ const ready = (async () => {
1407
+ const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
1408
+ await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
1409
+ if (undeadPids([child.pid]).length)
1410
+ recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
1411
+ try {
1412
+ process.kill(-child.pid, 'SIGTERM');
1413
+ }
1414
+ catch { /* best effort cleanup */ }
1415
+ throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1416
+ });
1417
+ await parallelStartup(allocated, async ({ service, port }) => {
1418
+ const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1419
+ await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
1420
+ if (undeadPids([child.pid]).length)
1421
+ recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1422
+ try {
1423
+ process.kill(-child.pid, 'SIGTERM');
1424
+ }
1425
+ catch { /* best effort cleanup */ }
1426
+ throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1427
+ });
1428
+ });
1429
+ // The host's receipt (this boot, every requested port) is published only
1430
+ // after every factory bound its own port; a port another process held fails
1431
+ // its bind and leaves no receipt. The receipt is the host's ownership proof
1432
+ // for each port, so no twin waits out a settle (ADR-0008). A port that
1433
+ // answers with another boot's identity is still refused.
1434
+ const hostSettle = Promise.resolve('alive');
1435
+ await parallelStartup(allocated, async ({ service, port }) => {
1436
+ await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
1437
+ if (undeadPids([child.pid]).length)
1438
+ recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1439
+ try {
1440
+ process.kill(-child.pid, 'SIGTERM');
1441
+ }
1442
+ catch { /* best effort cleanup */ }
1443
+ throw error;
1444
+ });
1445
+ });
1446
+ if (exitState.value !== null) {
1447
+ throw new Error(`The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
1448
+ + `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`);
1449
+ }
1450
+ })();
1451
+ // Observed here so a failure before the caller awaits it is not unhandled;
1452
+ // the caller awaits `ready` before the World is up.
1453
+ ready.catch(() => undefined);
1454
+ return { services, ready };
1451
1455
  }
1452
1456
  export async function upWorld(configId, options = {}, abort) {
1453
1457
  abort?.throwIfAborted();
@@ -1698,12 +1702,19 @@ export async function upWorld(configId, options = {}, abort) {
1698
1702
  const allocatedPorts = new Set(loaded.config.services.flatMap((service) => typeof service.port === 'number' ? [service.port] : []));
1699
1703
  // Non-'process' isolation: boot every colocate-declaring service inside ONE host child
1700
1704
  // first; anything without `colocate` (and externals) still goes through the spawn path.
1705
+ // The co-located host's twins are known by address once their ports are
1706
+ // allocated. A twin serves and calls no other service while it starts,
1707
+ // so a later twin declaration starts beside the host's startup; any other
1708
+ // service waits until every earlier one is ready, as declared (ADR-0009).
1709
+ let colocatedReady;
1701
1710
  if (isolation !== 'process') {
1702
1711
  const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
1703
- for (const started of await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot)) {
1712
+ const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
1713
+ for (const started of host.services) {
1704
1714
  services[started.id] = started;
1705
1715
  Object.assign(env, started.env);
1706
1716
  }
1717
+ colocatedReady = host.ready;
1707
1718
  recordPidsSoFar();
1708
1719
  writeProxyEnv(root, name, env);
1709
1720
  }
@@ -1736,6 +1747,10 @@ export async function upWorld(configId, options = {}, abort) {
1736
1747
  for (const service of loaded.config.services) {
1737
1748
  if (services[service.id])
1738
1749
  continue; // already up co-located
1750
+ if (colocatedReady && (service.type ?? 'twin') !== 'twin') {
1751
+ await colocatedReady;
1752
+ colocatedReady = undefined;
1753
+ }
1739
1754
  attemptProxy();
1740
1755
  boot.signal.throwIfAborted();
1741
1756
  const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
@@ -1744,6 +1759,8 @@ export async function upWorld(configId, options = {}, abort) {
1744
1759
  Object.assign(env, started.env);
1745
1760
  writeProxyEnv(root, name, env);
1746
1761
  }
1762
+ if (colocatedReady)
1763
+ await colocatedReady;
1747
1764
  detachBootSignals();
1748
1765
  // Re-evaluate once more after the LAST service: a single/last-twin world's OWN vendor twin
1749
1766
  // only lands in `env` once IT has started, so a check that only runs "before starting the
@@ -51,6 +51,7 @@ export type WorldManifestV2 = {
51
51
  share?: WorldShareConfig;
52
52
  };
53
53
  remotes?: Record<string, string>;
54
+ changesets?: WorldChangesetRules;
54
55
  scenario?: {
55
56
  actors?: Record<string, unknown>;
56
57
  fixtures?: Record<string, unknown>;
@@ -268,8 +269,17 @@ export type WorldServiceConfig = {
268
269
  * catalog the world was born against. Exact version or `^major.minor` range. */
269
270
  version?: string;
270
271
  };
272
+ /** How this World's changesets are made and readied (docs/reference/config.md, `changesets`): how many people must
273
+ * approve one's current contents before it deploys, and whether a cut needs its author's summary or takes the one
274
+ * generated from its changes. A team's rules, committed with the World, as a repository's are. */
275
+ export type WorldChangesetRules = {
276
+ approvals?: number;
277
+ summary?: 'required' | 'generated';
278
+ };
271
279
  export type WorldConfig = {
272
280
  id: string;
281
+ /** How changesets are made and readied here; omitted: one approval, a summary required. */
282
+ changesets?: WorldChangesetRules;
273
283
  /** Real external origins; omitted preserves legacy routing, [] explicitly closes egress. */
274
284
  network?: WorldNetwork;
275
285
  /** Internal normalized source format and saved discovery selection. */
@@ -452,3 +462,5 @@ export declare function assertWorldConfig(value: unknown, path: string): WorldCo
452
462
  export declare function worldSelectionIncludes(selection: WorldSelection, vendor: string, usage: WorldUsage): boolean;
453
463
  /** Serialize normalized intent as the committed format-2 manifest. */
454
464
  export declare function worldConfigDocument(config: WorldConfig, selection?: WorldSelection): WorldManifestV2 & Record<string, unknown>;
465
+ /** A World's changeset rules, strictly: `approvals` a whole number 0–10, `summary` `required` or `generated`, nothing else. */
466
+ export declare function assertChangesetRules(rules: unknown, path: string): asserts rules is WorldChangesetRules;
@@ -132,6 +132,8 @@ function assertLegacyWorldConfig(value, path) {
132
132
  if (typeof target !== 'string' || target === '' || !/^[A-Za-z0-9._-]+$/.test(name))
133
133
  throw new Error(`World config remote "${name}" must be a non-empty url or path in ${path}`);
134
134
  }
135
+ if (config.changesets !== undefined)
136
+ assertChangesetRules(config.changesets, path);
135
137
  if (config.isolation !== undefined && config.isolation !== 'process' && config.isolation !== 'colocated' && config.isolation !== 'worker') {
136
138
  throw new Error(`World config isolation must be "process", "colocated", or "worker" in ${path}`);
137
139
  }
@@ -458,7 +460,7 @@ function serviceV2ToLegacy(value, path) {
458
460
  return legacy;
459
461
  }
460
462
  function normalizeV2(value, path) {
461
- assertKeys(value, ['schemaVersion', 'metadata', 'discovery', 'services', 'runtime', 'serving', 'remotes', 'scenario', 'provenance'], 'World manifest', path, true);
463
+ assertKeys(value, ['schemaVersion', 'metadata', 'discovery', 'services', 'runtime', 'serving', 'remotes', 'changesets', 'scenario', 'provenance'], 'World manifest', path, true);
462
464
  for (const name of ['metadata', 'discovery', 'runtime', 'serving', 'scenario', 'provenance'])
463
465
  if (value[name] !== undefined && !record(value[name]))
464
466
  throw new Error(`${name} must be an object in ${path}`);
@@ -515,6 +517,7 @@ function normalizeV2(value, path) {
515
517
  ...(mode === 'bare' ? { bare: { name: serving.name } } : {}),
516
518
  ...(serving.share === undefined ? {} : { share: serving.share }),
517
519
  ...(value.remotes === undefined ? {} : { remotes: value.remotes }),
520
+ ...(value.changesets === undefined ? {} : { changesets: value.changesets }),
518
521
  ...(scenario.actors === undefined ? {} : { actors: scenario.actors }),
519
522
  ...(scenario.fixtures === undefined ? {} : { fixtures: scenario.fixtures }),
520
523
  ...(provenance.catalog === undefined ? {} : { catalog: provenance.catalog }),
@@ -604,6 +607,7 @@ export function worldConfigDocument(config, selection = config.selection ?? DEFA
604
607
  ...(config.share === undefined ? {} : { share: config.share }),
605
608
  } }),
606
609
  ...(config.remotes === undefined ? {} : { remotes: config.remotes }),
610
+ ...(config.changesets === undefined ? {} : { changesets: config.changesets }),
607
611
  ...(config.actors === undefined && config.fixtures === undefined ? {} : { scenario: {
608
612
  ...(config.actors === undefined ? {} : { actors: config.actors }),
609
613
  ...(config.fixtures === undefined ? {} : { fixtures: config.fixtures }),
@@ -612,3 +616,16 @@ export function worldConfigDocument(config, selection = config.selection ?? DEFA
612
616
  ...Object.fromEntries(Object.entries(config).filter(([key]) => key.startsWith('//'))),
613
617
  };
614
618
  }
619
+ /** A World's changeset rules, strictly: `approvals` a whole number 0–10, `summary` `required` or `generated`, nothing else. */
620
+ export function assertChangesetRules(rules, path) {
621
+ if (typeof rules !== 'object' || rules === null || Array.isArray(rules))
622
+ throw new Error(`World config changesets must be an object in ${path}`);
623
+ for (const key of Object.keys(rules))
624
+ if (key !== 'approvals' && key !== 'summary')
625
+ throw new Error(`World config changesets has no "${key}" (approvals, summary) in ${path}`);
626
+ const { approvals, summary } = rules;
627
+ if (approvals !== undefined && !(Number.isInteger(approvals) && approvals >= 0 && approvals <= 10))
628
+ throw new Error(`World config changesets.approvals must be a whole number from 0 to 10 in ${path}`);
629
+ if (summary !== undefined && summary !== 'required' && summary !== 'generated')
630
+ throw new Error(`World config changesets.summary must be "required" or "generated" in ${path}`);
631
+ }
@@ -3,7 +3,7 @@ import { type TwinStreamConnection, type TwinStreamSink } from '@volter/world-co
3
3
  import { type TrustedIssuer } from '@volter/world-access';
4
4
  import { type Changeset, type Receipt } from '@volter/world-core';
5
5
  import { rootForControlRoot } from './root.js';
6
- import type { WorldInstance } from './schema.js';
6
+ import { type WorldInstance } from './schema.js';
7
7
  export declare const TOKEN_HEADER = "x-volter-token";
8
8
  /** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
9
9
  export declare const TWINS_KEY_HEADER = "x-twins-key";
@@ -63,7 +63,8 @@ export declare function mountWorld(name: string, opts?: {
63
63
  root?: string; /** the host's branches of this World, by its served name */
64
64
  branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */
65
65
  browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
66
- localTrust?: boolean; /** DoorHost.serveBranch */
66
+ localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */
67
+ localPerson?: string; /** DoorHost.serveBranch */
67
68
  serveBranch?: (branch: string) => Promise<void>;
68
69
  }): Promise<MountedWorld>;
69
70
  /** Boot the world (state kept) and serve it on its own port. Announces and returns after boot; `stop` downs the world. */
@@ -138,6 +139,8 @@ export type DoorHost = {
138
139
  * loopback name, is given the World's session without a token (`localSession`). A remote host leaves it absent,
139
140
  * and its pages sign in with a token. */
140
141
  localTrust?: () => boolean;
142
+ /** whom this machine's own page acts as: the person `volter login` signed in here, if any ("Who acted is recorded") */
143
+ localPerson?: () => string | undefined;
141
144
  /** The platforms whose passes open this World (docs/contributing/architecture.md, "The hosted product"): a person a
142
145
  * trusted platform signed a pass for is given a session of the pass's scope. Absent, or empty, no pass opens it. */
143
146
  passIssuers?: () => TrustedIssuer[];
@@ -220,6 +223,9 @@ export declare class WorldDoors {
220
223
  * revoke every key made by or for a person (their leaving the org). */
221
224
  /** The person a key is for: whom it was made for, else the person whose session made it. */
222
225
  private keyPerson;
226
+ /** The person a key acts for, as attribution names them ("Who acted is recorded"): the name it was made for, else the
227
+ * person whose session made it, as their session names them (a pass's email, this machine's person). */
228
+ private keyName;
223
229
  /** Revoke the keys `pick` names, and what they reach: the branches whose parent key goes, and the branches (with their
224
230
  * keys) a revoked key made. Answers the revoked keys' ids. */
225
231
  private revokeKeys;
@@ -276,6 +282,10 @@ export declare class WorldDoors {
276
282
  * (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
277
283
  * this World issued. */
278
284
  private callerOf;
285
+ /** The person a request acts for: a session's person, or the person a key is for; none for this machine's page or a
286
+ * World's own token. What lands in a World others read must name one. */
287
+ private personOf;
288
+ private keyOf;
279
289
  /** The session cookie's name where `url` reached this World, or null where a session opens nothing:
280
290
  * on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
281
291
  * the path-scoped cookie a shared origin has always carried. */