@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.
@@ -31,13 +31,14 @@ import { APP_READ_ENDPOINT_ENV } from "./init.js";
31
31
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
32
32
  import { createHash } from 'node:crypto';
33
33
  import { dirname, join, resolve } from 'node:path';
34
- import { appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow, parentEntries, readBranchMeta, rebaseBranch, } from '@volter/world-core';
34
+ import { appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, changesetReadiness, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow, parentEntries, readBranchMeta, rebaseBranch, } from '@volter/world-core';
35
35
  import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from "./changeset.js";
36
36
  import { fetchFromOrigin } from "./origin.js";
37
37
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart } from "./console-apart.js";
38
- import { loadWorldConfig } from "./configs.js";
38
+ import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from "./configs.js";
39
39
  import { adaptersFor, credentialPath, packMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, rewrapTwinCredentials, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from "./root.js";
40
40
  import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from "./runtime.js";
41
+ import { assertChangesetRules } from "./schema.js";
41
42
  export const TOKEN_HEADER = 'x-volter-token';
42
43
  /** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
43
44
  export const TWINS_KEY_HEADER = 'x-twins-key';
@@ -142,7 +143,7 @@ export async function mountWorld(name, opts = {}) {
142
143
  clockPath: () => clockFile(worldRoot, name),
143
144
  ...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
144
145
  ...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
145
- ...(opts.localTrust ? { localTrust: () => true } : {}),
146
+ ...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
146
147
  ...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
147
148
  ...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers } : {}),
148
149
  // a local twin is its own process: the wire forwards to the port it listens on
@@ -423,7 +424,7 @@ export class WorldDoors {
423
424
  }
424
425
  /** A new session of `scope`: its id, the cookie's value. A token's lasts 30 days; a person's from a pass, `who`,
425
426
  * lasts as long as the pass's platform says (twelve hours), so a person removed there loses it within the day. */
426
- openSession(scope, person, key) {
427
+ openSession(scope, person, key, local) {
427
428
  const id = mintToken('ses_');
428
429
  let held = this.sessions();
429
430
  // a person holds at most five sessions here: passes (anyone's with one) never crowd out everyone else's
@@ -435,7 +436,7 @@ export class WorldDoors {
435
436
  }
436
437
  }
437
438
  const until = Math.min(Date.now() + (person?.ttlMs ?? 30 * 86_400_000), key?.until ?? Infinity);
438
- this.keepSessions({ ...held, [id]: { scope, until, ...(person ? { who: person.who, issuer: person.issuer, ...(person.sub ? { sub: person.sub } : {}) } : {}), ...(key ? { key: key.id } : {}) } });
439
+ this.keepSessions({ ...held, [id]: { scope, until, ...(local ? { local: true } : {}), ...(person ? { who: person.who, ...(person.issuer ? { issuer: person.issuer } : {}), ...(person.sub ? { sub: person.sub } : {}) } : {}), ...(key ? { key: key.id } : {}) } });
439
440
  return id;
440
441
  }
441
442
  /** NAMED KEYS: what an app or a script holds (docs/contributing/architecture.md, "The hosted product"), each named
@@ -466,6 +467,9 @@ export class WorldDoors {
466
467
  * revoke every key made by or for a person (their leaving the org). */
467
468
  /** The person a key is for: whom it was made for, else the person whose session made it. */
468
469
  keyPerson(k) { return k.for ?? (k.createdBy?.via === 'session' ? k.createdBy.sub : undefined); }
470
+ /** The person a key acts for, as attribution names them ("Who acted is recorded"): the name it was made for, else the
471
+ * person whose session made it, as their session names them (a pass's email, this machine's person). */
472
+ keyName(k) { return k.forName ?? k.for ?? (k.createdBy?.via === 'session' ? k.createdBy.who ?? k.createdBy.sub : undefined); }
469
473
  /** Revoke the keys `pick` names, and what they reach: the branches whose parent key goes, and the branches (with their
470
474
  * keys) a revoked key made. Answers the revoked keys' ids. */
471
475
  async revokeKeys(pick) {
@@ -491,7 +495,7 @@ export class WorldDoors {
491
495
  return Response.json({ error: 'a key manages no keys: use the World\'s token, or its page' }, { status: 403 });
492
496
  const now = Date.now();
493
497
  const live = () => this.keys().filter((k) => !k.expiresAt || Date.parse(k.expiresAt) > now);
494
- const view = (k) => ({ id: k.id, name: k.name, scope: k.scope, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt ?? null, expiresAt: k.expiresAt ?? null, for: k.for ?? null, person: this.keyPerson(k) ?? null, branch: k.branch ?? null, createdBy: k.createdBy?.via === 'session' ? (k.createdBy.who ?? k.createdBy.sub ?? 'a person') : k.createdBy?.via === 'key' ? `the key ${k.createdBy.keyId}` : k.createdBy ? 'the World\'s token' : null });
498
+ const view = (k) => ({ id: k.id, name: k.name, scope: k.scope, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt ?? null, expiresAt: k.expiresAt ?? null, for: k.for ?? null, person: this.keyName(k) ?? null, branch: k.branch ?? null, createdBy: k.createdBy?.via === 'session' ? (k.createdBy.who ?? k.createdBy.sub ?? 'a person') : k.createdBy?.via === 'key' ? `the key ${k.createdBy.keyId}` : k.createdBy ? 'the World\'s token' : null });
495
499
  const person = (k) => this.keyPerson(k);
496
500
  if (!id && request.method === 'GET')
497
501
  return Response.json({ keys: live().map(view) });
@@ -500,6 +504,9 @@ export class WorldDoors {
500
504
  const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
501
505
  if (!name)
502
506
  return Response.json({ error: 'a key needs a name: what holds it (an app, a CI job, a laptop)' }, { status: 400 });
507
+ // a key's name is what holds it, never whom it is for: the caller a World records reads `key:<name> for:<person>`
508
+ if (/\sfor:/i.test(name))
509
+ return Response.json({ error: 'a key\'s name says what holds it; whom it is for is its own field' }, { status: 400 });
503
510
  const keyScope = body.scope === 'read' ? 'read' : 'write';
504
511
  // a session a platform's pass opened makes no keys: a key outlives the session, and a script on this origin (a
505
512
  // stored page) could make one in the person's name. Keys for a platform's Worlds are made on the platform.
@@ -507,9 +514,10 @@ export class WorldDoors {
507
514
  if (opener?.issuer)
508
515
  return Response.json({ error: 'a key for this World is made on the platform it was opened from, not from a browser session here', platform: opener.issuer }, { status: 403 });
509
516
  // `for`, `replace` and an end are the token's to give (a platform making a command's key for a person)
510
- if ((body.for !== undefined || body.replace !== undefined) && via !== 'token')
517
+ if ((body.for !== undefined || body.forName !== undefined || body.replace !== undefined) && via !== 'token')
511
518
  return Response.json({ error: 'for and replace are the World token\'s' }, { status: 403 });
512
519
  const forWhom = typeof body.for === 'string' && /^[A-Za-z0-9:._@-]{1,160}$/.test(body.for) ? body.for : undefined;
520
+ const forName = forWhom && typeof body.forName === 'string' && /^[A-Za-z0-9:._@+ -]{1,160}$/.test(body.forName) ? body.forName : undefined;
513
521
  let expiresAt;
514
522
  if (body.expiresAt !== undefined) {
515
523
  const t = typeof body.expiresAt === 'string' ? Date.parse(body.expiresAt) : NaN;
@@ -526,7 +534,7 @@ export class WorldDoors {
526
534
  const opened = via === 'session' && session ? this.sessions()[session] : undefined;
527
535
  const createdBy = via === 'session' ? { via: 'session', ...(opened?.sub ? { sub: opened.sub } : {}), ...(opened?.who ? { who: opened.who } : {}), ...(opened?.issuer ? { issuer: opened.issuer } : {}) } : { via: 'token' };
528
536
  const key = mintToken('tok_k_');
529
- const held = { id: mintToken('key_').slice(0, 20), name, scope: keyScope, hash: keyHash(key), createdAt: new Date(now).toISOString(), createdBy, ...(forWhom ? { for: forWhom } : {}), ...(expiresAt ? { expiresAt } : {}) };
537
+ const held = { id: mintToken('key_').slice(0, 20), name, scope: keyScope, hash: keyHash(key), createdAt: new Date(now).toISOString(), createdBy, ...(forWhom ? { for: forWhom } : {}), ...(forName ? { forName } : {}), ...(expiresAt ? { expiresAt } : {}) };
530
538
  this.keepKeys([...kept, held]);
531
539
  return Response.json({ ...view(held), key }, { status: 201, headers: { 'cache-control': 'no-store' } });
532
540
  }
@@ -692,15 +700,32 @@ export class WorldDoors {
692
700
  const cred = this.credential(request);
693
701
  if (cred.scope === null)
694
702
  return null;
703
+ // this machine's own page with no one signed in here is this machine; another session with no name is just that
695
704
  if (cred.via === 'session')
696
- return cred.who ? `person:${cred.who}` : 'person';
705
+ return cred.who ? `person:${cred.who}` : cred.session && this.sessions()[cred.session]?.local ? 'machine' : 'person';
706
+ // a key acts as itself, for its person (docs/contributing/architecture.md, "Who acted is recorded")
697
707
  if (cred.via === 'key') {
698
- const token = cred.token;
699
- const held = token ? this.keys().find((k) => sameSecret(keyHash(token), k.hash)) : undefined;
700
- return held ? `key:${held.name}` : 'key';
708
+ const held = this.keyOf(cred.token);
709
+ const person = held ? this.keyName(held) : undefined;
710
+ return held ? `key:${held.name}${person ? ` for:${person}` : ''}` : 'key';
701
711
  }
702
712
  return cred.scope === 'read' ? 'token:read' : 'token';
703
713
  }
714
+ /** 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
715
+ * World's own token. What lands in a World others read must name one. */
716
+ personOf(request) {
717
+ const cred = this.credential(request);
718
+ if (cred.scope === null)
719
+ return undefined;
720
+ if (cred.via === 'session')
721
+ return cred.who;
722
+ if (cred.via === 'key') {
723
+ const held = this.keyOf(cred.token);
724
+ return held ? this.keyName(held) : undefined;
725
+ }
726
+ return undefined;
727
+ }
728
+ keyOf(token) { return token ? this.keys().find((k) => sameSecret(keyHash(token), k.hash)) : undefined; }
704
729
  /** The session cookie's name where `url` reached this World, or null where a session opens nothing:
705
730
  * on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
706
731
  * the path-scoped cookie a shared origin has always carried. */
@@ -1225,11 +1250,13 @@ export class WorldDoors {
1225
1250
  // with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
1226
1251
  if (name) {
1227
1252
  const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name });
1228
- return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0] });
1253
+ return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0], readiness: changesetReadiness(l.changeset, worldChangesetRules(this.worldRoot)) });
1229
1254
  }
1230
1255
  const all = listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset);
1231
1256
  const deployed = this.deployedOf(all);
1232
- return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i] })) });
1257
+ const rules = worldChangesetRules(this.worldRoot);
1258
+ // readiness under this World's rules, as deploy reads it: the console says what the kernel decides
1259
+ return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i], readiness: changesetReadiness(c, rules) })) });
1233
1260
  }
1234
1261
  if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
1235
1262
  const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
@@ -1243,7 +1270,8 @@ export class WorldDoors {
1243
1270
  const held = via === 'session' ? this.sessions()[this.credential(request).session ?? ''] : undefined;
1244
1271
  const named = typeof body.as === 'string' && body.as.trim() ? body.as.trim().slice(0, 80) : undefined;
1245
1272
  const tokenSession = held !== undefined && !held.who && held.key === undefined && held.scope === 'write';
1246
- const principal = via === 'session' ? held?.who ?? (this.host.localTrust?.() ? 'this machine' : tokenSession ? named : undefined) : named;
1273
+ // a key approves as itself, for its person, whatever it says: it cannot be several approvers by naming several
1274
+ const principal = via === 'session' ? held?.who ?? (this.host.localTrust?.() ? 'this machine' : tokenSession ? named : undefined) : via === 'key' ? this.callerOf(request) ?? undefined : named;
1247
1275
  if (!principal)
1248
1276
  return Response.json({ error: via === 'session' && !tokenSession ? 'this session names no person to sign as: open the World from its platform, or approve with its token' : 'approve: { as, note? } — name who approves' }, { status: via === 'session' && !tokenSession ? 403 : 400 });
1249
1277
  return Response.json(approveWorldChangeset(name, { root: this.worldRoot, world: this.name, principal, ...(body.note ? { note: body.note } : {}) }));
@@ -1381,11 +1409,53 @@ export class WorldDoors {
1381
1409
  }));
1382
1410
  }
1383
1411
  }
1412
+ // THE RULES DOOR: how this World's changesets are made and readied (`changesets` in its committed config). Read with any
1413
+ // access; changed by a person, this machine or the World's token, never by a key: a key's holder (an app, an agent)
1414
+ // does not relax the review its own changes go through
1415
+ if (kind === 'rules' && !name) {
1416
+ const path = join(this.worldRoot, stateDirName(), 'world.json');
1417
+ if (request.method === 'GET')
1418
+ return Response.json({ world: this.served, changesets: worldChangesetRules(this.worldRoot) });
1419
+ if (request.method === 'PUT') {
1420
+ if (scope !== 'write')
1421
+ return Response.json({ error: 'the rules are changed with write access' }, { status: 403 });
1422
+ if (via === 'key')
1423
+ return Response.json({ error: 'a key changes no rules: a person sets them, or the World\'s token' }, { status: 403 });
1424
+ const body = (await request.json().catch(() => null));
1425
+ try {
1426
+ assertChangesetRules(body?.changesets, path);
1427
+ }
1428
+ catch (error) {
1429
+ return Response.json({ error: error instanceof Error ? error.message.replace(` in ${path}`, '') : String(error) }, { status: 400 });
1430
+ }
1431
+ const { config } = loadWorldConfig(path, this.worldRoot);
1432
+ const rules = body.changesets;
1433
+ const next = { ...config, changesets: { ...(rules.approvals === undefined ? {} : { approvals: rules.approvals }), ...(rules.summary === undefined ? {} : { summary: rules.summary }) } };
1434
+ writeWorldConfig(path, next);
1435
+ return Response.json({ world: this.served, changesets: next.changesets });
1436
+ }
1437
+ }
1384
1438
  if (kind === 'changesets' && !name && request.method === 'POST') {
1385
1439
  const body = (await request.json().catch(() => ({})));
1386
1440
  const slug = body.message ? body.message.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) : '';
1387
- const cut = body.name ?? (slug || `changeset-${Date.now()}`);
1388
- return Response.json(await this.serialized('push', async () => createWorldChangeset(this.name, cut, { root: this.worldRoot, ...(body.message ? { message: body.message } : {}) })));
1441
+ // with no summary to name it, the next free `changeset-<n>`, as the command names one
1442
+ const taken = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset.name));
1443
+ let n = taken.size + 1;
1444
+ while (taken.has(`changeset-${n}`))
1445
+ n += 1;
1446
+ const cut = body.name ?? (slug || `changeset-${n}`);
1447
+ // a cut says what it does and why, unless this World's rules take the summary generated from its changes
1448
+ if (!body.message?.trim() && worldChangesetRules(this.worldRoot).summary !== 'generated')
1449
+ return Response.json({ error: 'a cut needs its summary: what it does and why' }, { status: 400 });
1450
+ const cutBy = this.callerOf(request) ?? undefined;
1451
+ const owner = this.personOf(request);
1452
+ return await this.serialized('push', async () => {
1453
+ // with no summary, a cut must have something to say: an empty one is a slip, refused as the command refuses it
1454
+ // (inside the lock, so two cuts at once never both take the same changes)
1455
+ if (!body.message?.trim() && diffWorld(this.name, { root: this.worldRoot }).actions.length === 0)
1456
+ return Response.json({ error: 'nothing to cut: every change is already in a changeset' }, { status: 400 });
1457
+ return Response.json(createWorldChangeset(this.name, cut, { root: this.worldRoot, ...(body.message?.trim() ? { message: body.message.trim() } : {}), ...(cutBy ? { cutBy } : {}), ...(owner ? { owner } : {}) }));
1458
+ });
1389
1459
  }
1390
1460
  // THE BLOBS DOOR: a twin's resource payloads (uploaded media, file bytes) by their key under its
1391
1461
  // resources directory. A push sends the ones its branch holds and this World lacks before the
@@ -1573,7 +1643,9 @@ export class WorldDoors {
1573
1643
  const own = this.host.browserOrigin?.() ?? null;
1574
1644
  if (this.sessionCookieName(url) === null)
1575
1645
  return Response.json({ error: `${this.served}'s browser session lives at its own origin`, origin: own }, { status: 409 });
1576
- return Response.json({ world: this.served, scope: 'write', local: true, ...(own ? { origin: own } : {}) }, { headers: { 'set-cookie': this.sessionCookie(url, this.openSession('write')) } });
1646
+ // this machine's page acts as the person signed in here, when one is (`volter login`); else as this machine
1647
+ const person = this.host.localPerson?.();
1648
+ return Response.json({ world: this.served, scope: 'write', local: true, ...(person ? { who: person } : {}), ...(own ? { origin: own } : {}) }, { headers: { 'set-cookie': this.sessionCookie(url, this.openSession('write', person ? { who: person, ttlMs: 30 * 86_400_000 } : undefined, undefined, true)) } });
1577
1649
  }
1578
1650
  session(request, url, scope, key) {
1579
1651
  const own = this.host.browserOrigin?.() ?? null;
@@ -1859,10 +1931,18 @@ export class WorldDoors {
1859
1931
  }
1860
1932
  /** PUSH: see `landChangeset` — the door hands the body to it. */
1861
1933
  async push(request) {
1934
+ // who pushed it is recorded, a person or a service (a key, the World's token): an automation pushes as itself
1935
+ // (docs/contributing/architecture.md, "Who acted is recorded")
1936
+ const person = this.personOf(request);
1862
1937
  const body = (await request.json());
1863
- const changeset = 'changeset' in body && !('actions' in body) ? body.changeset : body;
1864
- if (!changeset || !Array.isArray(changeset.actions))
1938
+ const sent = 'changeset' in body && !('actions' in body) ? body.changeset : body;
1939
+ if (!sent || !Array.isArray(sent.actions))
1865
1940
  return Response.json({ error: 'push: a changeset object' }, { status: 400 });
1941
+ // who pushed it, and its owner when it came with none and the pusher names a person: outside its hash, so nothing an
1942
+ // approval bound to moves
1943
+ const pushedBy = this.callerOf(request) ?? undefined;
1944
+ const owner = sent.owner ?? person;
1945
+ const changeset = { ...sent, ...(owner ? { owner } : {}), ...(pushedBy ? { pushedBy } : {}) };
1866
1946
  this.refresh();
1867
1947
  // fast-forward only: the client says where it last saw each twin's log (its fetch cursor); a
1868
1948
  // twin that moved past it refuses with the drift — fetch, rebase, push again
@@ -1872,7 +1952,14 @@ export class WorldDoors {
1872
1952
  const receipts = await landChangeset(this.name, this.worldRoot, this.up(), changeset, expect);
1873
1953
  const position = Object.fromEntries([...new Set(changeset.actions.map((a) => a.service))].map((twin) => [twin, positionOf(twin)]));
1874
1954
  const snapshots = Object.fromEntries(Object.keys(position).map(twin => { const head = captureHistory(this.stateOf(twin), this.controlRoot(twin)); return [twin, { view: head.view, position: head.position }]; }));
1875
- return Response.json({ world: this.served, changeset: changeset.name, receipts, position, snapshots });
1955
+ // who owns and pushed it, as this World keeps it: a changeset landed before keeps its first record
1956
+ const kept = (() => { try {
1957
+ return JSON.parse(getActiveWorldStore().read(join(worldChangesetsDir(this.name, this.worldRoot), `${changeset.name}.json`)) ?? 'null');
1958
+ }
1959
+ catch {
1960
+ return null;
1961
+ } })();
1962
+ return Response.json({ world: this.served, changeset: changeset.name, receipts, position, snapshots, ...(kept?.owner ? { owner: kept.owner } : {}), ...(kept?.pushedBy ? { pushedBy: kept.pushedBy } : {}) });
1876
1963
  }
1877
1964
  catch (error) {
1878
1965
  return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
@@ -16,6 +16,7 @@ export type WorldView = {
16
16
  stop: () => Promise<void>;
17
17
  };
18
18
  export declare function serveWorldView(name: string, opts?: {
19
+ localPerson?: string;
19
20
  root?: string;
20
21
  port?: number;
21
22
  host?: string;
@@ -48,7 +48,7 @@ export async function serveWorldView(name, opts = {}) {
48
48
  // (the first World mounted is the World; every later one is a branch of it)
49
49
  let base = null;
50
50
  const browserOrigin = origins ? (served) => (proxied && (base === null || served === base) ? proxied.origin : port ? localWorldOrigin(served, port) : null) : undefined;
51
- const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
51
+ const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
52
52
  const branches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
53
53
  // THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
54
54
  // same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
@@ -90,7 +90,7 @@ export async function serveWorldView(name, opts = {}) {
90
90
  switching = false;
91
91
  }
92
92
  };
93
- const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}), serveBranch };
93
+ const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
94
94
  let world = await mountWorld(name, mountOpts);
95
95
  base = world.served;
96
96
  worlds.set(world.served, world);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.49",
3
+ "version": "2.0.51",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -67,14 +67,14 @@
67
67
  "node": ">=22.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@volter/world-core": "2.0.31",
71
- "@volter/world-console": "2.0.13"
70
+ "@volter/world-core": "2.0.33",
71
+ "@volter/world-console": "2.0.14"
72
72
  },
73
73
  "dependencies": {
74
74
  "@electric-sql/pglite": "0.5.8",
75
75
  "@electric-sql/pglite-pgvector": "0.0.9",
76
76
  "@volter/world-access": "2.0.2",
77
- "@volter/world-core": "2.0.31",
77
+ "@volter/world-core": "2.0.33",
78
78
  "pg-gateway": "0.3.0-beta.4",
79
79
  "smol-toml": "^1.8.0"
80
80
  },
@@ -84,6 +84,6 @@
84
84
  }
85
85
  },
86
86
  "optionalDependencies": {
87
- "@volter/twin-mongodb": "0.1.31"
87
+ "@volter/twin-mongodb": "0.1.33"
88
88
  }
89
89
  }
package/src/changeset.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { worldChangesetRules } from './configs.ts';
1
2
  import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, getActiveWorldStore, originHead, withAncestryLock, worldPaths, type HistoryReference } 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
@@ -202,7 +203,7 @@ export type ChangesetLocation = { changeset: Changeset; world: string; path: str
202
203
  export function createWorldChangeset(
203
204
  world: string,
204
205
  name: string,
205
- options: WorldRootOptions & { base?: string; verifiers?: ChangesetVerifier[]; now?: Date; overwrite?: boolean; message?: string } = {},
206
+ options: WorldRootOptions & { base?: string; verifiers?: ChangesetVerifier[]; now?: Date; overwrite?: boolean; message?: string; /** who cuts it and the person it is owned by, from the cutting grant (a served World's door) */ cutBy?: string; owner?: string } = {},
206
207
  ): Changeset {
207
208
  const root = resolveRoot(options.root);
208
209
  assertSafeChangesetName(name);
@@ -227,6 +228,8 @@ export function createWorldChangeset(
227
228
  ...(options.verifiers ? { verifiers: options.verifiers } : {}),
228
229
  ...(options.now ? { createdAt: options.now.toISOString() } : {}),
229
230
  ...(options.message === undefined ? {} : { message: options.message }),
231
+ ...(options.cutBy === undefined ? {} : { cutBy: options.cutBy }),
232
+ ...(options.owner === undefined ? {} : { owner: options.owner }),
230
233
  });
231
234
  getActiveWorldStore().write(path, `${JSON.stringify(changeset, null, 2)}\n`);
232
235
  // a changeset records where the log was cut (contract "The model"): the next one starts here
@@ -447,7 +450,7 @@ export function statusWorldChangeset(
447
450
  ): ChangesetReadiness {
448
451
  const root = resolveRoot(options.root);
449
452
  const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
450
- return changesetReadiness(located.changeset);
453
+ return changesetReadiness(located.changeset, worldChangesetRules(root));
451
454
  }
452
455
 
453
456
  // ── governed push: `volter-world changeset push` ─────────────────────────────────────────
@@ -481,7 +484,7 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
481
484
  const namespace = options.namespace ?? instance.origin?.namespace;
482
485
  if (!url || !namespace) throw new Error(`World "${located.world}" has no origin to push to — \`volter remote add origin <url> --token <token>\` names one`);
483
486
  // over http to a served world's push door, or straight into a world at a path (git's file transport)
484
- let res: { ok: boolean; status: number }; let body: { receipts?: Array<{ actionId: string; twin: string } & Receipt>; error?: string; refusal?: string[]; position?: Record<string, number>; snapshots?: Record<string, HistoryReference> };
487
+ let res: { ok: boolean; status: number }; let body: { receipts?: Array<{ actionId: string; twin: string } & Receipt>; error?: string; refusal?: string[]; position?: Record<string, number>; snapshots?: Record<string, HistoryReference>; /** whom the remote records as its owner and as who pushed it */ owner?: string; pushedBy?: string };
485
488
  const expect: Record<string, HistoryReference> = {};
486
489
  for (const a of located.changeset.actions) {
487
490
  const meta = readBranchMeta(a.stateService ?? a.service, join(instance.dirs.data, a.service));
@@ -597,7 +600,8 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
597
600
  const receipts: ApplyReceipt[] = 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 } : {}) }));
598
601
  const outcome: ChangesetApplication['outcome'] = receipts.every((r) => r.status !== 'failed') ? 'applied' : receipts.some((r) => r.status === 'confirmed') ? 'partial' : 'refused';
599
602
  const application: ChangesetApplication = { at, contentHash: located.changeset.contentHash, outcome, receipts, compensation: [], ...(outcome === 'refused' ? { refusal: receipts.filter((r) => r.error).map((r) => r.error!) } : {}) };
600
- const changeset = withApplication(located.changeset, application);
603
+ // the remote's record of who pushed it and who owns it, kept on this copy too (outside the hash)
604
+ const changeset = { ...withApplication(located.changeset, application), ...(body.owner && !located.changeset.owner ? { owner: body.owner } : {}), ...(body.pushedBy ? { pushedBy: body.pushedBy } : {}) };
601
605
  writeChangesetFile(located.path, changeset);
602
606
  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;
603
607
  return { ...(unsent ? { unsent } : {}), application, changeset, world: located.world, path: located.path, remote: { url, namespace } };
package/src/configs.ts CHANGED
@@ -1,7 +1,7 @@
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';
4
- import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument, type WorldConfig } from './schema.ts';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { getActiveWorldStore, stateDirName } from '@volter/world-core';
4
+ import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument, type WorldChangesetRules, type WorldConfig } from './schema.ts';
5
5
 
6
6
  export function resolveConfigPath(config: string, root = process.cwd()): string {
7
7
  const candidates = config.includes('/') || config.endsWith('.json')
@@ -108,3 +108,11 @@ export function migrateWorldConfig(config: string, root = process.cwd()): WorldC
108
108
  dropped: normalized.resources === undefined ? [] : ['resources (deprecated; retained in backup)'],
109
109
  };
110
110
  }
111
+
112
+ /** The changeset rules of the World rooted at `root` (its committed `.volter/world.json`, `changesets`); none when it
113
+ * has no config or names none, which readiness reads as one approval and a required summary. */
114
+ export function worldChangesetRules(root: string): WorldChangesetRules {
115
+ const path = join(root, stateDirName(), 'world.json');
116
+ if (!getActiveWorldStore().exists(path) && !existsSync(path)) return {};
117
+ try { return loadWorldConfig(path, root).config.changesets ?? {}; } catch { return {}; }
118
+ }
@@ -210,6 +210,10 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
210
210
  { match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
211
211
  { match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
212
212
  { match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
213
+ // The slack twin answers a bearer that is none of Slack's token kinds with `invalid_auth`, as Slack does; any `xoxb-`
214
+ // acts as the workspace bot, and an app-level token is `xapp-`.
215
+ { match: /(^|_)SLACK_[A-Z0-9_]*APP_TOKEN$/, value: () => 'xapp-1-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/)' },
216
+ { 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)' },
213
217
  // tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
214
218
  { match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
215
219
  { match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
package/src/index.ts CHANGED
@@ -18,7 +18,8 @@ export type {
18
18
  WorldUsage,
19
19
  } from './schema.ts';
20
20
  export { DEFAULT_WORLD_SELECTION, LEGACY_WORLD_SELECTION, WORLD_SCHEMA_VERSION, WORLD_USAGES, worldConfigDocument, worldSelectionIncludes } from './schema.ts';
21
- export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, writeWorldConfig } from './configs.ts';
21
+ export type { WorldChangesetRules } from './schema.ts';
22
+ export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, worldChangesetRules, writeWorldConfig } from './configs.ts';
22
23
  export type { WorldConfigMigration } from './configs.ts';
23
24
  export {
24
25
  upWorld,
package/src/root.ts CHANGED
@@ -14,7 +14,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
14
14
  import {
15
15
  NO_SECRETS_CHECK, authStrategyFor, currentSealer, destinationOf, sealsThroughVault, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, observeAppends, readRoot, registerStateSystem, stateDirName, stateSystemFor, type Check, type CredentialPayload, type PerformAction, type PushReport, type RemoteExecute, type RootConfig, type SealedCredential, type TwinAction, validateRemoteOrigin, writeRoot,
16
16
  } from '@volter/world-core';
17
- import { loadWorldConfig, writeWorldConfig } from './configs.ts';
17
+ import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from './configs.ts';
18
18
  import { changesetReadiness, collectObservations, type Observation, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding, userKek, type DeployReport, type ObserveReport } from '@volter/world-core';
19
19
  import { findWorldChangeset, listWorldChangesets } from './changeset.ts';
20
20
  import { importModule } from './import-module.ts';
@@ -501,7 +501,7 @@ export async function deployWorld(name: string, opts: { root?: string; changeset
501
501
  const out: DeployTwinOutcome[] = [];
502
502
  if (!opts.changeset) {
503
503
  // no name: every ready changeset, by name, in order — so each outcome says which it was for
504
- 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));
504
+ 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));
505
505
  const anyGated = Object.keys(inst.services).some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
506
506
  if (anyGated || ready.length) {
507
507
  for (const l of ready) out.push(...(await deployWorld(name, { ...opts, changeset: l.changeset.name })).map((o) => ({ ...o, changeset: l.changeset.name })));
@@ -511,7 +511,7 @@ export async function deployWorld(name: string, opts: { root?: string; changeset
511
511
  const located = opts.changeset ? [findWorldChangeset(opts.changeset, { root: worldRoot, world: name })] : listWorldChangesets({ root: worldRoot, world: name });
512
512
  const ready = new Map<string, string[]>();
513
513
  for (const l of located) {
514
- const readiness = changesetReadiness(l.changeset);
514
+ const readiness = changesetReadiness(l.changeset, worldChangesetRules(worldRoot));
515
515
  if (!readiness.ready) {
516
516
  const gated = [...new Set(l.changeset.actions.map((e) => e.service))].some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
517
517
  if (opts.changeset && gated) throw new Error(`changeset ${opts.changeset} is not ready: ${readiness.reasons.join('; ')}`);
package/src/runtime.ts CHANGED
@@ -1347,8 +1347,8 @@ async function startColocatedServices(
1347
1347
  paths: { root: string; instance: string; logs: string; data: string },
1348
1348
  allocatedPorts: Set<number>,
1349
1349
  boot: BootContext,
1350
- ): Promise<WorldServiceInstance[]> {
1351
- if (colocated.length === 0) return [];
1350
+ ): Promise<{ services: WorldServiceInstance[]; ready: Promise<void> }> {
1351
+ if (colocated.length === 0) return { services: [], ready: Promise.resolve() };
1352
1352
  const hostCli = siblingScript(import.meta.url, 'host-cli');
1353
1353
  const readyFile = join(paths.instance, `host-ready-${boot.bootId}.json`);
1354
1354
  const args: string[] = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker', '--ready-file', readyFile];
@@ -1389,42 +1389,10 @@ async function startColocatedServices(
1389
1389
  });
1390
1390
 
1391
1391
  const command = [process.execPath, ...args];
1392
- const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
1393
- 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) => {
1394
- if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
1395
- try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1396
- throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1397
- });
1398
- await parallelStartup(allocated, async ({ service, port }) => {
1399
- const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1400
- await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
1401
- if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1402
- try { process.kill(-child.pid!, 'SIGTERM'); } 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: ReturnType<typeof setTimeout> | undefined;
1407
- const hostSettle = new Promise<'alive'>((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) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1414
- try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1415
- throw error;
1416
- });
1417
- });
1418
- } finally { if (hostSettleTimer !== undefined) clearTimeout(hostSettleTimer); }
1419
- if (exitState.value !== null) {
1420
- throw new Error(
1421
- `The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
1422
- + `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`,
1423
- );
1424
- }
1425
-
1426
1392
  const host = '127.0.0.1';
1427
- return allocated.map(({ service, port, mounted }) => {
1393
+ // Every twin's address is known once its port is allocated: the records and
1394
+ // the environment they inject exist before the host reports its starts.
1395
+ const services = allocated.map(({ service, port, mounted }) => {
1428
1396
  const url = `http://${host}:${port}`;
1429
1397
  return {
1430
1398
  id: service.id,
@@ -1443,6 +1411,45 @@ async function startColocatedServices(
1443
1411
  },
1444
1412
  };
1445
1413
  });
1414
+ const ready = (async (): Promise<void> => {
1415
+ const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
1416
+ 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) => {
1417
+ if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
1418
+ try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1419
+ throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
1420
+ });
1421
+ await parallelStartup(allocated, async ({ service, port }) => {
1422
+ const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
1423
+ await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
1424
+ if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
1425
+ try { process.kill(-child.pid!, 'SIGTERM'); } 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' as const);
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) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
1438
+ try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
1439
+ throw error;
1440
+ });
1441
+ });
1442
+ if (exitState.value !== null) {
1443
+ throw new Error(
1444
+ `The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
1445
+ + `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`,
1446
+ );
1447
+ }
1448
+ })();
1449
+ // Observed here so a failure before the caller awaits it is not unhandled;
1450
+ // the caller awaits `ready` before the World is up.
1451
+ ready.catch(() => undefined);
1452
+ return { services, ready };
1446
1453
  }
1447
1454
 
1448
1455
  export async function upWorld(configId: string, options: UpWorldOptions = {}, abort?: AbortSignal): Promise<WorldInstance> {
@@ -1669,12 +1676,19 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1669
1676
  const allocatedPorts = new Set(loaded.config.services.flatMap((service) => typeof service.port === 'number' ? [service.port] : []));
1670
1677
  // Non-'process' isolation: boot every colocate-declaring service inside ONE host child
1671
1678
  // first; anything without `colocate` (and externals) still goes through the spawn path.
1679
+ // The co-located host's twins are known by address once their ports are
1680
+ // allocated. A twin serves and calls no other service while it starts,
1681
+ // so a later twin declaration starts beside the host's startup; any other
1682
+ // service waits until every earlier one is ready, as declared (ADR-0009).
1683
+ let colocatedReady: Promise<void> | undefined;
1672
1684
  if (isolation !== 'process') {
1673
1685
  const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
1674
- for (const started of await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot)) {
1686
+ const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
1687
+ for (const started of host.services) {
1675
1688
  services[started.id] = started;
1676
1689
  Object.assign(env, started.env);
1677
1690
  }
1691
+ colocatedReady = host.ready;
1678
1692
  recordPidsSoFar();
1679
1693
  writeProxyEnv(root, name, env);
1680
1694
  }
@@ -1706,6 +1720,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1706
1720
  };
1707
1721
  for (const service of loaded.config.services) {
1708
1722
  if (services[service.id]) continue; // already up co-located
1723
+ if (colocatedReady && (service.type ?? 'twin') !== 'twin') { await colocatedReady; colocatedReady = undefined; }
1709
1724
  attemptProxy();
1710
1725
  boot.signal.throwIfAborted();
1711
1726
  const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
@@ -1714,6 +1729,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
1714
1729
  Object.assign(env, started.env);
1715
1730
  writeProxyEnv(root, name, env);
1716
1731
  }
1732
+ if (colocatedReady) await colocatedReady;
1717
1733
  detachBootSignals();
1718
1734
  // Re-evaluate once more after the LAST service: a single/last-twin world's OWN vendor twin
1719
1735
  // only lands in `env` once IT has started, so a check that only runs "before starting the