holosphere 1.3.0-alpha3 → 1.3.0-alpha5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/content.js CHANGED
@@ -1,5 +1,96 @@
1
1
  // holo_content.js
2
2
 
3
+ /**
4
+ * Default deadline (ms) for the read paths' `.once()` calls. Gun's `.once()`
5
+ * never fires when the requested node isn't in the local graph and no peer
6
+ * answers (cold-start, offline, partitioned mesh) — past consumers worked
7
+ * around this by wrapping every `getAll` in their own `Promise.race(reject,
8
+ * setTimeout(8000))`. Owning it here means the spinner can never hang
9
+ * indefinitely on a cold path and every caller stops needing the wrapper.
10
+ *
11
+ * Pick `READ_TIMEOUT_MS = 0` (or pass `{ timeout: 0 }`) to opt out of the
12
+ * fallback and keep the historical "wait forever" behaviour.
13
+ */
14
+ const READ_TIMEOUT_MS = 8000;
15
+
16
+ /**
17
+ * `.once()` wrapped in a deadline. Resolves with the value when Gun fires
18
+ * back, or `null` after `timeoutMs` if it hasn't. Pass `timeoutMs <= 0` to
19
+ * disable the deadline.
20
+ *
21
+ * The first responder wins — both branches are idempotent so a late
22
+ * `.once()` callback after timeout is harmlessly ignored.
23
+ */
24
+ function onceWithTimeout(node, timeoutMs = READ_TIMEOUT_MS) {
25
+ return new Promise((resolve) => {
26
+ let done = false;
27
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
28
+ node.once((data) => finish(data));
29
+ if (timeoutMs > 0) {
30
+ setTimeout(() => finish(null), timeoutMs);
31
+ }
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Recursively sanitizes a value for storage in GunDB.
37
+ *
38
+ * Drops keys whose values would corrupt the graph or round-trip incorrectly:
39
+ * - undefined, NaN, Infinity, -Infinity (JSON.stringify silently turns
40
+ * these into nothing or "null", which is how malformed payloads like
41
+ * `"initiated":,` end up in the graph and surface later as per-character
42
+ * parse warnings).
43
+ * - functions, symbols, bigints (not JSON-representable).
44
+ * Preserves null (legitimate Gun tombstone / explicit empty value).
45
+ * Guards against circular references.
46
+ *
47
+ * Logs one warning per dropped path so the caller can fix the producer.
48
+ */
49
+ function sanitizeForStorage(value, path = '', seen = new WeakSet(), warnings = []) {
50
+ if (value === null) return null;
51
+ const t = typeof value;
52
+
53
+ if (t === 'number') {
54
+ if (!Number.isFinite(value)) {
55
+ warnings.push(`${path || '<root>'}: ${value} (non-finite number)`);
56
+ return undefined;
57
+ }
58
+ return value;
59
+ }
60
+ if (t === 'string' || t === 'boolean') return value;
61
+ if (t === 'undefined' || t === 'function' || t === 'symbol' || t === 'bigint') {
62
+ warnings.push(`${path || '<root>'}: ${t}`);
63
+ return undefined;
64
+ }
65
+
66
+ if (t === 'object') {
67
+ if (seen.has(value)) {
68
+ warnings.push(`${path || '<root>'}: circular reference`);
69
+ return undefined;
70
+ }
71
+ seen.add(value);
72
+
73
+ if (Array.isArray(value)) {
74
+ const out = [];
75
+ for (let i = 0; i < value.length; i++) {
76
+ const cleaned = sanitizeForStorage(value[i], `${path}[${i}]`, seen, warnings);
77
+ out.push(cleaned === undefined ? null : cleaned);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ const out = {};
83
+ for (const k of Object.keys(value)) {
84
+ const cleaned = sanitizeForStorage(value[k], path ? `${path}.${k}` : k, seen, warnings);
85
+ if (cleaned !== undefined) out[k] = cleaned;
86
+ }
87
+ return out;
88
+ }
89
+
90
+ warnings.push(`${path || '<root>'}: unsupported type ${t}`);
91
+ return undefined;
92
+ }
93
+
3
94
  /**
4
95
  * Stores content in the specified holon and lens.
5
96
  * If the target path already contains a hologram, the put operation will be
@@ -142,15 +233,38 @@ export async function put(holoInstance, holon, lens, data, password = null, opti
142
233
 
143
234
  return new Promise((resolve, reject) => {
144
235
  try {
145
- // Create a copy of data, stripping read-side envelopes that
146
- // must never be persisted (they're attached at resolution time).
147
- let dataToStore = { ...data };
148
- if (dataToStore._meta !== undefined) {
149
- delete dataToStore._meta;
150
- }
151
- if (dataToStore._hologram !== undefined) {
152
- delete dataToStore._hologram;
236
+ // Sanitize before serialization so undefined/NaN/Infinity etc.
237
+ // can never produce a malformed payload like `"initiated":,`
238
+ // (which is what causes per-character parse warnings on read).
239
+ const sanitizeWarnings = [];
240
+ let dataToStore = sanitizeForStorage(data, '', new WeakSet(), sanitizeWarnings) || {};
241
+ if (sanitizeWarnings.length > 0) {
242
+ console.warn(
243
+ `holosphere.put: sanitized ${sanitizeWarnings.length} field(s) at ${targetHolon}/${targetLens}/${targetKey} (id=${data.id}):`,
244
+ sanitizeWarnings
245
+ );
153
246
  }
247
+ // Strip read-side envelopes that must never be persisted
248
+ // (they're attached at resolution time).
249
+ //
250
+ // `_hologram` and `_meta` are always read-side-only.
251
+ //
252
+ // `_federation` is also read-side for ordinary writes — it
253
+ // describes where data was fetched from, not where it
254
+ // currently lives. The one legitimate carrier is federation
255
+ // propagation, which writes hologram envelopes (top-level
256
+ // `id` + `soul`) tagged with `_federation` provenance; we
257
+ // detect that via `isHologram(data)` and leave it alone.
258
+ // Without this, a UI that reads a federated record and puts
259
+ // it back ends up persisting stale `_federation.origin`
260
+ // metadata, which then drives downstream code (federation
261
+ // propagators, hologram resolvers) to write or follow
262
+ // pointers that don't match the current storage location —
263
+ // producing "broken hologram" garbage-collection cascades
264
+ // that silently delete the user's write.
265
+ if (dataToStore._meta !== undefined) delete dataToStore._meta;
266
+ if (dataToStore._hologram !== undefined) delete dataToStore._hologram;
267
+ if (!isHologram && dataToStore._federation !== undefined) delete dataToStore._federation;
154
268
  const payload = JSON.stringify(dataToStore); // The data being stored
155
269
 
156
270
  const putCallback = async (ack) => {
@@ -335,7 +449,19 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
335
449
  }
336
450
 
337
451
  // Destructure options, including visited
338
- const { resolveHolograms = true, validationOptions = {}, visited } = options;
452
+ const {
453
+ resolveHolograms = true,
454
+ validationOptions = {},
455
+ visited,
456
+ timeout = READ_TIMEOUT_MS,
457
+ // `_deleted: true` is the soft-tombstone convention used by the bot,
458
+ // the web dashboard, and the MCP council tools. Pre-this fix the
459
+ // library was unaware of it and every caller filtered defensively.
460
+ // Now `get` returns `null` for tombstoned records by default; pass
461
+ // `includeDeleted: true` to surface them (admin/debug views,
462
+ // history reconstruction, etc.).
463
+ includeDeleted = false,
464
+ } = options;
339
465
 
340
466
  // Get schema for validation if in strict mode
341
467
  let schema = null;
@@ -379,6 +505,14 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
379
505
  return;
380
506
  }
381
507
 
508
+ // Treat the harvest-side `_deleted: true` soft-tombstone
509
+ // as "not found" by default. Callers that want to see
510
+ // tombstones can pass `{ includeDeleted: true }`.
511
+ if (!includeDeleted && parsed._deleted === true) {
512
+ resolve(null);
513
+ return;
514
+ }
515
+
382
516
  // Check if this is a hologram that needs to be resolved
383
517
  if (resolveHolograms && holoInstance.isHologram(parsed)) {
384
518
  const resolvedValue = await holoInstance.resolveHologram(parsed, {
@@ -389,17 +523,20 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
389
523
  });
390
524
 
391
525
  if (resolvedValue === null) {
392
- // This means resolveHologram determined the target doesn't exist or encountered an error
393
- console.warn(`Broken hologram detected at ${holon}/${lens}/${key}. Removing it...`);
394
-
395
- try {
396
- // Delete the broken hologram
397
- await holoInstance.delete(holon, lens, key, password);
398
- console.log(`Successfully removed broken hologram from ${holon}/${lens}/${key}`);
399
- } catch (cleanupError) {
400
- console.error(`Failed to remove broken hologram at ${holon}/${lens}/${key}:`, cleanupError);
401
- }
402
-
526
+ // `resolveHologram` returned null. DON'T treat this
527
+ // as a permission to delete — null fires for several
528
+ // transient reasons:
529
+ // - source soul not in our local Gun graph yet
530
+ // (peer offline, federation propagation in flight)
531
+ // - maxDepth (10) reached on a deep hologram chain
532
+ // - circular reference detected mid-chain
533
+ // - any internal resolve error
534
+ // None of these prove the pointer is permanently
535
+ // broken, but the old behaviour `await delete(...)`
536
+ // here permanently destroyed real data on the first
537
+ // transient miss. Skip the entry instead; a real
538
+ // garbage collector should own dead-pointer cleanup.
539
+ console.warn(`Hologram at ${holon}/${lens}/${key} did not resolve (soul=${parsed.soul}); skipping.`);
403
540
  resolve(null);
404
541
  return;
405
542
  }
@@ -407,7 +544,7 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
407
544
  // If it returned the hologram itself (if we ever revert to that), this logic would need adjustment.
408
545
  // For now, assume resolvedValue is either the resolved data or we've returned null above.
409
546
 
410
- if (resolvedValue !== parsed) {
547
+ if (resolvedValue !== parsed) {
411
548
  parsed = resolvedValue;
412
549
  }
413
550
  }
@@ -440,7 +577,10 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
440
577
  user.get('private').get(lens).get(key) :
441
578
  holoInstance.gun.get(holoInstance.appname).get(holon).get(lens).get(key);
442
579
 
443
- dataPath.once(handleData);
580
+ // `.once()` wrapped in a deadline — cold-path reads (peer offline,
581
+ // never-written key) used to hang forever otherwise. After
582
+ // `timeout` ms with no Gun response we treat it as "not found".
583
+ onceWithTimeout(dataPath, timeout).then(handleData);
444
584
  });
445
585
  } catch (error) {
446
586
  console.error('Error in get:', error);
@@ -454,12 +594,23 @@ export async function get(holoInstance, holon, lens, key, password = null, optio
454
594
  * @param {string} holon - The holon identifier.
455
595
  * @param {string} lens - The lens from which to retrieve content.
456
596
  * @param {string} [password] - Optional password for private holon.
597
+ * @param {object} [options] - Additional options.
598
+ * @param {number} [options.timeout=READ_TIMEOUT_MS] - Per-`.once()` deadline
599
+ * (ms); the shallow probe falls back to "empty" after this on cold paths
600
+ * where Gun never responds. Pass `0` to disable and keep the historical
601
+ * "wait forever" behaviour.
457
602
  * @returns {Promise<Array<object>>} - The retrieved content.
458
603
  */
459
- export async function getAll(holoInstance, holon, lens, password = null) {
604
+ export async function getAll(holoInstance, holon, lens, password = null, options = {}) {
460
605
  if (!holon || !lens) {
461
606
  throw new Error('getAll: Missing required parameters');
462
607
  }
608
+ const {
609
+ timeout = READ_TIMEOUT_MS,
610
+ // See `get` above: `_deleted: true` records are dropped from the
611
+ // response unless the caller opts in.
612
+ includeDeleted = false,
613
+ } = options;
463
614
 
464
615
  const schema = await holoInstance.getSchema(lens);
465
616
  if (!schema && holoInstance.strict) {
@@ -519,9 +670,12 @@ export async function getAll(holoInstance, holon, lens, password = null) {
519
670
  holoInstance.gun.get(holoInstance.appname).get(holon).get(lens);
520
671
 
521
672
  // PASS 1: Get shallow node to determine expected item count.
522
- // Retry once if empty Gun's .once() reads from local cache, which
523
- // may be cold immediately after startup before peers have synced.
524
- const shallowOnce = () => new Promise((res) => dataPath.once((d) => res(d)));
673
+ // Wrapped in a deadline (see `onceWithTimeout`) so cold paths
674
+ // resolve `null` after `timeout` ms instead of hanging forever.
675
+ // We still retry once below Gun's `.once()` reads from local
676
+ // cache, which may be cold immediately after startup before
677
+ // peers have synced.
678
+ const shallowOnce = () => onceWithTimeout(dataPath, timeout);
525
679
 
526
680
  const processShallow = (data) => {
527
681
  if (!data) {
@@ -556,6 +710,11 @@ export async function getAll(holoInstance, holon, lens, password = null) {
556
710
  const parsed = await holoInstance.parse(itemData);
557
711
  if (!parsed || !parsed.id) return;
558
712
 
713
+ // Drop `_deleted: true` soft-tombstones by default;
714
+ // callers wanting them in the result pass
715
+ // `{ includeDeleted: true }` to `getAll`.
716
+ if (!includeDeleted && parsed._deleted === true) return;
717
+
559
718
  if (holoInstance.isHologram(parsed)) {
560
719
  try {
561
720
  const resolved = await holoInstance.resolveHologram(parsed, {
@@ -565,12 +724,11 @@ export async function getAll(holoInstance, holon, lens, password = null) {
565
724
  });
566
725
 
567
726
  if (resolved === null) {
568
- console.warn(`Broken hologram detected in getAll for key ${key}. Removing it...`);
569
- try {
570
- await holoInstance.delete(holon, lens, key, password);
571
- } catch (cleanupError) {
572
- console.error(`Failed to remove broken hologram at ${holon}/${lens}/${key}:`, cleanupError);
573
- }
727
+ // See `get()` above: null is not proof of a
728
+ // permanently dead pointer. Skip the entry
729
+ // without deleting so transient resolution
730
+ // failures don't destroy real data.
731
+ console.warn(`Hologram at ${holon}/${lens}/${key} did not resolve (soul=${parsed.soul}); skipping.`);
574
732
  return;
575
733
  }
576
734
 
@@ -613,11 +771,10 @@ export async function getAll(holoInstance, holon, lens, password = null) {
613
771
  return processItem(inline, key);
614
772
  }
615
773
  // Otherwise fetch the leaf — sub-graph reference path.
616
- return new Promise((resolveItem) => {
617
- dataPath.get(key).once((itemData) => {
618
- processItem(itemData, key).then(resolveItem, resolveItem);
619
- });
620
- });
774
+ // Same deadline as the shallow probe; a single missing
775
+ // leaf can't stall the whole batch.
776
+ return onceWithTimeout(dataPath.get(key), timeout)
777
+ .then((itemData) => processItem(itemData, key));
621
778
  })).then(() => resolve(Array.from(output.values())));
622
779
  };
623
780
 
package/federation.js CHANGED
@@ -6,6 +6,35 @@
6
6
  import * as h3 from 'h3-js';
7
7
  import { attachHologramMeta } from './hologram.js';
8
8
 
9
+ /**
10
+ * Look up a holon's display name from its `settings` lens.
11
+ *
12
+ * Returns the `name` field of the holon's settings document, or null if
13
+ * settings are missing or unnamed. Callers should fall back to the bare
14
+ * holon id when this returns null.
15
+ *
16
+ * @param {HoloSphere} holosphere
17
+ * @param {string} space - holon id
18
+ * @returns {Promise<string|null>}
19
+ */
20
+ async function getHolonName(holosphere, space) {
21
+ if (!holosphere || !space) return null;
22
+ try {
23
+ const settings = await holosphere.get(space, 'settings', space);
24
+ if (!settings) return null;
25
+ if (Array.isArray(settings)) {
26
+ const found = settings.find(s => s && typeof s.name === 'string' && s.name.trim() !== '');
27
+ return found ? found.name : null;
28
+ }
29
+ if (typeof settings.name === 'string' && settings.name.trim() !== '') {
30
+ return settings.name;
31
+ }
32
+ return null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
9
38
  /**
10
39
  * Creates a directional federation relationship between two spaces.
11
40
  *
@@ -149,7 +178,18 @@ export async function subscribeFederation(holosphere, spaceId, password = null,
149
178
  const subscriptions = [];
150
179
  let lastNotificationTime = {};
151
180
 
181
+ // Cache partner display names once at setup. Each callback firing for
182
+ // a given partner uses the same name without re-reading settings.
183
+ const partnerNames = new Map();
184
+
152
185
  if (fedInfo.inbound && fedInfo.inbound.length > 0) {
186
+ await Promise.all(
187
+ fedInfo.inbound.map(async space => {
188
+ const name = await getHolonName(holosphere, space);
189
+ partnerNames.set(space, name);
190
+ })
191
+ );
192
+
153
193
  for (const federatedSpace of fedInfo.inbound) {
154
194
  // For each lens specified (or all if '*')
155
195
  for (const lens of lenses) {
@@ -158,27 +198,34 @@ export async function subscribeFederation(holosphere, spaceId, password = null,
158
198
  try {
159
199
  // Skip if data is missing or not from federated space
160
200
  if (!data || !data.id) return;
161
-
201
+
162
202
  // Apply throttling if configured
163
203
  const now = Date.now();
164
204
  const key = `${federatedSpace}_${lens}_${data.id}`;
165
-
205
+
166
206
  if (throttle > 0) {
167
- if (lastNotificationTime[key] &&
207
+ if (lastNotificationTime[key] &&
168
208
  (now - lastNotificationTime[key]) < throttle) {
169
209
  return; // Skip this notification (throttled)
170
210
  }
171
211
  lastNotificationTime[key] = now;
172
212
  }
173
-
174
- // Add federation metadata if not present
175
- if (!data.federation) {
176
- data.federation = {
213
+
214
+ // Add federation metadata if not present.
215
+ // Use the canonical `_federation` envelope so it
216
+ // matches what propagate() and getFederated()
217
+ // produce, and what consumers (UI badges, etc.)
218
+ // already check for.
219
+ if (!data._federation) {
220
+ const partnerName = partnerNames.get(federatedSpace);
221
+ data._federation = {
177
222
  origin: federatedSpace,
178
- timestamp: now
223
+ sourceLens: lens,
224
+ timestamp: now,
225
+ ...(partnerName ? { originName: partnerName } : {})
179
226
  };
180
227
  }
181
-
228
+
182
229
  // Execute callback with the data
183
230
  await callback(data, federatedSpace, lens);
184
231
  } catch (error) {
@@ -433,7 +480,7 @@ export async function removeNotify(holosphere, spaceId1, spaceId2, password1 = n
433
480
  */
434
481
  export async function getFederated(holosphere, holon, lens, options = {}) {
435
482
  // Set default options and extract queryIds
436
- const {
483
+ const {
437
484
  queryIds = null, // New option
438
485
  aggregate = false,
439
486
  idField = 'id',
@@ -443,9 +490,16 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
443
490
  mergeStrategy = null,
444
491
  includeLocal = true,
445
492
  includeFederated = true,
446
- resolveReferences = true,
493
+ resolveReferences = true,
447
494
  maxFederatedSpaces = -1,
448
- timeout = 10000
495
+ timeout = 10000,
496
+ // When `resolveReferences` is on, source-soul fetches that fail get
497
+ // replaced with `{ id, _hologram: { isHologram: false, error } }`
498
+ // error stubs (federation.js around line 654). By default we filter
499
+ // those out of the response so consumers never render broken-card
500
+ // placeholders. Set `includeUnresolvedStubs: true` if you actually
501
+ // want to surface the failure to the user (e.g. an admin/debug view).
502
+ includeUnresolvedStubs = false
449
503
  } = options;
450
504
 
451
505
  console.log(`resolveReferences option: ${resolveReferences}`);
@@ -476,10 +530,37 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
476
530
  spacesToQuery = spacesToQuery.concat(federatedSpaces);
477
531
  }
478
532
 
533
+ // Resolve display names for federated partner spaces in parallel, so
534
+ // every tagged item can carry the holon's name. Compute once per call.
535
+ const remoteSpaces = spacesToQuery.filter(s => s !== holon);
536
+ const spaceNames = new Map();
537
+ await Promise.all(
538
+ remoteSpaces.map(async space => {
539
+ const name = await getHolonName(holosphere, space);
540
+ spaceNames.set(space, name);
541
+ })
542
+ );
543
+
544
+ // Tag items pulled from a federated partner with the space they came
545
+ // from (and its resolved display name, if any). Local items are left
546
+ // untouched so consumers can distinguish own vs. external by absence
547
+ // or presence of `_federation`.
548
+ const tagWithSource = (item, space) => {
549
+ if (!item || space === holon) return item;
550
+ const originName = spaceNames.get(space);
551
+ const fed = {
552
+ ...(item._federation || {}),
553
+ origin: space,
554
+ sourceLens: lens
555
+ };
556
+ if (originName) fed.originName = originName;
557
+ return { ...item, _federation: fed };
558
+ };
559
+
479
560
  // Fetch data from all relevant spaces
480
561
  for (const currentSpace of spacesToQuery) {
481
562
  if (queryIds && Array.isArray(queryIds)) {
482
- // --- Fetch specific IDs using holosphere.get ---
563
+ // --- Fetch specific IDs using holosphere.get ---
483
564
  console.log(`Fetching specific IDs from ${currentSpace}: ${queryIds.join(', ')}`);
484
565
  for (const itemId of queryIds) {
485
566
  if (fetchedItems.has(itemId)) continue; // Skip if already fetched
@@ -487,7 +568,7 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
487
568
  holosphere.get(currentSpace, lens, itemId)
488
569
  .then(item => {
489
570
  if (item) {
490
- fetchedItems.set(itemId, item);
571
+ fetchedItems.set(itemId, tagWithSource(item, currentSpace));
491
572
  }
492
573
  })
493
574
  .catch(err => console.warn(`Error fetching item ${itemId} from ${currentSpace}: ${err.message}`))
@@ -504,7 +585,7 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
504
585
  .then(items => {
505
586
  for (const item of items) {
506
587
  if (item && item[idField] && !fetchedItems.has(item[idField])) {
507
- fetchedItems.set(item[idField], item);
588
+ fetchedItems.set(item[idField], tagWithSource(item, currentSpace));
508
589
  }
509
590
  }
510
591
  })
@@ -554,7 +635,26 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
554
635
  if (originalData) {
555
636
  // Replace the reference with the resolved data, attaching
556
637
  // the canonical _hologram envelope (single source of truth).
557
- result[i] = attachHologramMeta(originalData, item.soul);
638
+ const withMeta = attachHologramMeta(originalData, item.soul);
639
+ // Stamp the source holon's display name so consumers
640
+ // don't need a second round-trip to render it. Use
641
+ // the per-call cache when possible to avoid duplicate
642
+ // settings reads across many holograms from the same
643
+ // source.
644
+ if (withMeta._hologram?.sourceHolon) {
645
+ let sourceHolonName = spaceNames.get(withMeta._hologram.sourceHolon);
646
+ if (sourceHolonName === undefined) {
647
+ sourceHolonName = await getHolonName(holosphere, withMeta._hologram.sourceHolon);
648
+ spaceNames.set(withMeta._hologram.sourceHolon, sourceHolonName);
649
+ }
650
+ if (sourceHolonName) {
651
+ withMeta._hologram = {
652
+ ...withMeta._hologram,
653
+ sourceHolonName
654
+ };
655
+ }
656
+ }
657
+ result[i] = withMeta;
558
658
  } else {
559
659
  // Original data not found — keep the id so callers can
560
660
  // identify the broken reference, and surface the error.
@@ -644,14 +744,28 @@ export async function getFederated(holosphere, holon, lens, options = {}) {
644
744
  count: group.length,
645
745
  timestamp: Date.now()
646
746
  };
647
-
747
+
648
748
  return base;
649
749
  });
650
-
651
- return aggregatedData;
750
+
751
+ return includeUnresolvedStubs ? aggregatedData : aggregatedData.filter(isResolved);
652
752
  }
653
-
654
- return result;
753
+
754
+ return includeUnresolvedStubs ? result : result.filter(isResolved);
755
+ }
756
+
757
+ /**
758
+ * True for any record that isn't an unresolved-reference error stub.
759
+ *
760
+ * `getFederated` produces stubs of the shape
761
+ * `{ id, _hologram: { isHologram: false, error, ... } }` when a source
762
+ * soul can't be resolved (peer offline, federation in flight, etc.).
763
+ * Default response now filters those out so consumers don't render
764
+ * broken-card placeholders.
765
+ */
766
+ function isResolved(item) {
767
+ if (!item || typeof item !== 'object') return false;
768
+ return item._hologram?.isHologram !== false;
655
769
  }
656
770
 
657
771
  /**
@@ -702,7 +816,12 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
702
816
 
703
817
  // Only propagate if we have outbound partners configured.
704
818
  if (fedInfo && fedInfo.outbound && fedInfo.outbound.length > 0) {
705
- let spaces = fedInfo.outbound;
819
+ // Never propagate back to ourselves. Writing a hologram to the
820
+ // source holon overwrites the original data with a self-
821
+ // referencing pointer, and HoloSphere's get() then auto-deletes
822
+ // it as a "broken hologram" (resolveHologram returns null because
823
+ // the soul resolves to itself), silently destroying the entry.
824
+ let spaces = fedInfo.outbound.filter(s => String(s) !== String(holon));
706
825
 
707
826
  if (targetSpaces && Array.isArray(targetSpaces) && targetSpaces.length > 0) {
708
827
  spaces = spaces.filter(space => targetSpaces.includes(space));
@@ -732,6 +851,11 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
732
851
  // Check if data is already a hologram
733
852
  const isAlreadyHologram = holosphere.isHologram(data);
734
853
 
854
+ // Resolve our own holon's name once so every propagated
855
+ // payload carries it. Falls back to undefined (the field
856
+ // is omitted) so consumers can use the bare holon id.
857
+ const ownName = await getHolonName(holosphere, holon);
858
+
735
859
  // For each target space, propagate the data
736
860
  const propagatePromises = spaces.map(async (targetSpace) => {
737
861
  try {
@@ -740,7 +864,8 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
740
864
  origin: holon, // The space from which this data is being propagated
741
865
  sourceLens: lens, // The lens from which this data is being propagated
742
866
  propagatedAt: Date.now(),
743
- originalId: data.id
867
+ originalId: data.id,
868
+ ...(ownName ? { originName: ownName } : {})
744
869
  };
745
870
 
746
871
  if (useHolograms && !isAlreadyHologram) {
@@ -761,11 +886,19 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
761
886
  }
762
887
  };
763
888
  }
764
-
889
+
890
+ // Defensive: even if the outbound list somehow
891
+ // contains the source holon, never write a self-
892
+ // hologram. The source already has the original.
893
+ if (String(targetSpace) === String(holon)) {
894
+ result.skipped++;
895
+ return true;
896
+ }
897
+
765
898
  // Store in the target space with redirection disabled and no further auto-propagation
766
- await holosphere.put(targetSpace, lens, payloadToPut, null, {
767
- disableHologramRedirection: true,
768
- autoPropagate: false
899
+ await holosphere.put(targetSpace, lens, payloadToPut, null, {
900
+ disableHologramRedirection: true,
901
+ autoPropagate: false
769
902
  });
770
903
 
771
904
  result.success++;
@@ -847,7 +980,11 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
847
980
 
848
981
  // Check if data is already a hologram (reuse from federation section)
849
982
  const isAlreadyHologram = holosphere.isHologram(data);
850
-
983
+
984
+ // Resolve our own holon's name once for parent propagation
985
+ // (same as the federation block above).
986
+ const ownNameParent = await getHolonName(holosphere, holon);
987
+
851
988
  // Propagate to each parent hexagon
852
989
  const parentPropagatePromises = parentHexagons.map(async (parentHexagon) => {
853
990
  try {
@@ -858,7 +995,8 @@ export async function propagate(holosphere, holon, lens, data, options = {}) {
858
995
  propagatedAt: Date.now(),
859
996
  originalId: data.id,
860
997
  propagationType: 'parent', // Indicate this is parent propagation
861
- parentLevel: holonResolution - h3.getResolution(parentHexagon) // How many levels up
998
+ parentLevel: holonResolution - h3.getResolution(parentHexagon), // How many levels up
999
+ ...(ownNameParent ? { originName: ownNameParent } : {})
862
1000
  };
863
1001
 
864
1002
  if (useHolograms && !isAlreadyHologram) {