apcore-js 0.21.1 → 0.23.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.
Files changed (139) hide show
  1. package/README.md +37 -3
  2. package/dist/acl-handlers.d.ts.map +1 -1
  3. package/dist/acl-handlers.js +5 -0
  4. package/dist/acl-handlers.js.map +1 -1
  5. package/dist/async-task.d.ts +49 -14
  6. package/dist/async-task.d.ts.map +1 -1
  7. package/dist/async-task.js +134 -39
  8. package/dist/async-task.js.map +1 -1
  9. package/dist/bindings.d.ts +13 -1
  10. package/dist/bindings.d.ts.map +1 -1
  11. package/dist/bindings.js +21 -5
  12. package/dist/bindings.js.map +1 -1
  13. package/dist/browser/index.d.ts +2 -2
  14. package/dist/browser/index.d.ts.map +1 -1
  15. package/dist/browser/index.js +2 -2
  16. package/dist/browser/index.js.map +1 -1
  17. package/dist/builtin-steps.d.ts +14 -12
  18. package/dist/builtin-steps.d.ts.map +1 -1
  19. package/dist/builtin-steps.js +99 -30
  20. package/dist/builtin-steps.js.map +1 -1
  21. package/dist/cancel.d.ts +23 -2
  22. package/dist/cancel.d.ts.map +1 -1
  23. package/dist/cancel.js +31 -6
  24. package/dist/cancel.js.map +1 -1
  25. package/dist/client.d.ts +3 -0
  26. package/dist/client.d.ts.map +1 -1
  27. package/dist/client.js +7 -3
  28. package/dist/client.js.map +1 -1
  29. package/dist/config.d.ts +31 -1
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +108 -14
  32. package/dist/config.js.map +1 -1
  33. package/dist/context.d.ts +36 -3
  34. package/dist/context.d.ts.map +1 -1
  35. package/dist/context.js +111 -20
  36. package/dist/context.js.map +1 -1
  37. package/dist/errors.d.ts +74 -2
  38. package/dist/errors.d.ts.map +1 -1
  39. package/dist/errors.js +158 -11
  40. package/dist/errors.js.map +1 -1
  41. package/dist/events/emitter.d.ts +54 -16
  42. package/dist/events/emitter.d.ts.map +1 -1
  43. package/dist/events/emitter.js +162 -73
  44. package/dist/events/emitter.js.map +1 -1
  45. package/dist/events/index.d.ts +2 -1
  46. package/dist/events/index.d.ts.map +1 -1
  47. package/dist/events/index.js +1 -1
  48. package/dist/events/index.js.map +1 -1
  49. package/dist/events/retry.d.ts +35 -0
  50. package/dist/events/retry.d.ts.map +1 -0
  51. package/dist/events/retry.js +52 -0
  52. package/dist/events/retry.js.map +1 -0
  53. package/dist/events/subscribers.d.ts +54 -9
  54. package/dist/events/subscribers.d.ts.map +1 -1
  55. package/dist/events/subscribers.js +109 -67
  56. package/dist/events/subscribers.js.map +1 -1
  57. package/dist/executor.d.ts +4 -1
  58. package/dist/executor.d.ts.map +1 -1
  59. package/dist/executor.js +157 -21
  60. package/dist/executor.js.map +1 -1
  61. package/dist/generated/version.d.ts +1 -1
  62. package/dist/generated/version.js +1 -1
  63. package/dist/index.d.ts +8 -6
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +6 -4
  66. package/dist/index.js.map +1 -1
  67. package/dist/middleware/circuit-breaker.d.ts +10 -4
  68. package/dist/middleware/circuit-breaker.d.ts.map +1 -1
  69. package/dist/middleware/circuit-breaker.js +49 -24
  70. package/dist/middleware/circuit-breaker.js.map +1 -1
  71. package/dist/middleware/index.d.ts +2 -2
  72. package/dist/middleware/index.d.ts.map +1 -1
  73. package/dist/middleware/index.js +2 -2
  74. package/dist/middleware/index.js.map +1 -1
  75. package/dist/middleware/manager.d.ts +6 -2
  76. package/dist/middleware/manager.d.ts.map +1 -1
  77. package/dist/middleware/manager.js +36 -1
  78. package/dist/middleware/manager.js.map +1 -1
  79. package/dist/middleware/platform-notify.d.ts +2 -3
  80. package/dist/middleware/platform-notify.d.ts.map +1 -1
  81. package/dist/middleware/platform-notify.js +5 -6
  82. package/dist/middleware/platform-notify.js.map +1 -1
  83. package/dist/middleware/retry.d.ts +33 -15
  84. package/dist/middleware/retry.d.ts.map +1 -1
  85. package/dist/middleware/retry.js +48 -23
  86. package/dist/middleware/retry.js.map +1 -1
  87. package/dist/observability/context-logger.d.ts +15 -11
  88. package/dist/observability/context-logger.d.ts.map +1 -1
  89. package/dist/observability/context-logger.js +40 -14
  90. package/dist/observability/context-logger.js.map +1 -1
  91. package/dist/pipeline.d.ts +1 -1
  92. package/dist/pipeline.js +11 -11
  93. package/dist/pipeline.js.map +1 -1
  94. package/dist/registry/multi-class.js +6 -17
  95. package/dist/registry/multi-class.js.map +1 -1
  96. package/dist/registry/registry.d.ts +46 -2
  97. package/dist/registry/registry.d.ts.map +1 -1
  98. package/dist/registry/registry.js +276 -36
  99. package/dist/registry/registry.js.map +1 -1
  100. package/dist/registry/version.d.ts.map +1 -1
  101. package/dist/registry/version.js +9 -2
  102. package/dist/registry/version.js.map +1 -1
  103. package/dist/schema/ref-resolver.d.ts.map +1 -1
  104. package/dist/schema/ref-resolver.js +2 -2
  105. package/dist/schema/ref-resolver.js.map +1 -1
  106. package/dist/schema/strict.d.ts.map +1 -1
  107. package/dist/schema/strict.js +4 -1
  108. package/dist/schema/strict.js.map +1 -1
  109. package/dist/schema/types.d.ts.map +1 -1
  110. package/dist/schema/types.js +1 -1
  111. package/dist/schema/types.js.map +1 -1
  112. package/dist/schema/validator.d.ts.map +1 -1
  113. package/dist/schema/validator.js +11 -20
  114. package/dist/schema/validator.js.map +1 -1
  115. package/dist/streaming.d.ts +21 -0
  116. package/dist/streaming.d.ts.map +1 -0
  117. package/dist/streaming.js +32 -0
  118. package/dist/streaming.js.map +1 -0
  119. package/dist/sys-modules/control.d.ts +7 -0
  120. package/dist/sys-modules/control.d.ts.map +1 -1
  121. package/dist/sys-modules/control.js +32 -7
  122. package/dist/sys-modules/control.js.map +1 -1
  123. package/dist/sys-modules/manifest.js +7 -0
  124. package/dist/sys-modules/manifest.js.map +1 -1
  125. package/dist/sys-modules/registration.d.ts.map +1 -1
  126. package/dist/sys-modules/registration.js +18 -8
  127. package/dist/sys-modules/registration.js.map +1 -1
  128. package/dist/trace-context.d.ts.map +1 -1
  129. package/dist/trace-context.js +14 -3
  130. package/dist/trace-context.js.map +1 -1
  131. package/package.json +8 -4
  132. package/dist/registry/index.d.ts +0 -15
  133. package/dist/registry/index.d.ts.map +0 -1
  134. package/dist/registry/index.js +0 -11
  135. package/dist/registry/index.js.map +0 -1
  136. package/dist/schema/index.d.ts +0 -11
  137. package/dist/schema/index.d.ts.map +0 -1
  138. package/dist/schema/index.js +0 -9
  139. package/dist/schema/index.js.map +0 -1
@@ -2,7 +2,8 @@
2
2
  * Central module registry for discovering, registering, and querying modules.
3
3
  */
4
4
  import { getDefault } from '../config-defaults.js';
5
- import { InvalidInputError, ModuleNotFoundError } from '../errors.js';
5
+ import { DuplicateModuleIdError, ErrorCodes, InvalidInputError, ModuleNotFoundError, StreamingInterfaceError, } from '../errors.js';
6
+ import { isStreamingModule } from '../streaming.js';
6
7
  import { detectIdConflicts } from './conflicts.js';
7
8
  import { resolveDependencies } from './dependencies.js';
8
9
  import { resolveEntryPoint } from './entry-point.js';
@@ -137,22 +138,22 @@ export function isEphemeralModuleId(moduleId) {
137
138
  function validateModuleId(moduleId, allowReserved) {
138
139
  // 1. empty check (message byte-aligned with apcore-python and apcore-rust)
139
140
  if (!moduleId || typeof moduleId !== 'string') {
140
- throw new InvalidInputError('module_id must be a non-empty string');
141
+ throw new InvalidInputError('module_id must be a non-empty string', undefined, ErrorCodes.INVALID_MODULE_ID);
141
142
  }
142
143
  // 2. EBNF pattern check (message byte-aligned with apcore-python and apcore-rust:
143
144
  // single quotes around the offending ID; bare regex source without /…/ delimiters)
144
145
  if (!MODULE_ID_PATTERN.test(moduleId)) {
145
- throw new InvalidInputError(`Invalid module ID: '${moduleId}'. Must match pattern: ${MODULE_ID_PATTERN.source} (lowercase, digits, underscores, dots only; no hyphens)`);
146
+ throw new InvalidInputError(`Invalid module ID: '${moduleId}'. Must match pattern: ${MODULE_ID_PATTERN.source} (lowercase, digits, underscores, dots only; no hyphens)`, undefined, ErrorCodes.INVALID_MODULE_ID);
146
147
  }
147
148
  // 3. length check
148
149
  if (moduleId.length > MAX_MODULE_ID_LENGTH) {
149
- throw new InvalidInputError(`Module ID exceeds maximum length of ${MAX_MODULE_ID_LENGTH}: ${moduleId.length}`);
150
+ throw new InvalidInputError(`Module ID exceeds maximum length of ${MAX_MODULE_ID_LENGTH}: ${moduleId.length}`, undefined, ErrorCodes.INVALID_MODULE_ID);
150
151
  }
151
152
  // 4. reserved word first-segment check (skipped for registerInternal)
152
153
  if (!allowReserved) {
153
154
  const firstSegment = moduleId.split('.')[0];
154
155
  if (RESERVED_WORDS.has(firstSegment)) {
155
- throw new InvalidInputError(`Module ID contains reserved word: '${firstSegment}'`);
156
+ throw new InvalidInputError(`Module ID contains reserved word: '${firstSegment}'`, undefined, ErrorCodes.INVALID_MODULE_ID);
156
157
  }
157
158
  }
158
159
  }
@@ -178,6 +179,13 @@ export class Registry {
178
179
  _refCounts = new Map();
179
180
  _draining = new Set();
180
181
  _drainResolvers = new Map();
182
+ /**
183
+ * In-flight async registrations: modules whose async onLoad has not yet
184
+ * resolved. Keys in this map are NOT visible via get()/has() until the
185
+ * promise resolves and the module is committed to _modules.
186
+ * This enforces deferred-publish for async onLoad (issue #65).
187
+ */
188
+ _inFlight = new Map();
181
189
  /**
182
190
  * Optional EventEmitter for the ephemeral.* registry-side audit emits
183
191
  * (RFC `apcore/docs/spec/rfc-ephemeral-modules.md` "Audit-event single-emit
@@ -349,7 +357,7 @@ export class Registry {
349
357
  `'ephemeral.*' namespace: ${JSON.stringify(ids)}. The ephemeral.* ` +
350
358
  `namespace is reserved for programmatically-registered modules and ` +
351
359
  `may only be used via Registry.register(). Rename the offending ` +
352
- `directory or extension namespace.`);
360
+ `directory or extension namespace.`, undefined, ErrorCodes.INVALID_MODULE_ID);
353
361
  }
354
362
  async _applyIdMapOverrides(discovered) {
355
363
  if (Object.keys(this._idMap).length === 0)
@@ -496,31 +504,45 @@ export class Registry {
496
504
  ]);
497
505
  return resolveDependencies(modulesWithDeps, knownIds, moduleVersions);
498
506
  }
499
- _registerInOrder(loadOrder, validModules, rawMetadata) {
507
+ async _registerInOrder(loadOrder, validModules, rawMetadata) {
500
508
  // Stage 8 (D-32). Conflict detection / ID validation already happened in
501
509
  // `_filterIdConflicts` (stage 7), so this loop is purely a register pass.
510
+ //
511
+ // Deferred-publish contract (issue #65, audit D11-001 / D10-005): a module
512
+ // is NEVER published to `_modules`/`_moduleMeta`/`_lowercaseMap` until its
513
+ // `onLoad` (sync or async) has succeeded. This mirrors the public
514
+ // `_registerWithOnLoad` path: the caller observes either a fully-published
515
+ // module OR an `apcore.registry.module_load_failed` event — never a module
516
+ // that is visible mid-load. On `onLoad` failure we emit the event, skip
517
+ // publishing, and move to the next module (skip-on-failure, never abort).
502
518
  let count = 0;
503
519
  for (const modId of loadOrder) {
504
520
  const mod = validModules.get(modId);
505
521
  if (mod === undefined)
506
522
  continue;
507
523
  const modObj = mod;
508
- const mergedMeta = mergeModuleMetadata(modObj, rawMetadata.get(modId) ?? {});
509
- this._modules.set(modId, mod);
510
- this._moduleMeta.set(modId, mergedMeta);
511
- this._lowercaseMap.set(modId.toLowerCase(), modId);
524
+ // Run onLoad BEFORE publishing. Handle both sync and async onLoad: call
525
+ // it, and if it returns a Promise, await it.
512
526
  if (typeof modObj['onLoad'] === 'function') {
513
527
  try {
514
- modObj['onLoad']();
528
+ const onLoadResult = modObj['onLoad']();
529
+ if (onLoadResult instanceof Promise) {
530
+ await onLoadResult;
531
+ }
515
532
  }
516
533
  catch (e) {
534
+ // Sync throw or async rejection: emit module_load_failed, do NOT
535
+ // publish, warn, and skip to the next module.
536
+ this._emitModuleLoadFailed(modId, e);
517
537
  console.warn(`[apcore:registry] onLoad failed for ${modId}, skipping:`, e);
518
- this._modules.delete(modId);
519
- this._moduleMeta.delete(modId);
520
- this._lowercaseMap.delete(modId.toLowerCase());
521
538
  continue;
522
539
  }
523
540
  }
541
+ // Success (including empty / no-onLoad modules): publish all state.
542
+ const mergedMeta = mergeModuleMetadata(modObj, rawMetadata.get(modId) ?? {});
543
+ this._modules.set(modId, mod);
544
+ this._moduleMeta.set(modId, mergedMeta);
545
+ this._lowercaseMap.set(modId.toLowerCase(), modId);
524
546
  this._triggerEvent(REGISTRY_EVENTS.REGISTER, modId, mod);
525
547
  count++;
526
548
  }
@@ -539,8 +561,18 @@ export class Registry {
539
561
  * coexistence is not yet implemented in this SDK — when supplied, both
540
562
  * fields are merged into the module's metadata so callers can read them
541
563
  * back via `getDefinition()` and `list({tags})`. See PROTOCOL_SPEC §5.4.
564
+ *
565
+ * Returns a Promise that resolves once `onLoad` (if any) completes and the
566
+ * module is fully visible. For modules without `onLoad` or with a sync
567
+ * `onLoad`, the module is visible immediately (before the promise resolves)
568
+ * so existing sync callers that do not `await` continue to work.
569
+ *
570
+ * All sync validation errors (invalid ID, duplicate, streaming mismatch,
571
+ * sync onLoad failure) are thrown synchronously for backward compatibility
572
+ * with callers that use `expect(() => register(...)).toThrow(...)` patterns.
542
573
  */
543
574
  register(moduleId, module, version, metadata, options) {
575
+ // 1. ID validation (always sync — throws synchronously for backward compat)
544
576
  validateModuleId(moduleId, false);
545
577
  // ephemeral.* registrations only land via this programmatic path —
546
578
  // the filesystem discoverer rejects matching IDs upstream (see
@@ -551,54 +583,201 @@ export class Registry {
551
583
  if (ephemeral) {
552
584
  this._warnIfMissingApproval(moduleId, module);
553
585
  }
586
+ // 2. Duplicate detection (sync — preserves backward compat with `.toThrow()` tests)
587
+ const conflict = detectIdConflicts(moduleId, new Set([...this._modules.keys(), ...this._inFlight.keys()]), RESERVED_WORDS, this._lowercaseMap);
588
+ if (conflict !== null) {
589
+ if (conflict.severity === 'error') {
590
+ if (conflict.type === 'duplicate_id') {
591
+ throw new DuplicateModuleIdError(moduleId);
592
+ }
593
+ throw new InvalidInputError(conflict.message);
594
+ }
595
+ console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
596
+ }
597
+ // 3. Streaming annotation validation (sync — preserves .toThrow() compat)
598
+ const modForStreaming = module;
599
+ const annForStreaming = modForStreaming['annotations'];
600
+ if (annForStreaming != null && typeof annForStreaming === 'object') {
601
+ const streamingFlag = annForStreaming['streaming'];
602
+ if (streamingFlag === true) {
603
+ const hasStreamMethod = typeof modForStreaming['stream'] === 'function';
604
+ const hasMarker = modForStreaming[Symbol.for('apcore.streaming')] === true;
605
+ if (!isStreamingModule(module)) {
606
+ throw new StreamingInterfaceError(moduleId, true, hasStreamMethod, hasMarker);
607
+ }
608
+ }
609
+ }
610
+ // 4. Custom validator (may be async)
554
611
  if (this._customValidator !== null) {
555
612
  const result = this._customValidator.validate(module);
556
613
  if (result instanceof Promise) {
557
- throw new InvalidInputError(`Custom validator for '${moduleId}' is async — use discover() which awaits the validator, or register after awaiting validation manually.`);
614
+ return result.then((errors) => {
615
+ if (errors.length > 0) {
616
+ throw new InvalidInputError(`Custom validator rejected module '${moduleId}': ${errors.join('; ')}`);
617
+ }
618
+ return this._registerWithOnLoad(moduleId, module, version, metadata, options, ephemeral);
619
+ });
558
620
  }
559
621
  if (result.length > 0) {
560
622
  throw new InvalidInputError(`Custom validator rejected module '${moduleId}': ${result.join('; ')}`);
561
623
  }
562
624
  }
625
+ return this._registerWithOnLoad(moduleId, module, version, metadata, options, ephemeral);
626
+ }
627
+ /**
628
+ * Core registration logic after all sync validation has passed.
629
+ * Handles both sync and async onLoad with deferred-publish for async.
630
+ *
631
+ * For sync or absent onLoad: module becomes visible immediately, returns
632
+ * a resolved Promise (backward-compatible sync usage continues to work).
633
+ * For async onLoad: module is tracked in _inFlight and hidden from get()/has()
634
+ * until onLoad resolves; then committed to _modules and made visible.
635
+ */
636
+ _registerWithOnLoad(moduleId, module, version, metadata, options, ephemeral) {
563
637
  const overrides = { ...(metadata ?? {}) };
564
638
  if (version !== undefined && version !== null) {
565
639
  overrides['version'] = version;
566
640
  }
567
- this._registerImpl(moduleId, module, overrides);
641
+ // Detect async onLoad
642
+ const modObj = module;
643
+ if (typeof modObj['onLoad'] === 'function') {
644
+ // Call onLoad and check if it returns a Promise.
645
+ // Sync onLoad must emit module_load_failed on throw (Issue #65 gap;
646
+ // mirror the async-Promise rejection branch below). The strong-guarantee
647
+ // invariant — caller observes either fully-published or
648
+ // module_load_failed — applies to every registration path including
649
+ // this entry point.
650
+ // A-D-013: reserve the in-flight slot BEFORE invoking onLoad, for both
651
+ // sync and async onLoad. The module MUST NOT be observable via get()/has()
652
+ // while onLoad runs (strong-guarantee invariant #65). Reserving uniformly
653
+ // — not only for the async branch — makes the in-flight gate effective
654
+ // for a sync onLoad that re-entrantly inspects the registry, matching
655
+ // apcore-python (_in_flight.add before on_load) and apcore-rust.
656
+ // A resolved placeholder Promise is used as the sync sentinel; the
657
+ // async branch overwrites it with the real loadPromise below.
658
+ this._inFlight.set(moduleId, Promise.resolve());
659
+ let onLoadResult;
660
+ try {
661
+ onLoadResult = modObj['onLoad']();
662
+ }
663
+ catch (e) {
664
+ // Surface sync onLoad failures as a rejected Promise so the
665
+ // strong-guarantee invariant — `register()` always returns a Promise
666
+ // — holds for sync and async onLoad alike. Without this, callers
667
+ // doing `await registry.register(...)` would see sync throws and the
668
+ // module_load_failed event would arrive before the await target.
669
+ this._inFlight.delete(moduleId);
670
+ this._emitModuleLoadFailed(moduleId, e);
671
+ return Promise.reject(e);
672
+ }
673
+ if (onLoadResult instanceof Promise) {
674
+ // Async onLoad: deferred-publish
675
+ // Register metadata and lowercase index (but NOT _modules) so conflict
676
+ // detection works for concurrent registrations of the same ID.
677
+ const modObjFull = module;
678
+ this._moduleMeta.set(moduleId, mergeModuleMetadata(modObjFull, overrides));
679
+ this._lowercaseMap.set(moduleId.toLowerCase(), moduleId);
680
+ const loadPromise = onLoadResult.then(() => {
681
+ // Commit module to visible state
682
+ this._modules.set(moduleId, module);
683
+ this._inFlight.delete(moduleId);
684
+ this._triggerEvent(REGISTRY_EVENTS.REGISTER, moduleId, module);
685
+ if (ephemeral) {
686
+ this._emitEphemeralAudit('apcore.registry.module_registered', moduleId, options?.context ?? null);
687
+ }
688
+ }, (err) => {
689
+ // onLoad failed: rollback all state
690
+ this._inFlight.delete(moduleId);
691
+ this._moduleMeta.delete(moduleId);
692
+ this._lowercaseMap.delete(moduleId.toLowerCase());
693
+ // Emit module_load_failed event (spec #65)
694
+ this._emitModuleLoadFailed(moduleId, err);
695
+ throw err;
696
+ });
697
+ // Guard against unhandled rejection if caller fire-and-forgets (no await)
698
+ loadPromise.catch(() => { });
699
+ this._inFlight.set(moduleId, loadPromise);
700
+ return loadPromise;
701
+ }
702
+ // Sync onLoad returned (possibly undefined) — fall through to normal
703
+ // registration. Release the in-flight reservation now that onLoad has
704
+ // succeeded; the module becomes visible on the publish below (A-D-013).
705
+ this._inFlight.delete(moduleId);
706
+ }
707
+ // Sync path: no onLoad, or sync onLoad already called above
708
+ this._modules.set(moduleId, module);
709
+ this._lowercaseMap.set(moduleId.toLowerCase(), moduleId);
710
+ const modObjFull = module;
711
+ this._moduleMeta.set(moduleId, mergeModuleMetadata(modObjFull, overrides));
712
+ this._triggerEvent(REGISTRY_EVENTS.REGISTER, moduleId, module);
568
713
  if (ephemeral) {
569
714
  this._emitEphemeralAudit('apcore.registry.module_registered', moduleId, options?.context ?? null);
570
715
  }
716
+ return Promise.resolve();
571
717
  }
572
- /** Inner registration — no validator, no ID validation. Used by discover() paths that run their own checks. */
718
+ /**
719
+ * Inner registration — no validator, no ID validation. Used by discover() paths that run
720
+ * their own checks. Validates streaming annotation, runs sync onLoad, commits to _modules.
721
+ */
573
722
  _registerImpl(moduleId, module, metadataOverrides = {}) {
574
723
  // Algorithm A03: detect ID conflicts (exact duplicate, reserved word, case collision)
575
- const conflict = detectIdConflicts(moduleId, new Set(this._modules.keys()), RESERVED_WORDS, this._lowercaseMap);
724
+ const conflict = detectIdConflicts(moduleId, new Set([...this._modules.keys(), ...this._inFlight.keys()]), RESERVED_WORDS, this._lowercaseMap);
576
725
  if (conflict !== null) {
577
726
  if (conflict.severity === 'error') {
578
727
  throw new InvalidInputError(conflict.message);
579
728
  }
580
- else {
581
- console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
729
+ console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
730
+ }
731
+ // Streaming annotation validation: if module declares streaming=true it must
732
+ // implement the StreamingModule interface (has stream() + STREAMING_MARKER).
733
+ const modForStreaming = module;
734
+ const annForStreaming = modForStreaming['annotations'];
735
+ if (annForStreaming != null && typeof annForStreaming === 'object') {
736
+ const streamingFlag = annForStreaming['streaming'];
737
+ if (streamingFlag === true) {
738
+ const hasStreamMethod = typeof modForStreaming['stream'] === 'function';
739
+ const hasMarker = modForStreaming[Symbol.for('apcore.streaming')] === true;
740
+ if (!isStreamingModule(module)) {
741
+ throw new StreamingInterfaceError(moduleId, true, hasStreamMethod, hasMarker);
742
+ }
582
743
  }
583
744
  }
584
- this._modules.set(moduleId, module);
585
- this._lowercaseMap.set(moduleId.toLowerCase(), moduleId);
586
- // Populate metadata from the module object, layering any explicit overrides
587
- // (e.g. the `version` / `metadata` args passed to `register()`) on top.
745
+ // A-D-REG-003 / spec REG-003: deferred-publish for the discover path.
746
+ // Reserve the slot in metadata + lowercase index (for conflict detection)
747
+ // but do NOT insert into `_modules` until onLoad has completed. The
748
+ // strong-guarantee invariant (#65) applies uniformly to every registration
749
+ // path including discover/hot-reload — observers must never see a module
750
+ // that subsequently fails its onLoad.
588
751
  const modObj = module;
589
752
  this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, metadataOverrides));
590
- // Call onLoad if available
753
+ this._lowercaseMap.set(moduleId.toLowerCase(), moduleId);
754
+ // Call onLoad if available (sync only in this discover() path)
591
755
  if (typeof modObj['onLoad'] === 'function') {
756
+ let onLoadResult;
592
757
  try {
593
- modObj['onLoad']();
758
+ onLoadResult = modObj['onLoad']();
594
759
  }
595
760
  catch (e) {
596
- this._modules.delete(moduleId);
761
+ // Rollback the reservation: nothing was published, so observers
762
+ // never saw the module.
597
763
  this._moduleMeta.delete(moduleId);
598
764
  this._lowercaseMap.delete(moduleId.toLowerCase());
765
+ this._emitModuleLoadFailed(moduleId, e);
599
766
  throw e;
600
767
  }
768
+ if (onLoadResult instanceof Promise) {
769
+ // Async onLoad in discover() path: warn and skip — deferred-publish
770
+ // with async onLoad is only available via the public register() API.
771
+ console.warn(`[apcore:registry] Module '${moduleId}' has async onLoad in discover() path ` +
772
+ `— async onLoad is not supported here; use Registry.register() instead. Module skipped.`);
773
+ this._moduleMeta.delete(moduleId);
774
+ this._lowercaseMap.delete(moduleId.toLowerCase());
775
+ onLoadResult.catch(() => { }); // prevent unhandled rejection
776
+ return;
777
+ }
601
778
  }
779
+ // onLoad succeeded — now commit the module to the visible map.
780
+ this._modules.set(moduleId, module);
602
781
  this._triggerEvent(REGISTRY_EVENTS.REGISTER, moduleId, module);
603
782
  }
604
783
  unregister(moduleId, options) {
@@ -641,9 +820,20 @@ export class Registry {
641
820
  if (moduleId === '') {
642
821
  throw new ModuleNotFoundError('');
643
822
  }
823
+ // In-flight modules are not yet visible — spec #65: module MUST NOT be
824
+ // observable via get() until onLoad completes. Cross-SDK canonical
825
+ // behaviour (Python/Rust, A-D-002) is to return null for an in-flight id,
826
+ // matching this SDK's own well-formed-unregistered → null contract and
827
+ // getDefinition()'s null return for the same case (asymmetry removed).
828
+ if (this._inFlight.has(moduleId)) {
829
+ return null;
830
+ }
644
831
  return this._modules.get(moduleId) ?? null;
645
832
  }
646
833
  has(moduleId) {
834
+ // In-flight modules are not visible (same invariant as get())
835
+ if (this._inFlight.has(moduleId))
836
+ return false;
647
837
  return this._modules.has(moduleId);
648
838
  }
649
839
  /**
@@ -656,11 +846,25 @@ export class Registry {
656
846
  * `includeHidden: true` to enumerate every registered module — useful
657
847
  * for introspection tools, debug consoles, and tests.
658
848
  */
849
+ /**
850
+ * Return sorted list of unique registered module IDs, optionally filtered.
851
+ *
852
+ * @param options.tags When supplied, only modules carrying *all* of the given tags are returned.
853
+ * @param options.prefix When supplied, only IDs starting with the prefix are returned.
854
+ * @param options.visibility Filter by module visibility. Supported: `['public', 'hidden']`.
855
+ * Defaults to `['public']`. Aligned with apcore D-24.
856
+ * @param options.includeHidden Deprecated. Use `visibility: ['public', 'hidden']` instead.
857
+ */
659
858
  list(options) {
660
859
  let ids = [...this._modules.keys()];
661
- if (options?.includeHidden !== true) {
662
- ids = ids.filter((id) => this._isDiscoverable(id));
663
- }
860
+ // D-24 alignment: visibility list takes precedence over legacy includeHidden bool.
861
+ const vis = options?.visibility ?? (options?.includeHidden === true ? ['public', 'hidden'] : ['public']);
862
+ const showPublic = vis.includes('public');
863
+ const showHidden = vis.includes('hidden');
864
+ ids = ids.filter((id) => {
865
+ const isDisc = this._isDiscoverable(id);
866
+ return (isDisc && showPublic) || (!isDisc && showHidden);
867
+ });
664
868
  if (options?.prefix != null) {
665
869
  ids = ids.filter((id) => id.startsWith(options.prefix));
666
870
  }
@@ -1033,11 +1237,13 @@ export class Registry {
1033
1237
  if (conflict.severity === 'error') {
1034
1238
  throw new InvalidInputError(conflict.message);
1035
1239
  }
1036
- else {
1037
- console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
1038
- }
1240
+ console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
1039
1241
  }
1040
- this._modules.set(moduleId, module);
1242
+ // A-D-REG-004 / spec REG-003: deferred-publish for registerInternal.
1243
+ // Reserve metadata + lowercase index but defer the visible _modules
1244
+ // insert until onLoad succeeds so the strong-guarantee invariant (#65)
1245
+ // holds uniformly across `register()`, `registerInternal`, and
1246
+ // discover/hot-reload paths.
1041
1247
  const modObj = module;
1042
1248
  this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, {}));
1043
1249
  // Mirror apcore-python register_internal and apcore-rust register_core:
@@ -1051,12 +1257,13 @@ export class Registry {
1051
1257
  modObj['onLoad']();
1052
1258
  }
1053
1259
  catch (e) {
1054
- this._modules.delete(moduleId);
1055
1260
  this._moduleMeta.delete(moduleId);
1056
1261
  this._lowercaseMap.delete(moduleId.toLowerCase());
1262
+ this._emitModuleLoadFailed(moduleId, e);
1057
1263
  throw e;
1058
1264
  }
1059
1265
  }
1266
+ this._modules.set(moduleId, module);
1060
1267
  this._triggerEvent(REGISTRY_EVENTS.REGISTER, moduleId, module);
1061
1268
  }
1062
1269
  /**
@@ -1272,6 +1479,39 @@ export class Registry {
1272
1479
  }
1273
1480
  return { caller_id: callerId, identity: snapshot, namespace_class: 'ephemeral' };
1274
1481
  }
1482
+ /**
1483
+ * Emit `apcore.registry.module_load_failed` when onLoad raises (spec #65).
1484
+ * Delivered synchronously if the emitter is wired; logged as warn otherwise.
1485
+ */
1486
+ _emitModuleLoadFailed(moduleId, err) {
1487
+ const error = err instanceof Error ? err : new Error(String(err));
1488
+ const payload = {
1489
+ module_id: moduleId,
1490
+ callback_name: 'module.onLoad',
1491
+ error_type: error.constructor?.name ?? 'Error',
1492
+ error_message: error.message,
1493
+ timestamp: new Date().toISOString(),
1494
+ };
1495
+ const emitter = this._eventEmitter;
1496
+ if (emitter === null) {
1497
+ console.warn(`[apcore:registry] module_load_failed module_id=${moduleId} ` +
1498
+ `error_type=${payload['error_type']} message=${error.message}`);
1499
+ return;
1500
+ }
1501
+ const event = {
1502
+ eventType: 'apcore.registry.module_load_failed',
1503
+ moduleId,
1504
+ timestamp: new Date().toISOString(),
1505
+ severity: 'error',
1506
+ data: payload,
1507
+ };
1508
+ try {
1509
+ emitter.emit(event);
1510
+ }
1511
+ catch (emitErr) {
1512
+ console.error('[apcore:registry] Failed to emit module_load_failed:', emitErr);
1513
+ }
1514
+ }
1275
1515
  /**
1276
1516
  * Emit the canonical ephemeral.* audit event to the wired EventEmitter
1277
1517
  * (or warn-log if none is wired). The bridge in