@nanobpm/urban 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ applyAmbientLineage,
5
+ buildLineageTree,
6
+ deriveLineage,
7
+ type LineageEdge,
8
+ LINEAGE_KEY,
9
+ LINEAGE_NAMESPACE,
10
+ mintRootRequestKey,
11
+ readLineage,
12
+ writeLineage,
13
+ } from "./lineage.ts";
14
+ import { __resetExecStoreForTests, installExecStore, runInJobContext } from "./execContext.ts";
15
+ import { createNodeHost } from "../adapters/node.ts";
16
+ import { isRecord } from "./guards.ts";
17
+
18
+ function lineageOf(variables: Record<string, unknown>): unknown {
19
+ const ns = variables[LINEAGE_NAMESPACE];
20
+ return isRecord(ns) ? ns[LINEAGE_KEY] : undefined;
21
+ }
22
+
23
+ test("readLineage extracts a well-formed envelope and rejects malformed ones", () => {
24
+ assert.deepEqual(
25
+ readLineage({ _urban: { lineage: { rootRequestKey: "r1", causedByInstanceKey: "c1" } } }),
26
+ { rootRequestKey: "r1", causedByInstanceKey: "c1" },
27
+ );
28
+ // A bare root (no cause) is a valid fresh root.
29
+ assert.deepEqual(readLineage({ _urban: { lineage: { rootRequestKey: "r1" } } }), {
30
+ rootRequestKey: "r1",
31
+ causedByInstanceKey: undefined,
32
+ });
33
+ // Blank/absent rootRequestKey → not a valid envelope.
34
+ assert.equal(readLineage({ _urban: { lineage: { rootRequestKey: "" } } }), undefined);
35
+ assert.equal(readLineage({ _urban: { lineage: {} } }), undefined);
36
+ assert.equal(readLineage({ _urban: {} }), undefined);
37
+ assert.equal(readLineage({}), undefined);
38
+ assert.equal(readLineage(undefined), undefined);
39
+ });
40
+
41
+ test("writeLineage never mutates input and preserves other _urban.* keys", () => {
42
+ const input = { keep: 1, _urban: { other: "x" } };
43
+ const out = writeLineage(input, { rootRequestKey: "r1", causedByInstanceKey: "c1" });
44
+ assert.deepEqual(input, { keep: 1, _urban: { other: "x" } }, "input is not mutated");
45
+ assert.equal(out.keep, 1);
46
+ assert.deepEqual(out._urban, { other: "x", lineage: { rootRequestKey: "r1", causedByInstanceKey: "c1" } });
47
+ });
48
+
49
+ test("writeLineage drops an absent causedByInstanceKey (a fresh root is { rootRequestKey })", () => {
50
+ const out = writeLineage({}, { rootRequestKey: "r1" });
51
+ assert.deepEqual(lineageOf(out), { rootRequestKey: "r1" });
52
+ });
53
+
54
+ test("deriveLineage propagates an ambient root and sets causedByInstanceKey", () => {
55
+ assert.deepEqual(deriveLineage({ rootRequestKey: "root-1", instanceKey: "inst-5" }), {
56
+ rootRequestKey: "root-1",
57
+ causedByInstanceKey: "inst-5",
58
+ });
59
+ });
60
+
61
+ test("deriveLineage treats an ambient instance with no envelope as its own root", () => {
62
+ assert.deepEqual(deriveLineage({ instanceKey: "inst-9" }), {
63
+ rootRequestKey: "inst-9",
64
+ causedByInstanceKey: "inst-9",
65
+ });
66
+ });
67
+
68
+ test("deriveLineage mints a fresh root when there is no ambient job at all", () => {
69
+ assert.deepEqual(deriveLineage(undefined, () => "minted-1"), { rootRequestKey: "minted-1" });
70
+ });
71
+
72
+ test("mintRootRequestKey returns a non-empty, unique-ish value", () => {
73
+ const a = mintRootRequestKey();
74
+ const b = mintRootRequestKey();
75
+ assert.equal(typeof a, "string");
76
+ assert.notEqual(a, "");
77
+ assert.notEqual(a, b);
78
+ });
79
+
80
+ test("applyAmbientLineage: an explicit caller envelope always wins (untouched)", () => {
81
+ const explicit = { _urban: { lineage: { rootRequestKey: "explicit", causedByInstanceKey: "x" } } };
82
+ const out = applyAmbientLineage(explicit, {
83
+ ambient: { rootRequestKey: "ambient-root", instanceKey: "ambient-inst" },
84
+ });
85
+ assert.deepEqual(lineageOf(out), { rootRequestKey: "explicit", causedByInstanceKey: "x" });
86
+ });
87
+
88
+ test("applyAmbientLineage: propagates the ambient root and stamps the cause", () => {
89
+ const out = applyAmbientLineage(
90
+ { payload: 1 },
91
+ { ambient: { rootRequestKey: "root-A", instanceKey: "inst-B" } },
92
+ );
93
+ assert.equal(out.payload, 1);
94
+ assert.deepEqual(lineageOf(out), { rootRequestKey: "root-A", causedByInstanceKey: "inst-B" });
95
+ });
96
+
97
+ test("applyAmbientLineage: mints a fresh root for a genuine top-level request", () => {
98
+ const out = applyAmbientLineage({}, { ambient: undefined, mintRootRequestKey: () => "fresh-1" });
99
+ assert.deepEqual(lineageOf(out), { rootRequestKey: "fresh-1" });
100
+ });
101
+
102
+ test("applyAmbientLineage reads the real ambient job context (runInJobContext)", () => {
103
+ __resetExecStoreForTests();
104
+ const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
105
+ installExecStore(() => host.createAsyncStore?.());
106
+ try {
107
+ const out = runInJobContext({ instanceKey: "pi-1", rootRequestKey: "root-1", jobType: "w" }, () =>
108
+ applyAmbientLineage({ a: 1 }),
109
+ );
110
+ assert.deepEqual(lineageOf(out), { rootRequestKey: "root-1", causedByInstanceKey: "pi-1" });
111
+ } finally {
112
+ __resetExecStoreForTests();
113
+ }
114
+ });
115
+
116
+ test("buildLineageTree stitches a fan-out tree from weak edges", () => {
117
+ const edges: LineageEdge[] = [
118
+ { rootRequestKey: "root", instanceKey: "root", edgeType: "weak" },
119
+ { rootRequestKey: "root", instanceKey: "child-1", causedByInstanceKey: "root", edgeType: "weak" },
120
+ { rootRequestKey: "root", instanceKey: "child-2", causedByInstanceKey: "root", edgeType: "weak" },
121
+ { rootRequestKey: "root", instanceKey: "grand-1", causedByInstanceKey: "child-1", edgeType: "weak" },
122
+ ];
123
+ const tree = buildLineageTree("root", edges);
124
+ assert.equal(tree.root.instanceKey, "root");
125
+ assert.deepEqual(tree.root.children.map((c) => c.instanceKey).sort(), ["child-1", "child-2"]);
126
+ const child1 = tree.root.children.find((c) => c.instanceKey === "child-1");
127
+ assert.deepEqual(child1?.children.map((c) => c.instanceKey), ["grand-1"]);
128
+ assert.equal(tree.nodes.length, 4);
129
+ });
130
+
131
+ test("buildLineageTree: a strong (engine) edge supersedes a weak edge on the same node", () => {
132
+ const edges: LineageEdge[] = [
133
+ { rootRequestKey: "root", instanceKey: "child", causedByInstanceKey: "root", edgeType: "weak" },
134
+ { rootRequestKey: "root", instanceKey: "child", causedByInstanceKey: "root", edgeType: "strong" },
135
+ ];
136
+ const tree = buildLineageTree("root", edges);
137
+ const child = tree.nodes.find((n) => n.instanceKey === "child");
138
+ assert.equal(child?.edgeType, "strong");
139
+ });
140
+
141
+ test("buildLineageTree ignores edges from other roots and hangs attachments on their node", () => {
142
+ const edges: LineageEdge[] = [
143
+ { rootRequestKey: "root", instanceKey: "child", causedByInstanceKey: "root", edgeType: "weak" },
144
+ { rootRequestKey: "other", instanceKey: "stranger", causedByInstanceKey: "other", edgeType: "weak" },
145
+ ];
146
+ const tree = buildLineageTree("root", edges, [
147
+ { nodeKey: "child", kind: "pull_request", ref: "owner/repo#1", label: "PR 1" },
148
+ { nodeKey: "missing", kind: "task", ref: "t-1" },
149
+ ]);
150
+ assert.equal(tree.nodes.some((n) => n.instanceKey === "stranger"), false);
151
+ const child = tree.root.children[0];
152
+ assert.equal(child.instanceKey, "child");
153
+ assert.deepEqual(child.attachments, [{ nodeKey: "child", kind: "pull_request", ref: "owner/repo#1", label: "PR 1" }]);
154
+ // An attachment for an instance with no recorded edge yet is PRESERVED as an unattached node,
155
+ // never silently dropped — attachments are an extension point and must survive the projection.
156
+ const orphan = tree.nodes.find((n) => n.instanceKey === "missing");
157
+ assert.ok(orphan, "attachment for an un-stitched instance must materialise its node");
158
+ assert.deepEqual(orphan.attachments, [{ nodeKey: "missing", kind: "task", ref: "t-1" }]);
159
+ assert.equal(orphan.edgeType, undefined);
160
+ assert.equal(orphan.children.length, 0);
161
+ });
162
+
163
+ test("buildLineageTree: a stray orphan attachment does not shadow a MINTED root's real parentless instance", () => {
164
+ // Defect-class guard: materialising attachment-only nodes must not reintroduce the phantom-root
165
+ // bug. A minted (synthetic) rootRequestKey with an orphan attachment adds a second parentless
166
+ // node; the tree must still root at the real recorded instance, not the empty synthetic phantom.
167
+ const edges: LineageEdge[] = [
168
+ { rootRequestKey: "req-UUID", instanceKey: "inst-root", edgeType: "weak" },
169
+ { rootRequestKey: "req-UUID", instanceKey: "inst-child", causedByInstanceKey: "inst-root", edgeType: "strong" },
170
+ ];
171
+ const tree = buildLineageTree("req-UUID", edges, [{ nodeKey: "inst-orphan", kind: "note", ref: "n-1" }]);
172
+ assert.equal(tree.root.instanceKey, "inst-root");
173
+ assert.deepEqual(tree.root.children.map((c) => c.instanceKey), ["inst-child"]);
174
+ assert.equal(tree.nodes.some((n) => n.instanceKey === "req-UUID"), false);
175
+ // The orphan attachment is preserved (unattached), and the synthetic key never leaks as a node.
176
+ const orphan = tree.nodes.find((n) => n.instanceKey === "inst-orphan");
177
+ assert.ok(orphan, "orphan attachment node must be preserved");
178
+ assert.deepEqual(orphan.attachments, [{ nodeKey: "inst-orphan", kind: "note", ref: "n-1" }]);
179
+ });
180
+
181
+ test("buildLineageTree roots a MINTED (synthetic) rootRequestKey at the parentless instance, not a phantom", () => {
182
+ // A genuine top-level request MINTS a UUID `rootRequestKey` that is NOT any instance key
183
+ // (`deriveLineage` with no ambient job). The real root instance carries that key with no
184
+ // cause, and its descendants hang off the instance. The tree must be rooted at the real
185
+ // parentless instance — never at an empty synthetic node while the real root floats detached.
186
+ const edges: LineageEdge[] = [
187
+ { rootRequestKey: "req-UUID", instanceKey: "inst-root", edgeType: "weak" },
188
+ { rootRequestKey: "req-UUID", instanceKey: "inst-child", causedByInstanceKey: "inst-root", edgeType: "weak" },
189
+ { rootRequestKey: "req-UUID", instanceKey: "inst-grand", causedByInstanceKey: "inst-child", edgeType: "strong" },
190
+ ];
191
+ const tree = buildLineageTree("req-UUID", edges);
192
+ assert.equal(tree.root.instanceKey, "inst-root");
193
+ assert.deepEqual(tree.root.children.map((c) => c.instanceKey), ["inst-child"]);
194
+ assert.deepEqual(tree.root.children[0].children.map((c) => c.instanceKey), ["inst-grand"]);
195
+ // The synthetic correlation key must not leak into the flat view as a phantom node.
196
+ assert.equal(tree.nodes.some((n) => n.instanceKey === "req-UUID"), false);
197
+ assert.equal(tree.nodes.length, 3);
198
+ });
199
+
200
+ test("buildLineageTree keeps a lone real root (parentless, no descendants) as the tree root", () => {
201
+ // Guard the fallback boundary: a real root instance whose key IS the rootRequestKey and which
202
+ // has no descendants must stay the root — it must not be mistaken for a replaceable phantom.
203
+ const edges: LineageEdge[] = [{ rootRequestKey: "root", instanceKey: "root", edgeType: "weak" }];
204
+ const tree = buildLineageTree("root", edges);
205
+ assert.equal(tree.root.instanceKey, "root");
206
+ assert.equal(tree.root.children.length, 0);
207
+ assert.equal(tree.nodes.length, 1);
208
+ });
209
+
210
+ test("buildLineageTree keeps the synthetic root when no edges exist at all", () => {
211
+ const tree = buildLineageTree("req-UUID", []);
212
+ assert.equal(tree.root.instanceKey, "req-UUID");
213
+ assert.equal(tree.root.children.length, 0);
214
+ assert.deepEqual(tree.nodes.map((n) => n.instanceKey), ["req-UUID"]);
215
+ });
@@ -0,0 +1,297 @@
1
+ // lineage — the framework-level lineage primitive (issue #254).
2
+ //
3
+ // Any Urban app wants to stitch *user intent → progress*: a single root request (a
4
+ // button press, an inbound webhook, an API call) fans out into the process instances,
5
+ // PRs, tasks and sub-loops it *causes*, and an operator wants to see that whole thread.
6
+ // Today every app hand-rolls that correlation. This module makes it a framework
7
+ // primitive so the next app gets intent→progress threading for free.
8
+ //
9
+ // Two DISTINCT relationships stitch the thread, and they must stay separate:
10
+ //
11
+ // - the LINEAGE / CAUSATION edge (this module): asynchronous causation — the cause does
12
+ // NOT block the effect, edges fan out (1→N), descendants settle independently and
13
+ // survive their ancestor's termination, and they are created by message-start / API
14
+ // `createInstance` / webhooks as much as by call activities. Carried as a reserved
15
+ // `_urban.lineage` envelope on every instance/message the SDK starts, AUTO-THREADED
16
+ // from the ambient job context (see {@link applyAmbientLineage}).
17
+ // - the EXECUTION edge (`parentProcessInstanceKey`, engine-native — Magikcraft/nano-bpm#808):
18
+ // synchronous delegation where the parent token blocks on the child. This module does
19
+ // NOT duplicate it; the read projection ({@link buildLineageTree}) CONSUMES it as a
20
+ // strong edge, unioned with the weak/causal edges of the envelope.
21
+ //
22
+ // This file is intentionally `node:*`-free (the host contract): the only ambient
23
+ // capability it reaches for is `crypto.randomUUID` (a Web/global, not `node:crypto`),
24
+ // with a portable fallback.
25
+
26
+ import { currentJobContext, type JobExecContext } from "./execContext.ts";
27
+ import { isRecord } from "./guards.ts";
28
+
29
+ /** The reserved variable namespace the lineage envelope lives under on every
30
+ * instance/message (`_urban.lineage`). Kept as one object so future framework
31
+ * conventions can share the `_urban.*` namespace without colliding. */
32
+ export const LINEAGE_NAMESPACE = "_urban";
33
+ /** The key of the lineage envelope within {@link LINEAGE_NAMESPACE}. */
34
+ export const LINEAGE_KEY = "lineage";
35
+
36
+ /**
37
+ * The lineage envelope, a reserved convention on every instance/message:
38
+ * `_urban.lineage = { rootRequestKey, causedByInstanceKey? }`.
39
+ * - `rootRequestKey` threads every descendant of a single top-level request back to
40
+ * it; it is PRESERVED verbatim across the whole causal tree.
41
+ * - `causedByInstanceKey` is the instance that directly caused this one (the weak
42
+ * parent edge). Absent on a genuine top-level request (a fresh root).
43
+ */
44
+ export interface LineageEnvelope {
45
+ readonly rootRequestKey: string;
46
+ readonly causedByInstanceKey?: string;
47
+ }
48
+
49
+ /** Options for {@link applyAmbientLineage}, injectable for deterministic tests. */
50
+ export interface ApplyLineageOptions {
51
+ /** Override the ambient job context (defaults to {@link currentJobContext}). */
52
+ readonly ambient?: JobExecContext | undefined;
53
+ /** Override the fresh-root minter (defaults to {@link mintRootRequestKey}). */
54
+ readonly mintRootRequestKey?: () => string;
55
+ }
56
+
57
+ /** Mint a fresh `rootRequestKey` for a genuine top-level request (no ambient lineage).
58
+ * Uses `crypto.randomUUID` where available (Node ≥ 16.7 / Deno expose it as a global),
59
+ * with a time+random fallback so this stays host-agnostic and never throws. */
60
+ export function mintRootRequestKey(): string {
61
+ const uuid = globalThis.crypto?.randomUUID?.();
62
+ if (uuid) return uuid;
63
+ return `root-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
64
+ }
65
+
66
+ /** Coerce a value to a non-empty string, or `undefined`. */
67
+ function nonEmptyString(value: unknown): string | undefined {
68
+ return typeof value === "string" && value !== "" ? value : undefined;
69
+ }
70
+
71
+ /**
72
+ * Read the `_urban.lineage` envelope from a variables map, or `undefined` when absent or
73
+ * malformed (no/blank `rootRequestKey`). Tolerant by design: a bad envelope degrades to
74
+ * "no ambient lineage" rather than throwing on the hot instance-creation path.
75
+ */
76
+ export function readLineage(variables: unknown): LineageEnvelope | undefined {
77
+ if (!isRecord(variables)) return undefined;
78
+ const ns = variables[LINEAGE_NAMESPACE];
79
+ if (!isRecord(ns)) return undefined;
80
+ const env = ns[LINEAGE_KEY];
81
+ if (!isRecord(env)) return undefined;
82
+ const rootRequestKey = nonEmptyString(env.rootRequestKey);
83
+ if (!rootRequestKey) return undefined;
84
+ return { rootRequestKey, causedByInstanceKey: nonEmptyString(env.causedByInstanceKey) };
85
+ }
86
+
87
+ /** Normalise an envelope for storage in variables — drop an absent `causedByInstanceKey`
88
+ * rather than serialising `undefined`, so a fresh root's envelope is `{ rootRequestKey }`. */
89
+ function toEnvelopeRecord(envelope: LineageEnvelope): Record<string, unknown> {
90
+ return envelope.causedByInstanceKey
91
+ ? { rootRequestKey: envelope.rootRequestKey, causedByInstanceKey: envelope.causedByInstanceKey }
92
+ : { rootRequestKey: envelope.rootRequestKey };
93
+ }
94
+
95
+ /**
96
+ * Return a COPY of `variables` with `envelope` written under `_urban.lineage`, preserving
97
+ * any other `_urban.*` keys. Never mutates the input.
98
+ */
99
+ export function writeLineage(
100
+ variables: Record<string, unknown> | undefined,
101
+ envelope: LineageEnvelope,
102
+ ): Record<string, unknown> {
103
+ const base = isRecord(variables) ? variables : {};
104
+ const ns = isRecord(base[LINEAGE_NAMESPACE]) ? base[LINEAGE_NAMESPACE] : {};
105
+ return { ...base, [LINEAGE_NAMESPACE]: { ...ns, [LINEAGE_KEY]: toEnvelopeRecord(envelope) } };
106
+ }
107
+
108
+ /**
109
+ * Derive the envelope to attach to a NEW instance/message given the ambient job context.
110
+ * - Ambient lineage present → PROPAGATE it: preserve `rootRequestKey`, set
111
+ * `causedByInstanceKey` to the current instance (the cause).
112
+ * - Ambient job with an instance but no envelope → that instance is itself a root; the
113
+ * child inherits `rootRequestKey = <ambient instance>` and is caused by it.
114
+ * - No ambient job at all (a genuine top-level request) → MINT a fresh `rootRequestKey`.
115
+ */
116
+ export function deriveLineage(
117
+ ambient: JobExecContext | undefined,
118
+ mint: () => string = mintRootRequestKey,
119
+ ): LineageEnvelope {
120
+ const rootRequestKey = ambient?.rootRequestKey ?? ambient?.instanceKey;
121
+ if (rootRequestKey) {
122
+ return { rootRequestKey, causedByInstanceKey: ambient?.instanceKey };
123
+ }
124
+ return { rootRequestKey: mint() };
125
+ }
126
+
127
+ /**
128
+ * The single, adapter-agnostic step that auto-threads lineage onto a `createInstance` /
129
+ * `publishMessage` call's variables. Both the live `SdkEngineClient` and the testkit's
130
+ * `WasmEngineClient` route through this (No Drift Surfaces), so lineage threads identically
131
+ * in production and in-harness.
132
+ *
133
+ * An EXPLICIT caller-supplied `_urban.lineage` always wins (start a new root or re-parent
134
+ * deliberately) — it is returned untouched. Otherwise the envelope is derived from the
135
+ * ambient job context ({@link deriveLineage}) and merged in.
136
+ */
137
+ export function applyAmbientLineage(
138
+ variables: Record<string, unknown> | undefined,
139
+ opts: ApplyLineageOptions = {},
140
+ ): Record<string, unknown> {
141
+ const base = isRecord(variables) ? variables : {};
142
+ if (readLineage(base)) return { ...base };
143
+ const ambient = opts.ambient !== undefined ? opts.ambient : currentJobContext();
144
+ return writeLineage(base, deriveLineage(ambient, opts.mintRootRequestKey));
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Read projection — union the weak (envelope) and strong (parentProcessInstanceKey)
149
+ // edges into a stitched descendant tree keyed by rootRequestKey.
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /** How a node connects to its cause. `weak` = the causal envelope edge; `strong` = the
153
+ * engine-native execution edge (`parentProcessInstanceKey`, Magikcraft/nano-bpm#808). */
154
+ export type LineageEdgeType = "weak" | "strong";
155
+
156
+ /** One directed edge cause → effect in a lineage tree. `causedByInstanceKey` is absent on a
157
+ * root's own edge. */
158
+ export interface LineageEdge {
159
+ readonly rootRequestKey: string;
160
+ /** The effect / descendant instance this edge lands on. */
161
+ readonly instanceKey: string;
162
+ /** The cause / parent instance, or `undefined` for a root. */
163
+ readonly causedByInstanceKey?: string;
164
+ readonly edgeType: LineageEdgeType;
165
+ }
166
+
167
+ /** An app-registered domain row attached to a lineage node (a PR, a task, …). The framework
168
+ * stores it opaquely by `(nodeKey, kind, ref)` without knowing the app's schema. */
169
+ export interface LineageAttachment {
170
+ /** The instance key this row hangs off. */
171
+ readonly nodeKey: string;
172
+ /** App-defined category, e.g. `"pull_request"`, `"task"`. */
173
+ readonly kind: string;
174
+ /** App-defined reference into its own store, e.g. a PR key. */
175
+ readonly ref: string;
176
+ /** Optional human-readable label. */
177
+ readonly label?: string;
178
+ }
179
+
180
+ /** A node in the stitched lineage tree. */
181
+ export interface LineageNode {
182
+ readonly instanceKey: string;
183
+ /** How this node connects to its parent; `undefined` for the root. */
184
+ readonly edgeType?: LineageEdgeType;
185
+ readonly causedByInstanceKey?: string;
186
+ readonly attachments: LineageAttachment[];
187
+ readonly children: LineageNode[];
188
+ }
189
+
190
+ /** The stitched descendant tree for one `rootRequestKey`. */
191
+ export interface LineageTree {
192
+ readonly rootRequestKey: string;
193
+ /** The root node of the stitched tree, always present. Normally the top-level request's own
194
+ * instance; when `rootRequestKey` is a minted synthetic key, the parentless root instance it
195
+ * threads. Falls back to a synthetic node keyed by `rootRequestKey` when no edges exist. */
196
+ readonly root: LineageNode;
197
+ /** Every node keyed by instance, in insertion order — a convenience flat view. */
198
+ readonly nodes: LineageNode[];
199
+ }
200
+
201
+ /**
202
+ * Build the stitched descendant tree for `rootRequestKey` by unioning weak and strong edges.
203
+ * When both edge sources connect the SAME node, the STRONG (engine execution) edge wins — it
204
+ * is the authoritative parent — so #808's native edge supersedes the envelope's weak guess
205
+ * without duplicating it. Nodes whose parent chain does not reach the root stay unattached
206
+ * (they still appear in `nodes`). The root is the instance keyed by `rootRequestKey` when that
207
+ * is itself an instance, else the single parentless recorded instance (a minted `rootRequestKey`
208
+ * is a synthetic correlation key, not an instance), else a synthetic node. Pure and
209
+ * deterministic — the store is a thin persistence layer over this.
210
+ */
211
+ export function buildLineageTree(
212
+ rootRequestKey: string,
213
+ edges: readonly LineageEdge[],
214
+ attachments: readonly LineageAttachment[] = [],
215
+ ): LineageTree {
216
+ // Collapse to one edge per effect instance: strong supersedes weak.
217
+ const byInstance = new Map<string, LineageEdge>();
218
+ for (const e of edges) {
219
+ if (e.rootRequestKey !== rootRequestKey) continue;
220
+ const prev = byInstance.get(e.instanceKey);
221
+ if (!prev || (prev.edgeType === "weak" && e.edgeType === "strong")) {
222
+ byInstance.set(e.instanceKey, e);
223
+ }
224
+ }
225
+
226
+ const nodes = new Map<string, MutableNode>();
227
+ const ensure = (instanceKey: string): MutableNode => {
228
+ let n = nodes.get(instanceKey);
229
+ if (!n) {
230
+ n = { instanceKey, attachments: [], children: [] };
231
+ nodes.set(instanceKey, n);
232
+ }
233
+ return n;
234
+ };
235
+
236
+ // Speculatively materialise the `rootRequestKey` node so descendants whose cause IS the root
237
+ // (a child inherits the root's own instance key as `rootRequestKey`) can attach to it below.
238
+ const rootByKey = ensure(rootRequestKey);
239
+
240
+ for (const e of byInstance.values()) {
241
+ const node = ensure(e.instanceKey);
242
+ node.edgeType = e.edgeType;
243
+ node.causedByInstanceKey = e.causedByInstanceKey;
244
+ }
245
+
246
+ // Attachments are an app extension point keyed by instance. MATERIALISE the target node even
247
+ // when no edge has been recorded for it yet, so an attachment for an as-yet-unstitched instance
248
+ // is preserved in `tree.nodes` (unattached until its edge lands) rather than silently dropped.
249
+ for (const a of attachments) {
250
+ const node = ensure(a.nodeKey);
251
+ node.attachments.push({ nodeKey: a.nodeKey, kind: a.kind, ref: a.ref, ...(a.label ? { label: a.label } : {}) });
252
+ }
253
+
254
+ for (const node of nodes.values()) {
255
+ const parentKey = node.causedByInstanceKey;
256
+ if (parentKey && parentKey !== node.instanceKey && nodes.has(parentKey)) {
257
+ ensure(parentKey).children.push(node);
258
+ }
259
+ }
260
+
261
+ // Resolve the root node. Usually `rootRequestKey` IS the root instance's own key, so `rootByKey`
262
+ // carries that instance's edge and descendants. But a genuine top-level request (no ambient job)
263
+ // MINTS a synthetic `rootRequestKey` — a UUID that is not any instance — so `rootByKey` is an
264
+ // empty phantom while the real root is the single recorded instance with no cause. Prefer that
265
+ // parentless instance and drop the phantom, so the tree is never rooted at a childless synthetic
266
+ // node with the real root left floating detached (the bug this guards against).
267
+ let root = rootByKey;
268
+ const rootIsPhantom =
269
+ rootByKey.edgeType === undefined && rootByKey.children.length === 0 && rootByKey.attachments.length === 0;
270
+ if (rootIsPhantom) {
271
+ // A candidate real root is a parentless node that is genuinely part of the causal structure —
272
+ // it has its own recorded edge or descendants. Attachment-only orphan nodes (materialised above
273
+ // with no edge and no children) are NOT candidates, so a stray attachment can never shadow the
274
+ // real parentless root and re-introduce the phantom-root bug guarded by the minted-root case.
275
+ const parentlessInstances = [...nodes.values()].filter(
276
+ (n) =>
277
+ n !== rootByKey &&
278
+ n.causedByInstanceKey === undefined &&
279
+ (n.edgeType !== undefined || n.children.length > 0),
280
+ );
281
+ if (parentlessInstances.length === 1) {
282
+ root = parentlessInstances[0];
283
+ nodes.delete(rootRequestKey);
284
+ }
285
+ }
286
+
287
+ return { rootRequestKey, root, nodes: [...nodes.values()] };
288
+ }
289
+
290
+ /** The internal, mutable view of {@link LineageNode} used while building the tree. */
291
+ interface MutableNode {
292
+ instanceKey: string;
293
+ edgeType?: LineageEdgeType;
294
+ causedByInstanceKey?: string;
295
+ attachments: LineageAttachment[];
296
+ children: MutableNode[];
297
+ }
@@ -281,6 +281,15 @@ export class DataLayer {
281
281
  return s;
282
282
  }
283
283
 
284
+ /**
285
+ * Whether a default data source is *configured* — not whether it provisions. Lets a caller
286
+ * distinguish the expected "no source at all" case from a genuine misconfiguration (a default is
287
+ * named but its source is missing, so {@link source} throws `no such data source`).
288
+ */
289
+ hasDefaultSource(): boolean {
290
+ return this.defaultSource !== undefined;
291
+ }
292
+
284
293
  /** A typed accessor for a declared domain type (uses the default source). */
285
294
  repo(typeName: string, sourceName?: string): TypeRepo {
286
295
  const def = this.types[typeName];