@volter/world-core 3.0.138 → 3.0.139

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.
@@ -1,6 +1,7 @@
1
1
  import { type HistoryReference } from './history.js';
2
2
  import type { TwinActionRevertSpec } from './actions.js';
3
3
  import type { TwinAction, TwinActionPrecondition } from './actions.js';
4
+ import { type FieldChange } from './hash.js';
4
5
  /** One twin's action ledger, addressed the way the control plane addresses state:
5
6
  * `worldPaths(stateService, controlRoot)`. `service` is the caller's label for it (in a world:
6
7
  * the world service id); `stateService` is the control-plane service whose ledger this is.
@@ -60,7 +61,10 @@ export type LedgerDelta = {
60
61
  actions: ChangesetAction[];
61
62
  vendors: VendorSummary[];
62
63
  total: number;
64
+ /** Presentation only, outside changeset bodies: fields immediately before and after each write. */
65
+ fieldChanges?: Record<string, Record<string, FieldChange>>;
63
66
  };
67
+ export declare const fieldDiffKey: (service: string, stateService: string, id: string) => string;
64
68
  /** A verifier: one deterministic check against post-replay projected state — the CI assertion
65
69
  * of the operational PR (docs/concepts/the-model.md v1). The `assert` is the kernel's ONE check
66
70
  * expression (`TwinActionPrecondition`: subject/field/op/value, evaluated by
@@ -189,6 +193,8 @@ export declare function diffLedgers(opts: {
189
193
  ledgers: LedgerRef[];
190
194
  base: WorldMarker;
191
195
  }): LedgerDelta;
196
+ /** Read each write's pre-state through the authoritative kernel projection. No UI history fold. */
197
+ export declare function describeActionFields(ledgers: LedgerRef[], actions: ChangesetAction[]): NonNullable<LedgerDelta['fieldChanges']>;
192
198
  /** Group a delta by vendor, then by operation — both in FIRST-OCCURRENCE order, so the rollup
193
199
  * reads in the same causal order as the actions themselves. */
194
200
  export declare function summarizeByVendor(actions: ChangesetAction[]): VendorSummary[];
@@ -403,6 +409,11 @@ export type CompensationEntry = {
403
409
  export type ChangesetApplication = {
404
410
  at: string;
405
411
  contentHash: string;
412
+ /** A push receipt names its destination; absent on legacy and non-push applications. */
413
+ destination?: {
414
+ url: string;
415
+ namespace: string;
416
+ };
406
417
  outcome: 'applied' | 'partial' | 'refused';
407
418
  forced?: boolean;
408
419
  refusal?: string[];
@@ -20,7 +20,7 @@ import { worldPaths } from "./storage.js";
20
20
  // silently reporting the wrong delta.
21
21
  //
22
22
  // DELTA every action appended after the marker, across every ledger, ordered by
23
- // `occurredAt` (the same causal order `volter-world tail` uses), grouped by vendor
23
+ // `occurredAt` with stable ledger/row ties (a timeline, not cross-vendor causality), grouped by vendor
24
24
  // for rendering.
25
25
  //
26
26
  // CHANGESET a delta frozen into a content-addressed object: `{id, world, base, actions,
@@ -57,14 +57,15 @@ import { worldPaths } from "./storage.js";
57
57
  // the control root that holds it) and never learns what a world is. `volter-world` supplies
58
58
  // discovery and the CLI verbs; the state-level logic lives here, beside the ledgers it reads.
59
59
  import { createHash } from 'node:crypto';
60
- import { aliasesFrom } from "./log.js";
60
+ import { aliasesFrom, readTreeMap, wholeLog } from "./log.js";
61
61
  import { appendActionIfAbsent, checkPrecondition, listActions, projectedPreconditionValue, projectResources, isTwinBookkeeping } from "./actions.js";
62
- import { canonicalJson, hashFieldValue } from "./hash.js";
62
+ import { canonicalJson, diffSubjectFields, hashFieldValue } from "./hash.js";
63
63
  import { applyTwinWrite } from "./serve.js";
64
64
  export const MARKER_KIND = 'volter.world.marker.v1';
65
65
  export const CHANGESET_KIND = 'volter.world.changeset.v1';
66
66
  /** The id reserved for the synthetic "everything this world has ever recorded" base. */
67
67
  export const WORLD_BOOT_MARKER_ID = 'world-boot';
68
+ export const fieldDiffKey = (service, stateService, id) => JSON.stringify([service, stateService, id]);
68
69
  /** Names that address a file on disk (markers, changesets): no separators, no traversal. */
69
70
  const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
70
71
  export function assertSafeChangesetName(name, what = 'changeset') {
@@ -155,7 +156,36 @@ export function diffLedgers(opts) {
155
156
  return a.rowIndex - b.rowIndex;
156
157
  });
157
158
  const actions = ordered.map((o) => o.entry);
158
- return { world: opts.world, base: opts.base, actions, vendors: summarizeByVendor(actions), total: actions.length };
159
+ return { world: opts.world, base: opts.base, actions, vendors: summarizeByVendor(actions), total: actions.length, fieldChanges: describeActionFields(opts.ledgers, actions) };
160
+ }
161
+ /** Read each write's pre-state through the authoritative kernel projection. No UI history fold. */
162
+ export function describeActionFields(ledgers, actions) {
163
+ const fieldChanges = {};
164
+ for (const ledger of ledgers) {
165
+ const entries = wholeLog(ledger.stateService, ledger.controlRoot);
166
+ // An observation is atomic: its internal positions cannot be projected. Compare its
167
+ // writes with the state at the start of that batch, through the same kernel reader.
168
+ const positions = new Map();
169
+ let batchStart = 0;
170
+ entries.forEach((e, i) => {
171
+ if (!e.batch || e.batch !== entries[i - 1]?.batch)
172
+ batchStart = i;
173
+ if (!positions.has(e.id))
174
+ positions.set(e.id, e.batch ? batchStart : i);
175
+ });
176
+ for (const { action, service, stateService } of actions.filter(a => a.service === ledger.service && a.stateService === ledger.stateService)) {
177
+ const at = positions.get(action.id);
178
+ if (at === undefined || !action.fields)
179
+ continue;
180
+ const subject = readTreeMap(stateService, ledger.controlRoot, { at }).get(`${action.subject.type}:${action.subject.id}`);
181
+ const fields = subject?.fields ?? {};
182
+ fieldChanges[fieldDiffKey(service, stateService, action.id)] = diffSubjectFields({
183
+ subject: action.subject, fields, fieldHashes: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, hashFieldValue(v)])),
184
+ latestEventId: '', updatedAt: '',
185
+ }, action.fields);
186
+ }
187
+ }
188
+ return fieldChanges;
159
189
  }
160
190
  /** Group a delta by vendor, then by operation — both in FIRST-OCCURRENCE order, so the rollup
161
191
  * reads in the same causal order as the actions themselves. */
@@ -198,6 +228,11 @@ export function formatLedgerDelta(delta, opts = {}) {
198
228
  for (const vendor of delta.vendors) {
199
229
  const parts = vendor.operations.map((o) => `${o.count} ${o.operation}${renderIds(o.subjectIds)}`);
200
230
  lines.push(` ${vendor.service}: ${parts.join(', ')}`);
231
+ for (const item of delta.actions.filter(a => a.service === vendor.service)) {
232
+ for (const [field, change] of Object.entries(delta.fieldChanges?.[fieldDiffKey(item.service, item.stateService, item.action.id)] ?? {})) {
233
+ lines.push(` ${item.action.subject.type}:${item.action.subject.id}.${field}: ${change.before === undefined ? '(absent)' : JSON.stringify(change.before)} → ${JSON.stringify(change.after)}`);
234
+ }
235
+ }
201
236
  }
202
237
  lines.push('', ` ${plural(delta.total, 'action')} across ${plural(delta.vendors.length, 'twin')}`);
203
238
  return `${lines.join('\n')}\n`;
@@ -679,13 +679,18 @@ async function keptFiles(m, call, root) {
679
679
  }
680
680
  return out;
681
681
  }
682
+ /** Presentation tag only: keep provisioning in history without copying credential request data. */
683
+ function provisioningInput(call) {
684
+ return call.request.headers.get('x-volter-world-activity') === 'provisioning' ? { input: { activity: 'provisioning' } } : {};
685
+ }
682
686
  function recordedInput(m, call, merged) {
683
687
  const id = call.operation.id;
684
688
  const sent = sentOf.get(call);
685
- const asSent = sent ? { path: sent.path, query: sent.query, body: sent.body, headers: sent.headers, ...(sent.lane ? { lane: sent.lane } : {}), ...(sent.requestId ? { requestId: sent.requestId } : {}), ...(sent.type ? { contentType: sent.type } : {}) } : { path: call.params, body: merged };
689
+ const activity = call.request.headers.get('x-volter-world-activity') === 'provisioning' ? { activity: 'provisioning' } : {};
690
+ const asSent = sent ? { ...activity, path: sent.path, query: sent.query, body: sent.body, headers: sent.headers, ...(sent.lane ? { lane: sent.lane } : {}), ...(sent.requestId ? { requestId: sent.requestId } : {}), ...(sent.type ? { contentType: sent.type } : {}) } : { ...activity, path: call.params, body: merged };
686
691
  if (id === 'graphql') {
687
692
  const b = (sent?.body ?? {});
688
- return { graphql: { query: b.query, variables: b.variables ?? {}, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: sent?.headers ?? {} };
693
+ return { ...activity, graphql: { query: b.query, variables: b.variables ?? {}, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: sent?.headers ?? {} };
689
694
  }
690
695
  if (id === 'clock')
691
696
  return { clock: true };
@@ -1381,7 +1386,7 @@ export async function contextFor(m, call, scope) {
1381
1386
  return mintId(m, resource, root, at);
1382
1387
  const { value } = await applyTwinWriteAtomic(m.service, (snapshot) => {
1383
1388
  const id = mintId(m, resource, root, at, snapshot);
1384
- return { kind: 'write', value: id, write: { operation: `${resource.slice(1)}.issue`, subjectType: storedType(m, resource), subjectId: id, fields: { issuedAt: at }, occurredAt: at, actor: { kind: 'system' } } };
1389
+ return { kind: 'write', value: id, write: { operation: `${resource.slice(1)}.issue`, subjectType: storedType(m, resource), subjectId: id, fields: { issuedAt: at }, ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' } } };
1385
1390
  }, root);
1386
1391
  return value;
1387
1392
  },
@@ -1392,7 +1397,7 @@ export async function contextFor(m, call, scope) {
1392
1397
  throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
1393
1398
  if (id !== undefined) {
1394
1399
  const before = operation ? stateBeforeWrite(m, type, id, root) : undefined;
1395
- const { result } = await applyTwinWrite(m.service, { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: id, fields, occurredAt: at, actor: { kind: 'system' } }, root);
1400
+ const { result } = await applyTwinWrite(m.service, { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: id, fields, ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' } }, root);
1396
1401
  if (operation)
1397
1402
  observeStateWrite(m, result, before, root, stateChecks);
1398
1403
  return id;
@@ -1411,7 +1416,7 @@ export async function contextFor(m, call, scope) {
1411
1416
  }
1412
1417
  return `${prefix}${max + 1n}`;
1413
1418
  })();
1414
- return { kind: 'write', value: subject, write: { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } } };
1419
+ return { kind: 'write', value: subject, write: { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' } } };
1415
1420
  }, root, () => resourcesOfType(m.service, type, root));
1416
1421
  if (operation && result)
1417
1422
  observeStateWrite(m, result, undefined, root, stateChecks);
@@ -1488,7 +1493,7 @@ export async function contextFor(m, call, scope) {
1488
1493
  const w = d.write;
1489
1494
  if (transitionObserver)
1490
1495
  before = resources.find((r) => r.type === storedType(m, w.resource) && r.id === w.id);
1491
- return { kind: 'write', value: d.value, write: { operation: w.operation, subjectType: storedType(m, w.resource), subjectId: w.id, fields: w.fields, occurredAt: at, actor: { kind: 'agent' } } };
1496
+ return { kind: 'write', value: d.value, write: { operation: w.operation, subjectType: storedType(m, w.resource), subjectId: w.id, fields: w.fields, ...provisioningInput(call), occurredAt: at, actor: { kind: 'agent' } } };
1492
1497
  }, root);
1493
1498
  if (result)
1494
1499
  observeStateWrite(m, result, before, root, stateChecks);
package/dist/src/log.js CHANGED
@@ -31,6 +31,8 @@ import { hashFieldValue } from "./hash.js";
31
31
  import { worldPaths } from "./storage.js";
32
32
  import { getActiveWorldStore } from "./world-store.js";
33
33
  export const CHECKPOINT_EVERY = 100;
34
+ // Retained checkpoints and process memos are disposable projections, never history.
35
+ const TREE_PROJECTION_VERSION = 2;
34
36
  export const DELTA_SUFFIX = '.delta';
35
37
  export function parentLogPath(service, root) { return worldPaths(service, root).events; }
36
38
  export function branchLogPath(service, root) { return join(dirname(worldPaths(service, root).events), 'actions.jsonl'); }
@@ -389,7 +391,13 @@ export function foldHistory(entries, layout, service) {
389
391
  const baseCount = layout.base ? historyLength(layout.base) : 0;
390
392
  const tree = layout.base ? foldHistory(entries.slice(0, baseCount), layout.base, service) : new Map();
391
393
  const parent = entries.slice(0, baseCount + layout.parent);
392
- foldEntries(tree, parent.slice(baseCount));
394
+ const inheritedIds = landedIds(parent.slice(0, baseCount));
395
+ const ownParent = parent.slice(baseCount);
396
+ // A local acknowledgement is provenance for an inherited write, not a newer write.
397
+ // Reapplying it would overwrite later upstream fields (including vendor counters).
398
+ // Deployment outcomes remain observations and must still fold their returned fields.
399
+ const acknowledged = new Set([...inheritedIds, ...ownParent.filter(e => e.landsId && e.receipt?.status === 'landed' && inheritedIds.has(e.landsId)).map(e => e.id)]);
400
+ foldEntries(tree, ownParent, acknowledged);
393
401
  foldEntries(tree, entries.slice(baseCount + layout.parent, baseCount + layout.parent + layout.branch), landedIds(parent));
394
402
  foldAliases(tree, aliasesFrom(parent), service);
395
403
  return tree;
@@ -415,7 +423,7 @@ function statKey(path) { const s = getActiveWorldStore().stat(path); return s ?
415
423
  /** What a service's PARENT side at `root` is folded from (its parent log, branch.json and inherited history), as one
416
424
  * string: while it stands, its aliases and landed ids are the same, whatever the branch's own log does. */
417
425
  export function parentStamp(service, root) { return parentKeyOf(service, root); }
418
- function parentKeyOf(service, root) { return `${statKey(parentLogPath(service, root))}|${getActiveWorldStore().read(branchMetaPath(service, root)) ?? ''}|${inheritedHistoryKey(service, root)}`; }
426
+ function parentKeyOf(service, root) { return `${TREE_PROJECTION_VERSION}|${statKey(parentLogPath(service, root))}|${getActiveWorldStore().read(branchMetaPath(service, root)) ?? ''}|${inheritedHistoryKey(service, root)}`; }
419
427
  /** What a service's tree (and every subject's history) at `root` is folded from, as one string: while it
420
428
  * stands, nothing was written. A pack that projects the tree into its own state memoizes on it. */
421
429
  export function treeStamp(service, root) { return `${parentKeyOf(service, root)}#${statKey(branchLogPath(service, root))}`; }
@@ -595,7 +603,7 @@ function readTreeMapFull(service, root, opts = {}) {
595
603
  const branchAll = branchEntries(service, root);
596
604
  const until = opts.until === undefined ? -1 : branchAll.findIndex(e => e.id === opts.until);
597
605
  const branch = until < 0 ? branchAll : branchAll.slice(0, until);
598
- const parentDigest = hashFieldValue({ inherited, own });
606
+ const parentDigest = hashFieldValue({ projectionVersion: TREE_PROJECTION_VERSION, inherited, own });
599
607
  const branchDigest = hashFieldValue(branch);
600
608
  const raw = opts.until === undefined ? getActiveWorldStore().read(checkpointPath(service, root)) : null;
601
609
  const cp = raw === null ? null : parseExactJson(raw);
@@ -622,7 +630,7 @@ export function cutCheckpoint(service, root) {
622
630
  const subjects = [...readTreeMap(service, root).values()];
623
631
  const inherited = inheritedHistory(service, root);
624
632
  const own = ownParentEntries(service, root);
625
- getActiveWorldStore().writeAtomic(checkpointPath(service, root), JSON.stringify({ parentDigest: hashFieldValue({ inherited, own }), branchDigest: hashFieldValue(branchEntries(service, root)), branchCount: branchEntries(service, root).length, subjects }));
633
+ getActiveWorldStore().writeAtomic(checkpointPath(service, root), JSON.stringify({ parentDigest: hashFieldValue({ projectionVersion: TREE_PROJECTION_VERSION, inherited, own }), branchDigest: hashFieldValue(branchEntries(service, root)), branchCount: branchEntries(service, root).length, subjects }));
626
634
  });
627
635
  }
628
636
  /** The branch's entries the parent does not hold: `set` rows, not reverted, not landed. */
@@ -776,8 +784,11 @@ export function rebaseBranch(service, root, opts = {}) {
776
784
  // a field both sides set to an INSTANT (updated_at, created) is when each wrote, not what: never a conflict
777
785
  const instant = (v) => typeof v === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(v) && Number.isFinite(Date.parse(v));
778
786
  const currentAliases = aliasesFrom(historyEntries(next));
787
+ const incomingLanded = landedIds(historyEntries(next));
779
788
  const conflicts = [];
780
- for (const e of branchEntries(service, root)) {
789
+ // Acknowledged writes, including their counter effects, now belong to the incoming parent.
790
+ // Comparing them again against a later upstream write creates conflicts with already-landed work.
791
+ for (const e of unpushedEntries(service, root).filter(e => !incomingLanded.has(e.id))) {
781
792
  if (e.op !== 'set' || !e.fields || !existed.has(`${e.subject.type}:${e.subject.id}`))
782
793
  continue;
783
794
  const fields = resolveReferences(service, e.subject.type, e.fields, currentAliases);
@@ -11,7 +11,7 @@ import { ORIGINAL_PATH_HEADER } from "./sigv4.js";
11
11
  import { placedCookies, TWIN_PREFIX_HEADER } from "./twin-fetch.js";
12
12
  import { PREFIX_PARAMS_HEADER } from "./derived.js";
13
13
  import { withHeaders } from "./with-headers.js";
14
- import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, scenarioDecisionHeader, coreFor, crossCutting, derivedRequestScopes, vendorError } from "./derived-core.js";
14
+ import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, scenarioDecisionHeader, storedType, coreFor, crossCutting, derivedRequestScopes, vendorError } from "./derived-core.js";
15
15
  import { compileSurface, createDerivedFetch, matchOperation, pathParams } from "./derived.js";
16
16
  import { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, runAsVendorMove } from "./request-scope.js";
17
17
  import { parseScenarioDocument, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from "./scenario.js";
@@ -428,7 +428,18 @@ export function createPackFetch(parts, options = {}) {
428
428
  const discoveryDoors = [...(m.doors ?? []), ...(m.machines ? [{ id: 'machine-pool', method: 'POST', path: '/_twin/machine-pool', note: 'Enroll the World execution pool: { kind: light | docker | none | fake, images?: { image: { context, command?, runtime? } } }.' }] : [])];
429
429
  // the workspaces a person can be shown (Viewing a World): each built workspace screen's id and path
430
430
  const laneDiscovery = await laneDoor(request, '/twin');
431
- const shown = { ...(workspaces().length ? { screens: workspaces() } : {}), ...(Object.keys(laneDiscovery).length ? { lanes: laneDiscovery } : {}) };
431
+ const resources = {};
432
+ for (const [type, spec] of Object.entries(m.resources)) {
433
+ const resource = resources[storedType(m, type)] ??= { relations: [] };
434
+ const relations = [
435
+ ...(spec.parent ? [{ field: spec.parent.field, to: storedType(m, spec.parent.resource) }] : []),
436
+ ...Object.entries(spec.embeds ?? {}).map(([field, to]) => ({ field, to: storedType(m, to) })),
437
+ ];
438
+ for (const relation of relations)
439
+ if (!resource.relations.some(r => r.field === relation.field && r.to === relation.to))
440
+ resource.relations.push(relation);
441
+ }
442
+ const shown = { resources, ...(workspaces().length ? { screens: workspaces() } : {}), ...(Object.keys(laneDiscovery).length ? { lanes: laneDiscovery } : {}) };
432
443
  const identity = d.identity ?? (m.auth?.held ? 'Authenticate as the vendor does, using a credential issued in this World; unknown credentials are refused.' : undefined);
433
444
  // a model vendor's: its state, and what its scenario scripts, with the scenario's live counts
434
445
  if (parts.scenario && d.behavior) {
@@ -46,7 +46,7 @@ export { forkTwin, isFork, readForkMeta, } from './fork.js';
46
46
  export type { ForkMeta } from './fork.js';
47
47
  export { appendAction, appendTransactionCommit, checkPrecondition, confirmAction, listActions, listTransactionCommits, pendingActions, pushablePendingActions, isTwinBookkeeping, pendingTransactionCommits, projectResources, projectOwnerResources, OwnerStoreAmbiguousError, revertAction, TwinActionPreconditionError, subjectAliases, resolveSubjectId, runWithCorrelationId, currentCorrelationId } from './actions.js';
48
48
  export type { ActionProjection, ProjectedDelivery, ProjectedResource, ProjectedResourcePatch, ProjectedResourceRef, TwinAction, TwinActionOp, TwinActionPrecondition, TwinActionPreconditionOp, TwinActionRevertSpec, TwinTransactionCommit, TwinTransactionCommitOp, TwinTransactionPrecondition, TwinTransactionRevertSpec, } from './actions.js';
49
- export { approveChangeset, assertMarkerBelongsTo, assertSafeChangesetName, assertValidVerifiers, buildChangeset, captureMarker, changesetContentHash, changesetHashMatches, changesetReadiness, approverOf, CHANGESET_KIND, diffLedgers, formatApplication, formatChangeset, formatRebaseReport, narrateActions, narrationDrift, rebaseChangeset, formatChangesetStatus, formatLedgerDelta, formatReplayReport, formatVerification, MARKER_KIND, normalizeChangeset, parseVerifierExpression, replayChangeset, runChangesetVerifiers, summarizeByVendor, twinWriteShape, withApplication, withVerification, worldBootMarker, WORLD_BOOT_MARKER_ID, } from './changeset.js';
49
+ export { approveChangeset, assertMarkerBelongsTo, assertSafeChangesetName, assertValidVerifiers, buildChangeset, captureMarker, changesetContentHash, changesetHashMatches, changesetReadiness, approverOf, CHANGESET_KIND, diffLedgers, describeActionFields, fieldDiffKey, formatApplication, formatChangeset, formatRebaseReport, narrateActions, narrationDrift, rebaseChangeset, formatChangesetStatus, formatLedgerDelta, formatReplayReport, formatVerification, MARKER_KIND, normalizeChangeset, parseVerifierExpression, replayChangeset, runChangesetVerifiers, summarizeByVendor, twinWriteShape, withApplication, withVerification, worldBootMarker, WORLD_BOOT_MARKER_ID, } from './changeset.js';
50
50
  export type { ApplyReceipt, RebaseActionResult, RebaseReport, RebaseTarget, Changeset, ChangesetAction, ChangesetApplication, CompensationEntry, ChangesetApproval, ChangesetReadiness, ChangesetVerification, ChangesetVerifier, ChangesetVerifierResult, LedgerDelta, LedgerPosition, LedgerRef, ReplayActionResult, ReplayReport, ReplayTarget, VendorSummary, WorldMarker, } from './changeset.js';
51
51
  export { getActivePackAssets, setActivePackAssets, packAsset, assetContentType, confinedAssetPath, LocalPackAssets, BindingPackAssets, type PackAssetStore } from './pack-assets.js';
52
52
  export { installedPackageDir, installedVendorPacks, packVendor, packRepository, isDerivedPack, vendorPackageDir, vendorOfPackModule } from './pack-paths.js';
@@ -66,7 +66,7 @@ export { appendAction, appendTransactionCommit, checkPrecondition, confirmAction
66
66
  // the content-addressed, replayable changeset over that delta. State-level by construction
67
67
  // (it reads the same per-service action ledgers `actions.ts` writes); `volter-world` supplies
68
68
  // world discovery and thin verbs on top.
69
- export { approveChangeset, assertMarkerBelongsTo, assertSafeChangesetName, assertValidVerifiers, buildChangeset, captureMarker, changesetContentHash, changesetHashMatches, changesetReadiness, approverOf, CHANGESET_KIND, diffLedgers, formatApplication, formatChangeset, formatRebaseReport, narrateActions, narrationDrift, rebaseChangeset, formatChangesetStatus, formatLedgerDelta, formatReplayReport, formatVerification, MARKER_KIND, normalizeChangeset, parseVerifierExpression, replayChangeset, runChangesetVerifiers, summarizeByVendor, twinWriteShape, withApplication, withVerification, worldBootMarker, WORLD_BOOT_MARKER_ID, } from "./changeset.js";
69
+ export { approveChangeset, assertMarkerBelongsTo, assertSafeChangesetName, assertValidVerifiers, buildChangeset, captureMarker, changesetContentHash, changesetHashMatches, changesetReadiness, approverOf, CHANGESET_KIND, diffLedgers, describeActionFields, fieldDiffKey, formatApplication, formatChangeset, formatRebaseReport, narrateActions, narrationDrift, rebaseChangeset, formatChangesetStatus, formatLedgerDelta, formatReplayReport, formatVerification, MARKER_KIND, normalizeChangeset, parseVerifierExpression, replayChangeset, runChangesetVerifiers, summarizeByVendor, twinWriteShape, withApplication, withVerification, worldBootMarker, WORLD_BOOT_MARKER_ID, } from "./changeset.js";
70
70
  export { getActivePackAssets, setActivePackAssets, packAsset, assetContentType, confinedAssetPath, LocalPackAssets, BindingPackAssets } from "./pack-assets.js";
71
71
  export { installedPackageDir, installedVendorPacks, packVendor, packRepository, isDerivedPack, vendorPackageDir, vendorOfPackModule } from "./pack-paths.js";
72
72
  // The git plane (contract "The git plane is a kernel library"): objects, packs, refs, smart HTTP.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.138",
3
+ "version": "3.0.139",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
package/src/changeset.ts CHANGED
@@ -20,7 +20,7 @@ import { worldPaths } from './storage.ts';
20
20
  // silently reporting the wrong delta.
21
21
  //
22
22
  // DELTA every action appended after the marker, across every ledger, ordered by
23
- // `occurredAt` (the same causal order `volter-world tail` uses), grouped by vendor
23
+ // `occurredAt` with stable ledger/row ties (a timeline, not cross-vendor causality), grouped by vendor
24
24
  // for rendering.
25
25
  //
26
26
  // CHANGESET a delta frozen into a content-addressed object: `{id, world, base, actions,
@@ -57,14 +57,14 @@ import { worldPaths } from './storage.ts';
57
57
  // the control root that holds it) and never learns what a world is. `volter-world` supplies
58
58
  // discovery and the CLI verbs; the state-level logic lives here, beside the ledgers it reads.
59
59
  import { createHash } from 'node:crypto';
60
- import { aliasesFrom, parentEntries, type Entry } from './log.ts';
60
+ import { aliasesFrom, parentEntries, readTreeMap, wholeLog, type Entry } from './log.ts';
61
61
  import { appendActionIfAbsent, checkPrecondition, listActions, projectedPreconditionValue, projectResources, isTwinBookkeeping } from './actions.ts';
62
62
  import type { TwinActionRevertSpec } from './actions.ts';
63
63
  import { touchedSubjects } from './actions.ts';
64
64
  import type { PerformAction } from './head.ts';
65
65
  import type { RemoteExecute } from './remote-execute.ts';
66
66
  import type { TwinAction, TwinActionPrecondition, TwinActionPreconditionOp } from './actions.ts';
67
- import { canonicalJson, hashFieldValue } from './hash.ts';
67
+ import { canonicalJson, diffSubjectFields, hashFieldValue, type FieldChange } from './hash.ts';
68
68
  import { applyTwinWrite } from './serve.ts';
69
69
  import type { TwinResource } from './serve.ts';
70
70
 
@@ -130,8 +130,12 @@ export type LedgerDelta = {
130
130
  actions: ChangesetAction[];
131
131
  vendors: VendorSummary[];
132
132
  total: number;
133
+ /** Presentation only, outside changeset bodies: fields immediately before and after each write. */
134
+ fieldChanges?: Record<string, Record<string, FieldChange>>;
133
135
  };
134
136
 
137
+ export const fieldDiffKey = (service: string, stateService: string, id: string): string => JSON.stringify([service, stateService, id]);
138
+
135
139
  /** A verifier: one deterministic check against post-replay projected state — the CI assertion
136
140
  * of the operational PR (docs/concepts/the-model.md v1). The `assert` is the kernel's ONE check
137
141
  * expression (`TwinActionPrecondition`: subject/field/op/value, evaluated by
@@ -331,7 +335,34 @@ export function diffLedgers(opts: { world: string; ledgers: LedgerRef[]; base: W
331
335
  });
332
336
 
333
337
  const actions = ordered.map((o) => o.entry);
334
- return { world: opts.world, base: opts.base, actions, vendors: summarizeByVendor(actions), total: actions.length };
338
+ return { world: opts.world, base: opts.base, actions, vendors: summarizeByVendor(actions), total: actions.length, fieldChanges: describeActionFields(opts.ledgers, actions) };
339
+ }
340
+
341
+ /** Read each write's pre-state through the authoritative kernel projection. No UI history fold. */
342
+ export function describeActionFields(ledgers: LedgerRef[], actions: ChangesetAction[]): NonNullable<LedgerDelta['fieldChanges']> {
343
+ const fieldChanges: NonNullable<LedgerDelta['fieldChanges']> = {};
344
+ for (const ledger of ledgers) {
345
+ const entries = wholeLog(ledger.stateService, ledger.controlRoot);
346
+ // An observation is atomic: its internal positions cannot be projected. Compare its
347
+ // writes with the state at the start of that batch, through the same kernel reader.
348
+ const positions = new Map<string, number>();
349
+ let batchStart = 0;
350
+ entries.forEach((e, i) => {
351
+ if (!e.batch || e.batch !== entries[i - 1]?.batch) batchStart = i;
352
+ if (!positions.has(e.id)) positions.set(e.id, e.batch ? batchStart : i);
353
+ });
354
+ for (const { action, service, stateService } of actions.filter(a => a.service === ledger.service && a.stateService === ledger.stateService)) {
355
+ const at = positions.get(action.id);
356
+ if (at === undefined || !action.fields) continue;
357
+ const subject = readTreeMap(stateService, ledger.controlRoot, { at }).get(`${action.subject.type}:${action.subject.id}`);
358
+ const fields = subject?.fields ?? {};
359
+ fieldChanges[fieldDiffKey(service, stateService, action.id)] = diffSubjectFields({
360
+ subject: action.subject, fields, fieldHashes: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, hashFieldValue(v)])),
361
+ latestEventId: '', updatedAt: '',
362
+ }, action.fields);
363
+ }
364
+ }
365
+ return fieldChanges;
335
366
  }
336
367
 
337
368
  /** Group a delta by vendor, then by operation — both in FIRST-OCCURRENCE order, so the rollup
@@ -376,6 +407,11 @@ export function formatLedgerDelta(delta: LedgerDelta, opts: { title?: string } =
376
407
  for (const vendor of delta.vendors) {
377
408
  const parts = vendor.operations.map((o) => `${o.count} ${o.operation}${renderIds(o.subjectIds)}`);
378
409
  lines.push(` ${vendor.service}: ${parts.join(', ')}`);
410
+ for (const item of delta.actions.filter(a => a.service === vendor.service)) {
411
+ for (const [field, change] of Object.entries(delta.fieldChanges?.[fieldDiffKey(item.service, item.stateService, item.action.id)] ?? {})) {
412
+ lines.push(` ${item.action.subject.type}:${item.action.subject.id}.${field}: ${change.before === undefined ? '(absent)' : JSON.stringify(change.before)} → ${JSON.stringify(change.after)}`);
413
+ }
414
+ }
379
415
  }
380
416
  lines.push('', ` ${plural(delta.total, 'action')} across ${plural(delta.vendors.length, 'twin')}`);
381
417
  return `${lines.join('\n')}\n`;
@@ -984,6 +1020,8 @@ export type CompensationEntry = {
984
1020
  export type ChangesetApplication = {
985
1021
  at: string;
986
1022
  contentHash: string;
1023
+ /** A push receipt names its destination; absent on legacy and non-push applications. */
1024
+ destination?: { url: string; namespace: string };
987
1025
  outcome: 'applied' | 'partial' | 'refused';
988
1026
  forced?: boolean;
989
1027
  refusal?: string[];
@@ -1149,13 +1149,19 @@ async function keptFiles(m: DerivedManifest, call: DerivedCall, root: string | u
1149
1149
  return out;
1150
1150
  }
1151
1151
 
1152
+ /** Presentation tag only: keep provisioning in history without copying credential request data. */
1153
+ function provisioningInput(call: DerivedCall): { input?: { activity: 'provisioning' } } {
1154
+ return call.request.headers.get('x-volter-world-activity') === 'provisioning' ? { input: { activity: 'provisioning' } } : {};
1155
+ }
1156
+
1152
1157
  function recordedInput(m: DerivedManifest, call: DerivedCall, merged: Record<string, unknown>): Record<string, unknown> {
1153
1158
  const id = call.operation.id;
1154
1159
  const sent = sentOf.get(call);
1155
- const asSent = sent ? { path: sent.path, query: sent.query, body: sent.body, headers: sent.headers, ...(sent.lane ? { lane: sent.lane } : {}), ...(sent.requestId ? { requestId: sent.requestId } : {}), ...(sent.type ? { contentType: sent.type } : {}) } : { path: call.params, body: merged };
1160
+ const activity = call.request.headers.get('x-volter-world-activity') === 'provisioning' ? { activity: 'provisioning' } : {};
1161
+ const asSent = sent ? { ...activity, path: sent.path, query: sent.query, body: sent.body, headers: sent.headers, ...(sent.lane ? { lane: sent.lane } : {}), ...(sent.requestId ? { requestId: sent.requestId } : {}), ...(sent.type ? { contentType: sent.type } : {}) } : { ...activity, path: call.params, body: merged };
1156
1162
  if (id === 'graphql') {
1157
1163
  const b = (sent?.body ?? {}) as { query?: unknown; variables?: unknown; operationName?: unknown };
1158
- return { graphql: { query: b.query, variables: b.variables ?? {}, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: sent?.headers ?? {} };
1164
+ return { ...activity, graphql: { query: b.query, variables: b.variables ?? {}, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: sent?.headers ?? {} };
1159
1165
  }
1160
1166
  if (id === 'clock') return { clock: true };
1161
1167
  if (id === 'unmatched') return { gap: true, ...asSent };
@@ -1994,7 +2000,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
1994
2000
  if (scope.readOnly === true || isReadOnlyRequest(call.request)) return mintId(m, resource, root, at);
1995
2001
  const { value } = await applyTwinWriteAtomic(m.service, (snapshot) => {
1996
2002
  const id = mintId(m, resource, root, at, snapshot);
1997
- return { kind: 'write', value: id, write: { operation: `${resource.slice(1)}.issue`, subjectType: storedType(m, resource), subjectId: id, fields: { issuedAt: at }, occurredAt: at, actor: { kind: 'system' } } };
2003
+ return { kind: 'write', value: id, write: { operation: `${resource.slice(1)}.issue`, subjectType: storedType(m, resource), subjectId: id, fields: { issuedAt: at }, ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' } } };
1998
2004
  }, root);
1999
2005
  return value;
2000
2006
  },
@@ -2004,7 +2010,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2004
2010
  if (!type.startsWith('_')) throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
2005
2011
  if (id !== undefined) {
2006
2012
  const before = operation ? stateBeforeWrite(m, type, id, root) : undefined;
2007
- const { result } = await applyTwinWrite(m.service, { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: id, fields, occurredAt: at, actor: { kind: 'system' } }, root);
2013
+ const { result } = await applyTwinWrite(m.service, { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: id, fields, ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' } }, root);
2008
2014
  if (operation) observeStateWrite(m, result, before, root, stateChecks);
2009
2015
  return id;
2010
2016
  }
@@ -2020,7 +2026,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2020
2026
  }
2021
2027
  return `${prefix}${max + 1n}`;
2022
2028
  })();
2023
- return { kind: 'write', value: subject, write: { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } } };
2029
+ return { kind: 'write', value: subject, write: { operation: operation ?? `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' } } };
2024
2030
  }, root, () => resourcesOfType(m.service, type, root));
2025
2031
  if (operation && result) observeStateWrite(m, result, undefined, root, stateChecks);
2026
2032
  return value;
@@ -2088,7 +2094,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2088
2094
  if (!d.write) return { kind: 'skip', value: d.value };
2089
2095
  const w = d.write;
2090
2096
  if (transitionObserver) before = resources.find((r) => r.type === storedType(m, w.resource) && r.id === w.id);
2091
- return { kind: 'write', value: d.value, write: { operation: w.operation, subjectType: storedType(m, w.resource), subjectId: w.id, fields: w.fields, occurredAt: at, actor: { kind: 'agent' } } };
2097
+ return { kind: 'write', value: d.value, write: { operation: w.operation, subjectType: storedType(m, w.resource), subjectId: w.id, fields: w.fields, ...provisioningInput(call), occurredAt: at, actor: { kind: 'agent' } } };
2092
2098
  },
2093
2099
  root,
2094
2100
  );
package/src/log.ts CHANGED
@@ -92,6 +92,8 @@ export type BranchMeta = {
92
92
  };
93
93
 
94
94
  export const CHECKPOINT_EVERY = 100;
95
+ // Retained checkpoints and process memos are disposable projections, never history.
96
+ const TREE_PROJECTION_VERSION = 2;
95
97
  export const DELTA_SUFFIX = '.delta';
96
98
 
97
99
  export function parentLogPath(service: string, root?: string): string { return worldPaths(service, root).events; }
@@ -434,7 +436,13 @@ export function foldHistory(entries: Entry[], layout: HistoryLayout, service?: s
434
436
  const baseCount = layout.base ? historyLength(layout.base) : 0;
435
437
  const tree: Tree = layout.base ? foldHistory(entries.slice(0, baseCount), layout.base, service) : new Map();
436
438
  const parent = entries.slice(0, baseCount + layout.parent);
437
- foldEntries(tree, parent.slice(baseCount));
439
+ const inheritedIds = landedIds(parent.slice(0, baseCount));
440
+ const ownParent = parent.slice(baseCount);
441
+ // A local acknowledgement is provenance for an inherited write, not a newer write.
442
+ // Reapplying it would overwrite later upstream fields (including vendor counters).
443
+ // Deployment outcomes remain observations and must still fold their returned fields.
444
+ const acknowledged = new Set([...inheritedIds, ...ownParent.filter(e => e.landsId && e.receipt?.status === 'landed' && inheritedIds.has(e.landsId)).map(e => e.id)]);
445
+ foldEntries(tree, ownParent, acknowledged);
438
446
  foldEntries(tree, entries.slice(baseCount + layout.parent, baseCount + layout.parent + layout.branch), landedIds(parent));
439
447
  foldAliases(tree, aliasesFrom(parent), service);
440
448
  return tree;
@@ -476,7 +484,7 @@ function statKey(path: string): string { const s = getActiveWorldStore().stat(pa
476
484
  /** What a service's PARENT side at `root` is folded from (its parent log, branch.json and inherited history), as one
477
485
  * string: while it stands, its aliases and landed ids are the same, whatever the branch's own log does. */
478
486
  export function parentStamp(service: string, root?: string): string { return parentKeyOf(service, root); }
479
- function parentKeyOf(service: string, root?: string): string { return `${statKey(parentLogPath(service, root))}|${getActiveWorldStore().read(branchMetaPath(service, root)) ?? ''}|${inheritedHistoryKey(service, root)}`; }
487
+ function parentKeyOf(service: string, root?: string): string { return `${TREE_PROJECTION_VERSION}|${statKey(parentLogPath(service, root))}|${getActiveWorldStore().read(branchMetaPath(service, root)) ?? ''}|${inheritedHistoryKey(service, root)}`; }
480
488
  /** What a service's tree (and every subject's history) at `root` is folded from, as one string: while it
481
489
  * stands, nothing was written. A pack that projects the tree into its own state memoizes on it. */
482
490
  export function treeStamp(service: string, root?: string): string { return `${parentKeyOf(service, root)}#${statKey(branchLogPath(service, root))}`; }
@@ -612,7 +620,7 @@ function readTreeMapFull(service: string, root?: string, opts: { until?: string;
612
620
  const branchAll = branchEntries(service, root);
613
621
  const until = opts.until === undefined ? -1 : branchAll.findIndex(e => e.id === opts.until);
614
622
  const branch = until < 0 ? branchAll : branchAll.slice(0, until);
615
- const parentDigest = hashFieldValue({ inherited, own }); const branchDigest = hashFieldValue(branch);
623
+ const parentDigest = hashFieldValue({ projectionVersion: TREE_PROJECTION_VERSION, inherited, own }); const branchDigest = hashFieldValue(branch);
616
624
  const raw = opts.until === undefined ? getActiveWorldStore().read(checkpointPath(service, root)) : null;
617
625
  const cp = raw === null ? null : parseExactJson(raw) as Checkpoint;
618
626
  if (cp?.parentDigest === parentDigest && cp.branchDigest === branchDigest) return new Map(cp.subjects.map(s => [`${s.type}:${s.id}`, { ...s, fields: { ...s.fields } }]));
@@ -633,7 +641,7 @@ export function cutCheckpoint(service: string, root?: string): void {
633
641
  dropCheckpoint(service, root);
634
642
  const subjects = [...readTreeMap(service, root).values()];
635
643
  const inherited = inheritedHistory(service, root); const own = ownParentEntries(service, root);
636
- getActiveWorldStore().writeAtomic(checkpointPath(service, root), JSON.stringify({ parentDigest: hashFieldValue({ inherited, own }), branchDigest: hashFieldValue(branchEntries(service, root)), branchCount: branchEntries(service, root).length, subjects }));
644
+ getActiveWorldStore().writeAtomic(checkpointPath(service, root), JSON.stringify({ parentDigest: hashFieldValue({ projectionVersion: TREE_PROJECTION_VERSION, inherited, own }), branchDigest: hashFieldValue(branchEntries(service, root)), branchCount: branchEntries(service, root).length, subjects }));
637
645
  });
638
646
  }
639
647
 
@@ -773,8 +781,11 @@ export function rebaseBranch(service: string, root?: string, opts: { origin?: bo
773
781
  // a field both sides set to an INSTANT (updated_at, created) is when each wrote, not what: never a conflict
774
782
  const instant = (v: unknown): boolean => typeof v === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(v) && Number.isFinite(Date.parse(v));
775
783
  const currentAliases = aliasesFrom(historyEntries(next));
784
+ const incomingLanded = landedIds(historyEntries(next));
776
785
  const conflicts: Array<{ entryId: string; subject: { type: string; id: string }; field: string; op: string; expected?: unknown; actual?: unknown }> = [];
777
- for (const e of branchEntries(service, root)) {
786
+ // Acknowledged writes, including their counter effects, now belong to the incoming parent.
787
+ // Comparing them again against a later upstream write creates conflicts with already-landed work.
788
+ for (const e of unpushedEntries(service, root).filter(e => !incomingLanded.has(e.id))) {
778
789
  if (e.op !== 'set' || !e.fields || !existed.has(`${e.subject.type}:${e.subject.id}`)) continue;
779
790
  const fields = resolveReferences(service, e.subject.type, e.fields, currentAliases);
780
791
  for (const p of since) {
package/src/pack-fetch.ts CHANGED
@@ -11,7 +11,7 @@ import { ORIGINAL_PATH_HEADER } from './sigv4.ts';
11
11
  import { placedCookies, TWIN_PREFIX_HEADER } from './twin-fetch.ts';
12
12
  import { PREFIX_PARAMS_HEADER } from './derived.ts';
13
13
  import { withHeaders } from './with-headers.ts';
14
- import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, scenarioDecisionHeader, coreFor, crossCutting, derivedRequestScopes, vendorError, type CoreScope, type DerivedManifest, type ErrorSpec, type Handler, type HandlerContext, type LanesDecl, type VendorManifest } from './derived-core.ts';
14
+ import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, scenarioDecisionHeader, storedType, coreFor, crossCutting, derivedRequestScopes, vendorError, type CoreScope, type DerivedManifest, type ErrorSpec, type Handler, type HandlerContext, type LanesDecl, type VendorManifest } from './derived-core.ts';
15
15
  import { compileSurface, createDerivedFetch, matchOperation, pathParams, type DerivedFetch, type DerivedOperation, type DerivedOwner, type DerivedSurface, type Workspace, type BoardFrame } from './derived.ts';
16
16
  import { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, runAsVendorMove } from './request-scope.ts';
17
17
  import { parseScenarioDocument, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest, type PackScenarioAdapter } from './scenario.ts';
@@ -457,7 +457,16 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
457
457
  const discoveryDoors = [...(m.doors ?? []), ...(m.machines ? [{ id: 'machine-pool', method: 'POST', path: '/_twin/machine-pool', note: 'Enroll the World execution pool: { kind: light | docker | none | fake, images?: { image: { context, command?, runtime? } } }.' }] : [])];
458
458
  // the workspaces a person can be shown (Viewing a World): each built workspace screen's id and path
459
459
  const laneDiscovery = await laneDoor(request, '/twin');
460
- const shown = { ...(workspaces().length ? { screens: workspaces() } : {}), ...(Object.keys(laneDiscovery).length ? { lanes: laneDiscovery } : {}) };
460
+ const resources: Record<string, { relations: Array<{ field: string; to: string }> }> = {};
461
+ for (const [type, spec] of Object.entries(m.resources)) {
462
+ const resource = resources[storedType(m, type)] ??= { relations: [] };
463
+ const relations = [
464
+ ...(spec.parent ? [{ field: spec.parent.field, to: storedType(m, spec.parent.resource) }] : []),
465
+ ...Object.entries(spec.embeds ?? {}).map(([field, to]) => ({ field, to: storedType(m, to) })),
466
+ ];
467
+ for (const relation of relations) if (!resource.relations.some(r => r.field === relation.field && r.to === relation.to)) resource.relations.push(relation);
468
+ }
469
+ const shown = { resources, ...(workspaces().length ? { screens: workspaces() } : {}), ...(Object.keys(laneDiscovery).length ? { lanes: laneDiscovery } : {}) };
461
470
  const identity = d.identity ?? (m.auth?.held ? 'Authenticate as the vendor does, using a credential issued in this World; unknown credentials are refused.' : undefined);
462
471
  // a model vendor's: its state, and what its scenario scripts, with the scenario's live counts
463
472
  if (parts.scenario && d.behavior) {
package/src/runtime.ts CHANGED
@@ -244,7 +244,7 @@ export {
244
244
  changesetReadiness,
245
245
  approverOf,
246
246
  CHANGESET_KIND,
247
- diffLedgers,
247
+ diffLedgers, describeActionFields, fieldDiffKey,
248
248
  formatApplication,
249
249
  formatChangeset,
250
250
  formatRebaseReport,