@arki/dot 0.1.0 → 0.1.1

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.
@@ -6,12 +6,17 @@
6
6
  */
7
7
  import { Logger } from '@arki/log';
8
8
  import { createDebugLogger } from '@arki/log/debug';
9
- import { buildDependencyEdges, topologicalSort } from '../dependency-graph.js';
10
9
  import { DotLifecycleError, DotLifecycleErrorCode } from '../lifecycle.js';
11
- import { pipProvides } from '../pip-contract.js';
10
+ import { isLazy, isLazyWitness, lazyOf } from '../pip-contract.js';
12
11
  import { withPhaseSpan, withPipHookSpan } from './otel.js';
13
12
  const debugKernel = createDebugLogger('arki:dot:kernel');
14
13
  const DOCS_BASE = 'https://docs.arki.dev/dot/diagnostics';
14
+ /** Resolve a needs-shape witness to its wire key. */
15
+ function wireKeyOf(witness, alias) {
16
+ return 'key' in witness && typeof witness.key === 'string'
17
+ ? witness.key
18
+ : alias;
19
+ }
15
20
  /** Helper for stable issue construction. */
16
21
  function makeIssue(args) {
17
22
  return {
@@ -32,16 +37,20 @@ export class DotAppImpl {
32
37
  #appName;
33
38
  #appVersion;
34
39
  #config;
35
- /** Pips in topological order (set after the constructor sorts them). */
40
+ /** Pips in declaration order v2 boot order IS declaration order. */
36
41
  #ordered;
37
42
  #records;
38
43
  /** Macro-state of the app. */
39
44
  #state = 'defined';
40
45
  /** Manifest finalised after `configure`. */
41
46
  #manifest;
42
- /** Cached services map — populated as pips boot. */
47
+ /** Wire-keyed services map — populated as pips boot. */
43
48
  #serviceMap = new Map();
44
- /** Cached merged services record for `start`/`stop`/`dispose` contexts. */
49
+ /** Which pip provided each wire key — feeds dependency edges + collisions. */
50
+ #providerByWireKey = new Map();
51
+ /** Dependency edges observed during boot (consumer → provider). */
52
+ #wiringEdges = [];
53
+ /** Merged wire-keyed services record exposed as `app.services`. */
45
54
  #aggregatedServices = {};
46
55
  /** Configure has already happened (idempotent). */
47
56
  #configured = false;
@@ -74,20 +83,27 @@ export class DotAppImpl {
74
83
  this.#config = Object.freeze({ ...config.config });
75
84
  this.#logger = new Logger('arki:dot:lifecycle', { 'dot.app.name': config.appName });
76
85
  this.#observers = new Set(config.observers);
77
- // Topological sort happens up-front surfaces cycle/dup/missing-dep errors
78
- // at construction (the `configure` phase pseudo-time).
79
- debugKernel('[%s] topologically sorting %d pip(s)', this.#appName, config.pips.length);
80
- this.#ordered = topologicalSort(config.pips);
86
+ // Declaration order is boot order in v2. Duplicate names are surfaced at
87
+ // construction (the `configure` phase pseudo-time).
88
+ debugKernel('[%s] registering %d pip(s) in declaration order', this.#appName, config.pips.length);
89
+ this.#ordered = [...config.pips];
81
90
  this.#records = new Map();
82
91
  for (const [order, pip] of this.#ordered.entries()) {
92
+ if (this.#records.has(pip.name)) {
93
+ throw new DotLifecycleError({
94
+ code: DotLifecycleErrorCode.DuplicatePip,
95
+ phase: 'configure',
96
+ pip: pip.name,
97
+ message: `Pip "${pip.name}" is registered twice`,
98
+ });
99
+ }
83
100
  this.#records.set(pip.name, {
84
101
  pip,
85
102
  order,
86
103
  routes: [],
87
104
  services: [],
88
105
  hooks: new Set(),
89
- provides: new Set(pipProvides(pip)),
90
- extraDependencies: [],
106
+ provides: new Set(),
91
107
  publishedServices: {},
92
108
  booted: false,
93
109
  started: false,
@@ -278,7 +294,7 @@ export class DotAppImpl {
278
294
  for (const pip of this.#ordered) {
279
295
  const record = this.#records.get(pip.name);
280
296
  const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
281
- if (!pip.configure) {
297
+ if (!pip.hooks.configure) {
282
298
  pipLogger.debug('configure: skipped (no hook)');
283
299
  continue;
284
300
  }
@@ -299,9 +315,6 @@ export class DotAppImpl {
299
315
  for (const cap of caps)
300
316
  record.provides.add(cap);
301
317
  },
302
- declareDependency: (to, kind) => {
303
- record.extraDependencies.push({ to, kind: kind ?? 'uses' });
304
- },
305
318
  };
306
319
  const started = performance.now();
307
320
  this.#emitHook('configure', pip.name, record.order, 'starting');
@@ -314,7 +327,7 @@ export class DotAppImpl {
314
327
  hook: 'configure',
315
328
  order: record.order,
316
329
  logger: pipLogger,
317
- }, () => pip.configure(ctx));
330
+ }, () => pip.hooks.configure(ctx));
318
331
  }
319
332
  catch (error) {
320
333
  const durationMs = performance.now() - started;
@@ -392,13 +405,13 @@ export class DotAppImpl {
392
405
  // on the pip object, so the manifest reflects them.
393
406
  for (const pip of this.#ordered) {
394
407
  const record = this.#records.get(pip.name);
395
- if (pip.boot)
408
+ if (pip.hooks.boot)
396
409
  record.hooks.add('boot');
397
- if (pip.start)
410
+ if (pip.hooks.start)
398
411
  record.hooks.add('start');
399
- if (pip.stop)
412
+ if (pip.hooks.stop)
400
413
  record.hooks.add('stop');
401
- if (pip.dispose)
414
+ if (pip.hooks.dispose)
402
415
  record.hooks.add('dispose');
403
416
  }
404
417
  this.#configured = true;
@@ -434,6 +447,53 @@ export class DotAppImpl {
434
447
  logger: phaseLogger,
435
448
  }, () => this.#runBootInner(phaseLogger)));
436
449
  }
450
+ /**
451
+ * Resolve a pip's needs into a hook context (alias-keyed), joined with
452
+ * the `$`-prefixed kernel keys and — for post-boot hooks — the pip's own
453
+ * published services (local keys).
454
+ *
455
+ * @throws {DotLifecycleError} `DOT_LIFECYCLE_E012` when a need has no
456
+ * provider among earlier-booted pips. Teardown hooks can never hit
457
+ * this: the service map only grows, and reverse-order teardown keeps
458
+ * providers alive until their consumers are done.
459
+ */
460
+ #buildHookCtx(record, phase, includeOwnProvides) {
461
+ const ctx = {
462
+ $app: this.#appName,
463
+ $pip: record.pip.name,
464
+ $config: this.#config,
465
+ };
466
+ for (const [alias, witness] of Object.entries(record.pip.needs)) {
467
+ const wireKey = wireKeyOf(witness, alias);
468
+ if (!this.#serviceMap.has(wireKey)) {
469
+ throw new DotLifecycleError({
470
+ code: DotLifecycleErrorCode.UnsatisfiedNeed,
471
+ phase,
472
+ pip: record.pip.name,
473
+ message: `Pip "${record.pip.name}" needs service "${wireKey}" but no earlier pip provides it. ` +
474
+ `Register a provider with .use() before this pip. Services flow strictly forward — ` +
475
+ `if a later pip provides "${wireKey}", move that provider earlier; if two pips need ` +
476
+ `each other's services, that is a cycle: merge them or extract the shared piece into ` +
477
+ `a third pip both consume.`,
478
+ });
479
+ }
480
+ const value = this.#serviceMap.get(wireKey);
481
+ // A lazy-lifting witness (`service.lazy<T>()`) always receives a
482
+ // handle: lazy provides pass through, eager provides are lifted into
483
+ // a pre-initialized wrapper. The wrapper is created per-injection and
484
+ // never published, so the kernel's auto-dispose does not touch it —
485
+ // the underlying value's lifecycle stays with its provider.
486
+ ctx[alias] = isLazyWitness(witness) && !isLazy(value) ? lazyOf(value) : value;
487
+ const provider = this.#providerByWireKey.get(wireKey);
488
+ if (provider !== undefined && !this.#wiringEdges.some(e => e.from === record.pip.name && e.to === provider)) {
489
+ this.#wiringEdges.push({ from: record.pip.name, to: provider, kind: 'requires' });
490
+ }
491
+ }
492
+ if (includeOwnProvides) {
493
+ Object.assign(ctx, record.publishedServices);
494
+ }
495
+ return ctx;
496
+ }
437
497
  /**
438
498
  * Inner boot loop. Separated from `#runBoot` so the phase span wrapper
439
499
  * stays thin and the loop body — which orchestrates rollback on
@@ -441,78 +501,126 @@ export class DotAppImpl {
441
501
  */
442
502
  async #runBootInner(phaseLogger) {
443
503
  const bootedRecords = [];
504
+ /** Shared failure path: mark diagnostics, roll back, throw. */
505
+ const fail = async (args) => {
506
+ const issue = makeIssue({
507
+ code: args.code,
508
+ pip: args.record.pip.name,
509
+ message: args.message,
510
+ remediation: args.remediation,
511
+ docsAnchor: args.docsAnchor,
512
+ });
513
+ args.record.issues.push(issue);
514
+ args.record.lifecycleDiagnostics.push({
515
+ pip: args.record.pip.name,
516
+ hook: 'boot',
517
+ state: 'failed',
518
+ order: args.record.order,
519
+ durationMs: args.durationMs,
520
+ issues: [issue],
521
+ });
522
+ this.#emitHook('boot', args.record.pip.name, args.record.order, 'failed', {
523
+ durationMs: args.durationMs,
524
+ error: args.cause ?? new Error(args.message),
525
+ });
526
+ phaseLogger.error('boot: FAILED — rolling back already-booted pips', {
527
+ 'dot.pip.name': args.record.pip.name,
528
+ 'dot.app.rollback.count': args.rollback.length,
529
+ });
530
+ const disposeFailures = await this.#runDisposeForRecords(reverseRecords(args.rollback), phaseLogger);
531
+ this.#state = 'failed';
532
+ this.#manifest = this.#buildManifest();
533
+ throw new DotLifecycleError({
534
+ code: args.code,
535
+ phase: 'boot',
536
+ pip: args.record.pip.name,
537
+ message: args.message,
538
+ cause: args.cause,
539
+ failures: disposeFailures.length > 0 ? disposeFailures : undefined,
540
+ });
541
+ };
444
542
  for (const pip of this.#ordered) {
445
543
  const record = this.#records.get(pip.name);
446
544
  const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
447
- if (!pip.boot) {
545
+ const started = performance.now();
546
+ // Resolve needs BEFORE invoking the hook — a pip with needs but no
547
+ // boot hook still fails fast when its wiring is unsatisfied.
548
+ let ctx;
549
+ try {
550
+ ctx = this.#buildHookCtx(record, 'boot', false);
551
+ }
552
+ catch (error) {
553
+ const message = error instanceof DotLifecycleError ? error.message : stringifyError(error);
554
+ return fail({
555
+ record,
556
+ code: DotLifecycleErrorCode.UnsatisfiedNeed,
557
+ message,
558
+ remediation: `Add a provider for the missing service with .use() before "${pip.name}", or rename an ` +
559
+ `existing provider's keys to match. If a later pip provides it, reorder — services flow ` +
560
+ `strictly forward. Mutual needs between two pips are a cycle: merge them or extract the ` +
561
+ `shared piece into a third pip both consume.`,
562
+ docsAnchor: 'unsatisfied-need',
563
+ durationMs: performance.now() - started,
564
+ rollback: bootedRecords,
565
+ });
566
+ }
567
+ if (!pip.hooks.boot) {
448
568
  record.booted = true;
449
569
  bootedRecords.push(record);
450
570
  pipLogger.debug('boot: skipped (no hook)');
451
571
  continue;
452
572
  }
453
573
  record.hooks.add('boot');
454
- const ctx = {
455
- pipName: pip.name,
456
- appName: this.#appName,
457
- services: this.#serviceMap,
458
- config: this.#config,
459
- };
460
- const started = performance.now();
461
574
  this.#emitHook('boot', pip.name, record.order, 'starting');
462
575
  let result;
463
576
  try {
464
- const maybeResult = await withPipHookSpan({
577
+ result = await withPipHookSpan({
465
578
  appName: this.#appName,
466
579
  pipName: pip.name,
467
580
  pipVersion: pip.version,
468
581
  hook: 'boot',
469
582
  order: record.order,
470
583
  logger: pipLogger,
471
- }, () => pip.boot(ctx));
472
- result = maybeResult ?? {};
584
+ },
585
+ // Erasure boundary: hooks are stored as `(ctx: never) => ...`;
586
+ // the kernel is the one caller allowed to cross it.
587
+ () => pip.hooks.boot(ctx));
473
588
  }
474
589
  catch (error) {
475
- const durationMs = performance.now() - started;
476
- const issue = makeIssue({
590
+ return fail({
591
+ record,
477
592
  code: DotLifecycleErrorCode.BootFailed,
478
- pip: pip.name,
479
593
  message: `boot hook threw for pip "${pip.name}": ${stringifyError(error)}`,
480
594
  remediation: `Fix the error in the boot() hook of "${pip.name}". If boot opens partial resources before throwing, clean them up locally — DOT only disposes pips whose boot completed.`,
481
595
  docsAnchor: 'boot-failed',
482
- });
483
- record.issues.push(issue);
484
- record.lifecycleDiagnostics.push({
485
- pip: pip.name,
486
- hook: 'boot',
487
- state: 'failed',
488
- order: record.order,
489
- durationMs,
490
- issues: [issue],
491
- });
492
- this.#emitHook('boot', pip.name, record.order, 'failed', { durationMs, error });
493
- pipLogger.error('boot: FAILED — rolling back already-booted pips', {
494
- 'dot.app.rollback.count': bootedRecords.length,
495
- });
496
- // Dispose already-booted pips in reverse-topological order.
497
- const disposeFailures = await this.#runDisposeForRecords(reverseRecords(bootedRecords), phaseLogger);
498
- this.#state = 'failed';
499
- this.#manifest = this.#buildManifest();
500
- throw new DotLifecycleError({
501
- code: DotLifecycleErrorCode.BootFailed,
502
- phase: 'boot',
503
- pip: pip.name,
504
- message: issue.message,
596
+ durationMs: performance.now() - started,
505
597
  cause: error,
506
- failures: disposeFailures.length > 0 ? disposeFailures : undefined,
598
+ rollback: bootedRecords,
507
599
  });
508
600
  }
509
- const publishedServices = result.services ?? {};
601
+ const publishedServices = result ?? {};
510
602
  record.publishedServices = publishedServices;
511
- for (const [name, value] of Object.entries(publishedServices)) {
512
- this.#serviceMap.set(name, value);
513
- }
514
- Object.assign(this.#aggregatedServices, publishedServices);
515
603
  record.booted = true;
604
+ for (const [localKey, value] of Object.entries(publishedServices)) {
605
+ const wireKey = pip.renames[localKey] ?? localKey;
606
+ if (this.#serviceMap.has(wireKey)) {
607
+ const owner = this.#providerByWireKey.get(wireKey) ?? 'unknown';
608
+ // The current pip HAS booted — include it in the rollback.
609
+ return fail({
610
+ record,
611
+ code: DotLifecycleErrorCode.ServiceCollision,
612
+ message: `Pip "${pip.name}" publishes service "${wireKey}" which pip "${owner}" already provides.`,
613
+ remediation: `Mount one of the two with rename(pip, { '${wireKey}': '<newKey>' }) to keep both instances, or remove the duplicate provider.`,
614
+ docsAnchor: 'service-collision',
615
+ durationMs: performance.now() - started,
616
+ rollback: [...bootedRecords, record],
617
+ });
618
+ }
619
+ this.#serviceMap.set(wireKey, value);
620
+ this.#providerByWireKey.set(wireKey, pip.name);
621
+ this.#aggregatedServices[wireKey] = value;
622
+ record.provides.add(wireKey);
623
+ }
516
624
  bootedRecords.push(record);
517
625
  const durationMs = performance.now() - started;
518
626
  record.lifecycleDiagnostics.push({
@@ -562,16 +670,12 @@ export class DotAppImpl {
562
670
  for (const pip of this.#ordered) {
563
671
  const record = this.#records.get(pip.name);
564
672
  const pipLogger = phaseLogger.withAttribute('dot.pip.name', pip.name);
565
- if (!pip.start) {
673
+ if (!pip.hooks.start) {
566
674
  pipLogger.debug('start: skipped (no hook)');
567
675
  continue;
568
676
  }
569
677
  record.hooks.add('start');
570
- const ctx = {
571
- pipName: pip.name,
572
- appName: this.#appName,
573
- services: this.#aggregatedServices,
574
- };
678
+ const ctx = this.#buildHookCtx(record, 'start', true);
575
679
  const startedAt = performance.now();
576
680
  this.#emitHook('start', pip.name, record.order, 'starting');
577
681
  try {
@@ -582,7 +686,7 @@ export class DotAppImpl {
582
686
  hook: 'start',
583
687
  order: record.order,
584
688
  logger: pipLogger,
585
- }, () => pip.start(ctx));
689
+ }, () => pip.hooks.start(ctx));
586
690
  }
587
691
  catch (error) {
588
692
  const durationMs = performance.now() - startedAt;
@@ -690,15 +794,11 @@ export class DotAppImpl {
690
794
  async #runStopForRecords(records, phaseLogger) {
691
795
  const failures = [];
692
796
  for (const record of records) {
693
- if (!record.pip.stop)
797
+ if (!record.pip.hooks.stop)
694
798
  continue;
695
799
  const pipLogger = phaseLogger.withAttribute('dot.pip.name', record.pip.name);
696
800
  record.hooks.add('stop');
697
- const ctx = {
698
- pipName: record.pip.name,
699
- appName: this.#appName,
700
- services: this.#aggregatedServices,
701
- };
801
+ const ctx = this.#buildHookCtx(record, 'stop', true);
702
802
  const startedAt = performance.now();
703
803
  this.#emitHook('stop', record.pip.name, record.order, 'starting');
704
804
  try {
@@ -709,7 +809,7 @@ export class DotAppImpl {
709
809
  hook: 'stop',
710
810
  order: record.order,
711
811
  logger: pipLogger,
712
- }, () => record.pip.stop(ctx));
812
+ }, () => record.pip.hooks.stop(ctx));
713
813
  record.started = false;
714
814
  const durationMs = performance.now() - startedAt;
715
815
  record.lifecycleDiagnostics.push({
@@ -828,68 +928,97 @@ export class DotAppImpl {
828
928
  async #runDisposeForRecords(records, phaseLogger) {
829
929
  const failures = [];
830
930
  for (const record of records) {
831
- if (!record.pip.dispose) {
931
+ const lazyPublishes = Object.entries(record.publishedServices).filter((entry) => isLazy(entry[1]));
932
+ const hasHook = record.pip.hooks.dispose !== undefined;
933
+ if (!hasHook && lazyPublishes.length === 0) {
832
934
  record.booted = false;
833
935
  continue;
834
936
  }
835
937
  const pipLogger = phaseLogger.withAttribute('dot.pip.name', record.pip.name);
836
- record.hooks.add('dispose');
837
- const ctx = {
838
- pipName: record.pip.name,
839
- appName: this.#appName,
840
- services: this.#aggregatedServices,
841
- };
842
- const startedAt = performance.now();
843
- this.#emitHook('dispose', record.pip.name, record.order, 'starting');
844
- try {
845
- await withPipHookSpan({
846
- appName: this.#appName,
847
- pipName: record.pip.name,
848
- pipVersion: record.pip.version,
849
- hook: 'dispose',
850
- order: record.order,
851
- logger: pipLogger,
852
- }, () => record.pip.dispose(ctx));
853
- record.booted = false;
854
- const durationMs = performance.now() - startedAt;
855
- record.lifecycleDiagnostics.push({
856
- pip: record.pip.name,
857
- hook: 'dispose',
858
- state: 'disposed',
859
- order: record.order,
860
- durationMs,
861
- issues: [],
862
- });
863
- this.#emitHook('dispose', record.pip.name, record.order, 'completed', { durationMs });
864
- pipLogger.debug('dispose: done', { 'dot.pip.duration.ms': durationMs });
938
+ if (hasHook) {
939
+ await this.#runDisposeHook(record, pipLogger, failures);
865
940
  }
866
- catch (error) {
867
- const durationMs = performance.now() - startedAt;
868
- const issue = makeIssue({
869
- code: DotLifecycleErrorCode.DisposeFailed,
870
- pip: record.pip.name,
871
- message: `dispose hook threw for pip "${record.pip.name}": ${stringifyError(error)}`,
872
- remediation: `Fix the error in the dispose() hook of "${record.pip.name}". Dispose continues through individual failures and reports an aggregate error.`,
873
- docsAnchor: 'dispose-failed',
874
- });
875
- record.issues.push(issue);
876
- record.lifecycleDiagnostics.push({
877
- pip: record.pip.name,
878
- hook: 'dispose',
879
- state: 'failed',
880
- order: record.order,
881
- durationMs,
882
- issues: [issue],
883
- });
884
- this.#emitHook('dispose', record.pip.name, record.order, 'failed', { durationMs, error });
885
- failures.push({ pip: record.pip.name, phase: 'dispose', error });
886
- pipLogger.error('dispose: failed (continuing)', {
887
- 'dot.pip.error.message': stringifyError(error),
888
- });
941
+ // Auto-dispose lazy service handles AFTER the pip's own dispose hook
942
+ // (the hook may still use them). Never-initialized handles no-op.
943
+ for (const [serviceKey, handle] of lazyPublishes) {
944
+ try {
945
+ await handle.dispose();
946
+ }
947
+ catch (error) {
948
+ const issue = makeIssue({
949
+ code: DotLifecycleErrorCode.DisposeFailed,
950
+ pip: record.pip.name,
951
+ message: `lazy service "${serviceKey}" cleanup threw for pip "${record.pip.name}": ${stringifyError(error)}`,
952
+ remediation: `Fix the error in the dispose callback passed to lazy(...) for service "${serviceKey}". Dispose continues through individual failures and reports an aggregate error.`,
953
+ docsAnchor: 'dispose-failed',
954
+ });
955
+ record.issues.push(issue);
956
+ failures.push({ pip: record.pip.name, phase: 'dispose', error });
957
+ pipLogger.error('dispose: lazy service cleanup failed (continuing)', {
958
+ 'dot.pip.service.key': serviceKey,
959
+ 'dot.pip.error.message': stringifyError(error),
960
+ });
961
+ }
962
+ }
963
+ if (!hasHook) {
964
+ record.booted = false;
889
965
  }
890
966
  }
891
967
  return failures;
892
968
  }
969
+ /** Run a single pip's dispose hook with spans/events/diagnostics. */
970
+ async #runDisposeHook(record, pipLogger, failures) {
971
+ record.hooks.add('dispose');
972
+ const ctx = this.#buildHookCtx(record, 'dispose', true);
973
+ const startedAt = performance.now();
974
+ this.#emitHook('dispose', record.pip.name, record.order, 'starting');
975
+ try {
976
+ await withPipHookSpan({
977
+ appName: this.#appName,
978
+ pipName: record.pip.name,
979
+ pipVersion: record.pip.version,
980
+ hook: 'dispose',
981
+ order: record.order,
982
+ logger: pipLogger,
983
+ }, () => record.pip.hooks.dispose(ctx));
984
+ record.booted = false;
985
+ const durationMs = performance.now() - startedAt;
986
+ record.lifecycleDiagnostics.push({
987
+ pip: record.pip.name,
988
+ hook: 'dispose',
989
+ state: 'disposed',
990
+ order: record.order,
991
+ durationMs,
992
+ issues: [],
993
+ });
994
+ this.#emitHook('dispose', record.pip.name, record.order, 'completed', { durationMs });
995
+ pipLogger.debug('dispose: done', { 'dot.pip.duration.ms': durationMs });
996
+ }
997
+ catch (error) {
998
+ const durationMs = performance.now() - startedAt;
999
+ const issue = makeIssue({
1000
+ code: DotLifecycleErrorCode.DisposeFailed,
1001
+ pip: record.pip.name,
1002
+ message: `dispose hook threw for pip "${record.pip.name}": ${stringifyError(error)}`,
1003
+ remediation: `Fix the error in the dispose() hook of "${record.pip.name}". Dispose continues through individual failures and reports an aggregate error.`,
1004
+ docsAnchor: 'dispose-failed',
1005
+ });
1006
+ record.issues.push(issue);
1007
+ record.lifecycleDiagnostics.push({
1008
+ pip: record.pip.name,
1009
+ hook: 'dispose',
1010
+ state: 'failed',
1011
+ order: record.order,
1012
+ durationMs,
1013
+ issues: [issue],
1014
+ });
1015
+ this.#emitHook('dispose', record.pip.name, record.order, 'failed', { durationMs, error });
1016
+ failures.push({ pip: record.pip.name, phase: 'dispose', error });
1017
+ pipLogger.error('dispose: failed (continuing)', {
1018
+ 'dot.pip.error.message': stringifyError(error),
1019
+ });
1020
+ }
1021
+ }
893
1022
  #reuseError(phase) {
894
1023
  const code = this.#state === 'disposed' ? DotLifecycleErrorCode.ReuseAfterDispose : DotLifecycleErrorCode.ReuseAfterFailure;
895
1024
  const reason = this.#state === 'disposed' ? 'disposed' : 'failed';
@@ -904,13 +1033,17 @@ export class DotAppImpl {
904
1033
  const routes = [];
905
1034
  const services = [];
906
1035
  const lifecycle = [];
907
- const dependencies = buildDependencyEdges(this.#ordered).map(e => ({ ...e }));
1036
+ // Edges are observed at boot time: consumer pip → the pip whose
1037
+ // published wire key satisfied its need. Before boot, the per-pip
1038
+ // `dependencies` array (wire-key strings) is the declarative view.
1039
+ const dependencies = this.#wiringEdges.map(e => ({ ...e }));
908
1040
  for (const pip of this.#ordered) {
909
1041
  const record = this.#records.get(pip.name);
1042
+ const needWireKeys = Object.entries(pip.needs).map(([alias, witness]) => wireKeyOf(witness, alias));
910
1043
  pips.push({
911
1044
  name: pip.name,
912
1045
  version: pip.version,
913
- dependencies: pip.dependencies ?? [],
1046
+ dependencies: needWireKeys,
914
1047
  provides: [...record.provides],
915
1048
  });
916
1049
  routes.push(...record.routes);
@@ -919,33 +1052,6 @@ export class DotAppImpl {
919
1052
  pip: pip.name,
920
1053
  hooks: [...record.hooks],
921
1054
  });
922
- // Append extra dependency edges declared during configure.
923
- for (const dep of record.extraDependencies) {
924
- dependencies.push({ from: pip.name, to: dep.to, kind: dep.kind });
925
- }
926
- // Optional manifest contribution from the pip itself.
927
- const contribution = typeof pip.manifest === 'function'
928
- ? pip.manifest({
929
- pipName: pip.name,
930
- services: this.#aggregatedServices,
931
- })
932
- : pip.manifest;
933
- if (contribution) {
934
- for (const svc of contribution.services ?? []) {
935
- services.push({ name: svc.name, pip: pip.name, kind: svc.kind });
936
- }
937
- for (const route of contribution.routes ?? []) {
938
- routes.push({ ...route, pip: pip.name });
939
- }
940
- for (const cap of contribution.provides ?? []) {
941
- if (!pips.at(-1).provides.includes(cap)) {
942
- pips.at(-1).provides.push(cap);
943
- }
944
- }
945
- for (const dep of contribution.dependencies ?? []) {
946
- dependencies.push({ from: pip.name, to: dep.to, kind: dep.kind ?? 'uses' });
947
- }
948
- }
949
1055
  }
950
1056
  return {
951
1057
  app: {