apcore-js 0.19.0 → 0.21.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 (193) hide show
  1. package/README.md +110 -9
  2. package/dist/acl.d.ts +18 -0
  3. package/dist/acl.d.ts.map +1 -1
  4. package/dist/acl.js +54 -17
  5. package/dist/acl.js.map +1 -1
  6. package/dist/async-task.d.ts +70 -16
  7. package/dist/async-task.d.ts.map +1 -1
  8. package/dist/async-task.js +212 -72
  9. package/dist/async-task.js.map +1 -1
  10. package/dist/builtin-steps.d.ts +16 -5
  11. package/dist/builtin-steps.d.ts.map +1 -1
  12. package/dist/builtin-steps.js +45 -28
  13. package/dist/builtin-steps.js.map +1 -1
  14. package/dist/config.d.ts +38 -0
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +163 -33
  17. package/dist/config.js.map +1 -1
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +12 -1
  20. package/dist/context.js.map +1 -1
  21. package/dist/errors.d.ts +32 -10
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +55 -16
  24. package/dist/errors.js.map +1 -1
  25. package/dist/events/circuit-breaker.d.ts +45 -0
  26. package/dist/events/circuit-breaker.d.ts.map +1 -0
  27. package/dist/events/circuit-breaker.js +115 -0
  28. package/dist/events/circuit-breaker.js.map +1 -0
  29. package/dist/events/emitter.d.ts +22 -1
  30. package/dist/events/emitter.d.ts.map +1 -1
  31. package/dist/events/emitter.js +66 -2
  32. package/dist/events/emitter.js.map +1 -1
  33. package/dist/events/index.d.ts +4 -2
  34. package/dist/events/index.d.ts.map +1 -1
  35. package/dist/events/index.js +3 -2
  36. package/dist/events/index.js.map +1 -1
  37. package/dist/events/subscribers.d.ts +33 -1
  38. package/dist/events/subscribers.d.ts.map +1 -1
  39. package/dist/events/subscribers.js +124 -1
  40. package/dist/events/subscribers.js.map +1 -1
  41. package/dist/executor.d.ts +10 -2
  42. package/dist/executor.d.ts.map +1 -1
  43. package/dist/executor.js +173 -52
  44. package/dist/executor.js.map +1 -1
  45. package/dist/generated/version.d.ts +1 -1
  46. package/dist/generated/version.js +1 -1
  47. package/dist/index.d.ts +35 -25
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +23 -17
  50. package/dist/index.js.map +1 -1
  51. package/dist/middleware/base.d.ts +25 -3
  52. package/dist/middleware/base.d.ts.map +1 -1
  53. package/dist/middleware/base.js +24 -0
  54. package/dist/middleware/base.js.map +1 -1
  55. package/dist/middleware/circuit-breaker.d.ts +54 -0
  56. package/dist/middleware/circuit-breaker.d.ts.map +1 -0
  57. package/dist/middleware/circuit-breaker.js +168 -0
  58. package/dist/middleware/circuit-breaker.js.map +1 -0
  59. package/dist/middleware/context-namespace.d.ts +30 -0
  60. package/dist/middleware/context-namespace.d.ts.map +1 -0
  61. package/dist/middleware/context-namespace.js +38 -0
  62. package/dist/middleware/context-namespace.js.map +1 -0
  63. package/dist/middleware/index.d.ts +7 -1
  64. package/dist/middleware/index.d.ts.map +1 -1
  65. package/dist/middleware/index.js +4 -1
  66. package/dist/middleware/index.js.map +1 -1
  67. package/dist/middleware/manager.d.ts +11 -4
  68. package/dist/middleware/manager.d.ts.map +1 -1
  69. package/dist/middleware/manager.js +25 -9
  70. package/dist/middleware/manager.js.map +1 -1
  71. package/dist/middleware/platform-notify.d.ts +8 -4
  72. package/dist/middleware/platform-notify.d.ts.map +1 -1
  73. package/dist/middleware/platform-notify.js +11 -7
  74. package/dist/middleware/platform-notify.js.map +1 -1
  75. package/dist/middleware/tracing.d.ts +50 -0
  76. package/dist/middleware/tracing.d.ts.map +1 -0
  77. package/dist/middleware/tracing.js +89 -0
  78. package/dist/middleware/tracing.js.map +1 -0
  79. package/dist/module.d.ts +94 -2
  80. package/dist/module.d.ts.map +1 -1
  81. package/dist/module.js +99 -2
  82. package/dist/module.js.map +1 -1
  83. package/dist/observability/batch-span-processor.d.ts +48 -0
  84. package/dist/observability/batch-span-processor.d.ts.map +1 -0
  85. package/dist/observability/batch-span-processor.js +89 -0
  86. package/dist/observability/batch-span-processor.js.map +1 -0
  87. package/dist/observability/context-logger.d.ts +54 -1
  88. package/dist/observability/context-logger.d.ts.map +1 -1
  89. package/dist/observability/context-logger.js +270 -6
  90. package/dist/observability/context-logger.js.map +1 -1
  91. package/dist/observability/error-history.d.ts +36 -7
  92. package/dist/observability/error-history.d.ts.map +1 -1
  93. package/dist/observability/error-history.js +169 -50
  94. package/dist/observability/error-history.js.map +1 -1
  95. package/dist/observability/index.d.ts +16 -5
  96. package/dist/observability/index.d.ts.map +1 -1
  97. package/dist/observability/index.js +8 -3
  98. package/dist/observability/index.js.map +1 -1
  99. package/dist/observability/metrics.d.ts +14 -1
  100. package/dist/observability/metrics.d.ts.map +1 -1
  101. package/dist/observability/metrics.js +23 -2
  102. package/dist/observability/metrics.js.map +1 -1
  103. package/dist/observability/prometheus-exporter.d.ts +37 -0
  104. package/dist/observability/prometheus-exporter.d.ts.map +1 -0
  105. package/dist/observability/prometheus-exporter.js +135 -0
  106. package/dist/observability/prometheus-exporter.js.map +1 -0
  107. package/dist/observability/storage.d.ts +43 -0
  108. package/dist/observability/storage.d.ts.map +1 -0
  109. package/dist/observability/storage.js +58 -0
  110. package/dist/observability/storage.js.map +1 -0
  111. package/dist/observability/store.d.ts +29 -0
  112. package/dist/observability/store.d.ts.map +1 -0
  113. package/dist/observability/store.js +36 -0
  114. package/dist/observability/store.js.map +1 -0
  115. package/dist/observability/usage-exporter.d.ts +58 -0
  116. package/dist/observability/usage-exporter.d.ts.map +1 -0
  117. package/dist/observability/usage-exporter.js +86 -0
  118. package/dist/observability/usage-exporter.js.map +1 -0
  119. package/dist/observability/usage.d.ts +18 -1
  120. package/dist/observability/usage.d.ts.map +1 -1
  121. package/dist/observability/usage.js +25 -3
  122. package/dist/observability/usage.js.map +1 -1
  123. package/dist/pipeline-config.d.ts +11 -0
  124. package/dist/pipeline-config.d.ts.map +1 -1
  125. package/dist/pipeline-config.js +36 -10
  126. package/dist/pipeline-config.js.map +1 -1
  127. package/dist/pipeline.d.ts +123 -2
  128. package/dist/pipeline.d.ts.map +1 -1
  129. package/dist/pipeline.js +249 -50
  130. package/dist/pipeline.js.map +1 -1
  131. package/dist/registry/index.d.ts +2 -0
  132. package/dist/registry/index.d.ts.map +1 -1
  133. package/dist/registry/index.js +1 -0
  134. package/dist/registry/index.js.map +1 -1
  135. package/dist/registry/multi-class.d.ts +71 -0
  136. package/dist/registry/multi-class.d.ts.map +1 -0
  137. package/dist/registry/multi-class.js +120 -0
  138. package/dist/registry/multi-class.js.map +1 -0
  139. package/dist/registry/registry.d.ts +246 -5
  140. package/dist/registry/registry.d.ts.map +1 -1
  141. package/dist/registry/registry.js +485 -16
  142. package/dist/registry/registry.js.map +1 -1
  143. package/dist/schema/annotations.d.ts.map +1 -1
  144. package/dist/schema/annotations.js +1 -0
  145. package/dist/schema/annotations.js.map +1 -1
  146. package/dist/schema/constants.d.ts +9 -0
  147. package/dist/schema/constants.d.ts.map +1 -0
  148. package/dist/schema/constants.js +9 -0
  149. package/dist/schema/constants.js.map +1 -0
  150. package/dist/schema/index.d.ts +1 -1
  151. package/dist/schema/index.d.ts.map +1 -1
  152. package/dist/schema/index.js +1 -1
  153. package/dist/schema/index.js.map +1 -1
  154. package/dist/schema/loader.d.ts +27 -3
  155. package/dist/schema/loader.d.ts.map +1 -1
  156. package/dist/schema/loader.js +137 -32
  157. package/dist/schema/loader.js.map +1 -1
  158. package/dist/schema/types.d.ts +4 -0
  159. package/dist/schema/types.d.ts.map +1 -1
  160. package/dist/schema/types.js.map +1 -1
  161. package/dist/schema/validator.d.ts +9 -0
  162. package/dist/schema/validator.d.ts.map +1 -1
  163. package/dist/schema/validator.js +153 -4
  164. package/dist/schema/validator.js.map +1 -1
  165. package/dist/sys-modules/audit.d.ts +50 -0
  166. package/dist/sys-modules/audit.d.ts.map +1 -0
  167. package/dist/sys-modules/audit.js +89 -0
  168. package/dist/sys-modules/audit.js.map +1 -0
  169. package/dist/sys-modules/control.d.ts +32 -4
  170. package/dist/sys-modules/control.d.ts.map +1 -1
  171. package/dist/sys-modules/control.js +196 -25
  172. package/dist/sys-modules/control.js.map +1 -1
  173. package/dist/sys-modules/index.d.ts +7 -2
  174. package/dist/sys-modules/index.d.ts.map +1 -1
  175. package/dist/sys-modules/index.js +3 -1
  176. package/dist/sys-modules/index.js.map +1 -1
  177. package/dist/sys-modules/overrides.d.ts +58 -0
  178. package/dist/sys-modules/overrides.d.ts.map +1 -0
  179. package/dist/sys-modules/overrides.js +106 -0
  180. package/dist/sys-modules/overrides.js.map +1 -0
  181. package/dist/sys-modules/registration.d.ts +17 -12
  182. package/dist/sys-modules/registration.d.ts.map +1 -1
  183. package/dist/sys-modules/registration.js +134 -23
  184. package/dist/sys-modules/registration.js.map +1 -1
  185. package/dist/sys-modules/toggle.d.ts +7 -2
  186. package/dist/sys-modules/toggle.d.ts.map +1 -1
  187. package/dist/sys-modules/toggle.js +61 -5
  188. package/dist/sys-modules/toggle.js.map +1 -1
  189. package/dist/trace-context.d.ts +47 -9
  190. package/dist/trace-context.d.ts.map +1 -1
  191. package/dist/trace-context.js +139 -16
  192. package/dist/trace-context.js.map +1 -1
  193. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { detectIdConflicts } from './conflicts.js';
7
7
  import { resolveDependencies } from './dependencies.js';
8
8
  import { resolveEntryPoint } from './entry-point.js';
9
9
  import { mergeModuleMetadata, parseDependencies } from './metadata.js';
10
+ import { _discoverMultiClass } from './multi-class.js';
10
11
  import { getSchema } from './schema-export.js';
11
12
  import { toStrictSchema } from '../schema/strict.js';
12
13
  import { deepCopy } from '../utils/index.js';
@@ -43,6 +44,35 @@ async function lazyScanMultiRoot(roots, maxDepth, followSymlinks) {
43
44
  const { scanMultiRoot } = await import('./scanner.js');
44
45
  return scanMultiRoot(roots, maxDepth, followSymlinks);
45
46
  }
47
+ /**
48
+ * One-shot deprecation flag for the legacy 4-arg
49
+ * `Registry.discoverMultiClass(filePath, classes, extensionsRoot, multiClassEnabled)`
50
+ * call shape. Cleared per process so the warning fires at most once even
51
+ * across many invocations from the same caller. See apcore decision-log
52
+ * D-06 (apcore commit 973410b).
53
+ *
54
+ * @internal exported for tests so they can reset between cases.
55
+ */
56
+ let _multiClassEnabledDeprecationWarned = false;
57
+ /**
58
+ * Reset the one-shot deprecation flag for the legacy 4-arg
59
+ * `Registry.discoverMultiClass` overload. Test-only — production callers
60
+ * never need this.
61
+ *
62
+ * @internal
63
+ */
64
+ export function _resetMultiClassEnabledDeprecationWarned() {
65
+ _multiClassEnabledDeprecationWarned = false;
66
+ }
67
+ function warnMultiClassEnabledDeprecated() {
68
+ if (_multiClassEnabledDeprecationWarned)
69
+ return;
70
+ _multiClassEnabledDeprecationWarned = true;
71
+ console.warn('[apcore:registry] DEPRECATION: the `multiClassEnabled` argument to ' +
72
+ '`Registry.discoverMultiClass()` is deprecated and ignored under apcore ' +
73
+ 'decision-log D-06. Mark each `ClassDescriptor` with `multiClass: true` ' +
74
+ 'instead. The 4-arg overload will be removed in v0.22.0.');
75
+ }
46
76
  /**
47
77
  * Standard registry event names.
48
78
  */
@@ -68,6 +98,25 @@ export const MAX_MODULE_ID_LENGTH = 192;
68
98
  * Reserved words that cannot appear as the first segment of a module ID.
69
99
  */
70
100
  export const RESERVED_WORDS = new Set(['system', 'internal', 'core', 'apcore', 'plugin', 'schema', 'acl']);
101
+ /**
102
+ * Namespace prefix reserved for programmatically-registered modules
103
+ * synthesized at runtime (PROTOCOL_SPEC §2.5; RFC
104
+ * `apcore/docs/spec/rfc-ephemeral-modules.md`). IDs in this namespace MUST
105
+ * be registered through {@link Registry.register} only — the filesystem
106
+ * discoverer rejects matching IDs because the namespace has no
107
+ * directory-rooted source of truth, and {@link Registry.registerInternal}
108
+ * rejects them too so the audit-trail provenance distinction between
109
+ * framework-emitted (`system.*`) and caller-emitted (`ephemeral.*`)
110
+ * modules stays clean.
111
+ *
112
+ * The trailing dot is required so module IDs whose first segment merely
113
+ * *starts with* `ephemeral` (e.g. `ephemerals`) are not falsely classified.
114
+ */
115
+ export const EPHEMERAL_NAMESPACE_PREFIX = 'ephemeral.';
116
+ /** Return true when `moduleId` belongs to the reserved `ephemeral.*` namespace. */
117
+ export function isEphemeralModuleId(moduleId) {
118
+ return moduleId === 'ephemeral' || moduleId.startsWith(EPHEMERAL_NAMESPACE_PREFIX);
119
+ }
71
120
  /**
72
121
  * Validate a module ID against PROTOCOL_SPEC §2.7 in canonical order.
73
122
  *
@@ -129,6 +178,16 @@ export class Registry {
129
178
  _refCounts = new Map();
130
179
  _draining = new Set();
131
180
  _drainResolvers = new Map();
181
+ /**
182
+ * Optional EventEmitter for the ephemeral.* registry-side audit emits
183
+ * (RFC `apcore/docs/spec/rfc-ephemeral-modules.md` "Audit-event single-emit
184
+ * rule"). Set via {@link setEventEmitter}; when null, ephemeral audit
185
+ * events are warn-logged so they never silently disappear.
186
+ *
187
+ * Mirrors apcore-python `Registry._event_emitter` /
188
+ * `Registry.set_event_emitter`.
189
+ */
190
+ _eventEmitter = null;
132
191
  constructor(options) {
133
192
  const config = options?.config ?? null;
134
193
  const extensionsDir = options?.extensionsDir ?? null;
@@ -197,6 +256,27 @@ export class Registry {
197
256
  continue;
198
257
  }
199
258
  }
259
+ // PROTOCOL_SPEC §2.7 ID validation — sync finding A-D-102.
260
+ // Mirrors apcore-python `Registry._discover_custom` which calls
261
+ // `_validate_module_id` before registration. Invalid IDs are skipped
262
+ // with a warning rather than aborting the whole discover run.
263
+ try {
264
+ validateModuleId(moduleId, false);
265
+ }
266
+ catch (e) {
267
+ console.warn(`[apcore:registry] Skipping custom-discovered module with invalid ID '${moduleId}': ${e.message}`);
268
+ continue;
269
+ }
270
+ // RFC `apcore/docs/spec/rfc-ephemeral-modules.md`: filesystem /
271
+ // discoverer paths MUST reject `ephemeral.*` IDs. The custom
272
+ // discoverer is functionally a discovery path too, so the same
273
+ // contract applies — ephemeral.* must land via Registry.register().
274
+ if (isEphemeralModuleId(moduleId)) {
275
+ console.warn(`[apcore:registry] Skipping custom-discovered module '${moduleId}': ` +
276
+ `'ephemeral.*' is reserved for programmatic registration via ` +
277
+ `Registry.register(); see docs/spec/rfc-ephemeral-modules.md.`);
278
+ continue;
279
+ }
200
280
  try {
201
281
  this._registerImpl(moduleId, mod);
202
282
  count++;
@@ -207,15 +287,31 @@ export class Registry {
207
287
  }
208
288
  return count;
209
289
  }
290
+ /**
291
+ * Default discovery pipeline (D-32 — 8 canonical stages, mirroring
292
+ * apcore-rust `default_discoverer.rs`):
293
+ *
294
+ * 1. _ensureIdMap — lazy-load the optional id_map.json
295
+ * 2. _scanRoots — walk extension roots
296
+ * 3. _applyIdMapOverrides — rewrite canonical IDs from the map
297
+ * 4. _loadAllMetadata — load each module's `module.yaml`
298
+ * 5. _resolveAllEntryPoints — import the JS/TS entry point
299
+ * 6. _validateAll — run module/custom validators
300
+ * 7. _filterIdConflicts — batch-drop conflicting / invalid IDs
301
+ * 8. _resolveLoadOrder + _registerInOrder
302
+ * — topological sort then register.
303
+ */
210
304
  async _discoverDefault() {
211
305
  await this._ensureIdMap();
212
306
  const discovered = await this._scanRoots();
213
307
  await this._applyIdMapOverrides(discovered);
308
+ this._rejectEphemeralDiscoveries(discovered);
214
309
  const rawMetadata = await this._loadAllMetadata(discovered);
215
310
  const resolvedModules = await this._resolveAllEntryPoints(discovered, rawMetadata);
216
311
  const validModules = await this._validateAll(resolvedModules);
217
- const loadOrder = this._resolveLoadOrder(validModules, rawMetadata);
218
- return this._registerInOrder(loadOrder, validModules, rawMetadata);
312
+ const filteredModules = this._filterIdConflicts(validModules, rawMetadata);
313
+ const loadOrder = this._resolveLoadOrder(filteredModules, rawMetadata);
314
+ return this._registerInOrder(loadOrder, filteredModules, rawMetadata);
219
315
  }
220
316
  async _scanRoots() {
221
317
  let maxDepth = 8;
@@ -230,6 +326,31 @@ export class Registry {
230
326
  }
231
327
  return lazyScanExtensions(this._extensionRoots[0]['root'], maxDepth, followSymlinks);
232
328
  }
329
+ /**
330
+ * Reject filesystem-derived IDs that fall in the reserved `ephemeral.*`
331
+ * namespace.
332
+ *
333
+ * Per the apcore ephemeral-modules RFC pilot (`apcore/docs/spec/
334
+ * rfc-ephemeral-modules.md`), `ephemeral.*` is reserved for
335
+ * programmatically-registered modules synthesized at runtime. Any
336
+ * filesystem layout that produces such an ID is a configuration error —
337
+ * either the directory is misnamed or the namespace prefix is being
338
+ * misused. We surface this as an `InvalidInputError` so callers cannot
339
+ * accidentally pollute the registry with filesystem-rooted ephemerals.
340
+ *
341
+ * Mirrors apcore-python `Registry._reject_ephemeral_discoveries`.
342
+ */
343
+ _rejectEphemeralDiscoveries(discovered) {
344
+ const offenders = discovered.filter((dm) => isEphemeralModuleId(dm.canonicalId));
345
+ if (offenders.length === 0)
346
+ return;
347
+ const ids = [...new Set(offenders.map((dm) => dm.canonicalId))].sort();
348
+ throw new InvalidInputError(`Filesystem discovery produced module ID(s) in the reserved ` +
349
+ `'ephemeral.*' namespace: ${JSON.stringify(ids)}. The ephemeral.* ` +
350
+ `namespace is reserved for programmatically-registered modules and ` +
351
+ `may only be used via Registry.register(). Rename the offending ` +
352
+ `directory or extension namespace.`);
353
+ }
233
354
  async _applyIdMapOverrides(discovered) {
234
355
  if (Object.keys(this._idMap).length === 0)
235
356
  return;
@@ -295,6 +416,56 @@ export class Registry {
295
416
  }
296
417
  return validModules;
297
418
  }
419
+ /**
420
+ * Stage 7 (D-32) — batch-drop modules with invalid or conflicting IDs.
421
+ *
422
+ * Mirrors apcore-python `_filter_id_conflicts` and apcore-rust
423
+ * `default_discoverer::filter_id_conflicts`. Two failure modes drop a
424
+ * module here (warn + skip) rather than aborting the whole batch:
425
+ * - PROTOCOL_SPEC §2.7 ID validation (empty / pattern / length /
426
+ * reserved-word first segment), and
427
+ * - Algorithm A03 conflict detection (duplicate against an existing
428
+ * registration, lowercase collision, reserved-word collision).
429
+ *
430
+ * Soft-severity conflicts (e.g. case-insensitive match against an
431
+ * already-registered ID at `warn` level) are NOT dropped here — the
432
+ * warning is logged and the module flows through to registration so
433
+ * existing behaviour is preserved. Only `error`-severity conflicts and
434
+ * invalid IDs are filtered out.
435
+ *
436
+ * `rawMetadata` is accepted for cross-language signature parity with
437
+ * the Rust/Python helpers (which may inspect metadata for additional
438
+ * checks); the TS implementation reads only the module ID.
439
+ */
440
+ _filterIdConflicts(validModules, _rawMetadata) {
441
+ const filtered = new Map();
442
+ // Track within-batch IDs (case-insensitive) so two newly-discovered
443
+ // modules whose IDs collide on lowercase don't both slip through.
444
+ const batchLowercase = new Map(this._lowercaseMap);
445
+ const batchIds = new Set(this._modules.keys());
446
+ for (const [modId, mod] of validModules.entries()) {
447
+ try {
448
+ validateModuleId(modId, false);
449
+ }
450
+ catch (e) {
451
+ console.warn(`[apcore:registry] Skipping discovered module with invalid ID '${modId}': ${e.message}`);
452
+ continue;
453
+ }
454
+ const conflict = detectIdConflicts(modId, batchIds, RESERVED_WORDS, batchLowercase);
455
+ if (conflict !== null) {
456
+ if (conflict.severity === 'error') {
457
+ console.warn(`[apcore:registry] Skipping discovered module '${modId}' due to ID conflict: ${conflict.message}`);
458
+ continue;
459
+ }
460
+ // Soft severity — log but keep the module.
461
+ console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
462
+ }
463
+ filtered.set(modId, mod);
464
+ batchIds.add(modId);
465
+ batchLowercase.set(modId.toLowerCase(), modId);
466
+ }
467
+ return filtered;
468
+ }
298
469
  _resolveLoadOrder(validModules, rawMetadata) {
299
470
  const modulesWithDeps = [];
300
471
  const moduleVersions = new Map();
@@ -326,9 +497,13 @@ export class Registry {
326
497
  return resolveDependencies(modulesWithDeps, knownIds, moduleVersions);
327
498
  }
328
499
  _registerInOrder(loadOrder, validModules, rawMetadata) {
500
+ // Stage 8 (D-32). Conflict detection / ID validation already happened in
501
+ // `_filterIdConflicts` (stage 7), so this loop is purely a register pass.
329
502
  let count = 0;
330
503
  for (const modId of loadOrder) {
331
504
  const mod = validModules.get(modId);
505
+ if (mod === undefined)
506
+ continue;
332
507
  const modObj = mod;
333
508
  const mergedMeta = mergeModuleMetadata(modObj, rawMetadata.get(modId) ?? {});
334
509
  this._modules.set(modId, mod);
@@ -357,9 +532,25 @@ export class Registry {
357
532
  * Validation order (PROTOCOL_SPEC §2.7, aligned with apcore-python and
358
533
  * apcore-rust): empty → pattern → length → reserved (per-segment) →
359
534
  * duplicate.
535
+ *
536
+ * The optional `version` and `metadata` parameters mirror apcore-python's
537
+ * `Registry.register(module_id, module, version=None, metadata=None)` for
538
+ * cross-language signature parity (sync finding A-001). Multi-version
539
+ * coexistence is not yet implemented in this SDK — when supplied, both
540
+ * fields are merged into the module's metadata so callers can read them
541
+ * back via `getDefinition()` and `list({tags})`. See PROTOCOL_SPEC §5.4.
360
542
  */
361
- register(moduleId, module) {
543
+ register(moduleId, module, version, metadata, options) {
362
544
  validateModuleId(moduleId, false);
545
+ // ephemeral.* registrations only land via this programmatic path —
546
+ // the filesystem discoverer rejects matching IDs upstream (see
547
+ // `_rejectEphemeralDiscoveries`). When invoked here the RFC pilot
548
+ // recommends `requires_approval=true` so a human gates execution of
549
+ // agent-synthesized code; we soft-warn but never refuse.
550
+ const ephemeral = isEphemeralModuleId(moduleId);
551
+ if (ephemeral) {
552
+ this._warnIfMissingApproval(moduleId, module);
553
+ }
363
554
  if (this._customValidator !== null) {
364
555
  const result = this._customValidator.validate(module);
365
556
  if (result instanceof Promise) {
@@ -369,10 +560,17 @@ export class Registry {
369
560
  throw new InvalidInputError(`Custom validator rejected module '${moduleId}': ${result.join('; ')}`);
370
561
  }
371
562
  }
372
- this._registerImpl(moduleId, module);
563
+ const overrides = { ...(metadata ?? {}) };
564
+ if (version !== undefined && version !== null) {
565
+ overrides['version'] = version;
566
+ }
567
+ this._registerImpl(moduleId, module, overrides);
568
+ if (ephemeral) {
569
+ this._emitEphemeralAudit('apcore.registry.module_registered', moduleId, options?.context ?? null);
570
+ }
373
571
  }
374
572
  /** Inner registration — no validator, no ID validation. Used by discover() paths that run their own checks. */
375
- _registerImpl(moduleId, module) {
573
+ _registerImpl(moduleId, module, metadataOverrides = {}) {
376
574
  // Algorithm A03: detect ID conflicts (exact duplicate, reserved word, case collision)
377
575
  const conflict = detectIdConflicts(moduleId, new Set(this._modules.keys()), RESERVED_WORDS, this._lowercaseMap);
378
576
  if (conflict !== null) {
@@ -385,9 +583,10 @@ export class Registry {
385
583
  }
386
584
  this._modules.set(moduleId, module);
387
585
  this._lowercaseMap.set(moduleId.toLowerCase(), moduleId);
388
- // Populate metadata from the module object
586
+ // Populate metadata from the module object, layering any explicit overrides
587
+ // (e.g. the `version` / `metadata` args passed to `register()`) on top.
389
588
  const modObj = module;
390
- this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, {}));
589
+ this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, metadataOverrides));
391
590
  // Call onLoad if available
392
591
  if (typeof modObj['onLoad'] === 'function') {
393
592
  try {
@@ -402,7 +601,7 @@ export class Registry {
402
601
  }
403
602
  this._triggerEvent(REGISTRY_EVENTS.REGISTER, moduleId, module);
404
603
  }
405
- unregister(moduleId) {
604
+ unregister(moduleId, options) {
406
605
  if (!this._modules.has(moduleId))
407
606
  return false;
408
607
  const module = this._modules.get(moduleId);
@@ -421,9 +620,24 @@ export class Registry {
421
620
  }
422
621
  }
423
622
  this._triggerEvent(REGISTRY_EVENTS.UNREGISTER, moduleId, module);
623
+ if (isEphemeralModuleId(moduleId)) {
624
+ this._emitEphemeralAudit('apcore.registry.module_unregistered', moduleId, options?.context ?? null);
625
+ }
424
626
  return true;
425
627
  }
426
- get(moduleId) {
628
+ /**
629
+ * Look up a registered module by ID.
630
+ *
631
+ * @param moduleId - Module identifier (must be non-empty).
632
+ * @param _versionHint - Optional semver range for multi-version coexistence
633
+ * (PROTOCOL_SPEC §5.4). This SDK currently exposes a single-version
634
+ * registry, so the hint is accepted for cross-language API parity with
635
+ * apcore-python (sync finding A-002) but does NOT participate in
636
+ * resolution: the latest registered module for `moduleId` is returned
637
+ * regardless of the hint. When multi-version registration lands, this
638
+ * parameter will gate semver-range matching.
639
+ */
640
+ get(moduleId, _versionHint) {
427
641
  if (moduleId === '') {
428
642
  throw new ModuleNotFoundError('');
429
643
  }
@@ -432,8 +646,21 @@ export class Registry {
432
646
  has(moduleId) {
433
647
  return this._modules.has(moduleId);
434
648
  }
649
+ /**
650
+ * Return sorted list of registered module IDs, optionally filtered by
651
+ * `prefix` / `tags`.
652
+ *
653
+ * Modules whose `ModuleAnnotations.discoverable === false` are excluded
654
+ * by default per PROTOCOL_SPEC §4.4 (RFC
655
+ * `apcore/docs/spec/rfc-ephemeral-modules.md`). Pass
656
+ * `includeHidden: true` to enumerate every registered module — useful
657
+ * for introspection tools, debug consoles, and tests.
658
+ */
435
659
  list(options) {
436
660
  let ids = [...this._modules.keys()];
661
+ if (options?.includeHidden !== true) {
662
+ ids = ids.filter((id) => this._isDiscoverable(id));
663
+ }
437
664
  if (options?.prefix != null) {
438
665
  ids = ids.filter((id) => id.startsWith(options.prefix));
439
666
  }
@@ -456,16 +683,92 @@ export class Registry {
456
683
  }
457
684
  return ids.sort();
458
685
  }
459
- iter() {
460
- return this._modules.entries();
686
+ /**
687
+ * Iterate `[moduleId, module]` pairs. Hides modules annotated
688
+ * `discoverable: false` by default; pass `includeHidden: true` to walk
689
+ * every registered module.
690
+ */
691
+ iter(options) {
692
+ if (options?.includeHidden === true) {
693
+ return this._modules.entries();
694
+ }
695
+ const visible = [];
696
+ for (const [id, mod] of this._modules.entries()) {
697
+ if (this._isDiscoverable(id))
698
+ visible.push([id, mod]);
699
+ }
700
+ return visible[Symbol.iterator]();
461
701
  }
702
+ /** Total number of registered modules (includes `discoverable: false`). */
462
703
  get count() {
463
704
  return this._modules.size;
464
705
  }
706
+ /**
707
+ * Sorted list of registered module IDs (excludes
708
+ * `ModuleAnnotations.discoverable === false` modules). Use
709
+ * {@link list} with `includeHidden: true` when the full set is required.
710
+ */
465
711
  get moduleIds() {
466
- return [...this._modules.keys()].sort();
712
+ return [...this._modules.keys()]
713
+ .filter((id) => this._isDiscoverable(id))
714
+ .sort();
715
+ }
716
+ /**
717
+ * Return false when the module's annotations declare
718
+ * `discoverable: false`. Anything else (including missing annotations)
719
+ * keeps the module visible — the default `true` preserves backward
720
+ * compatibility.
721
+ *
722
+ * Resolution order matches `mergeModuleMetadata`:
723
+ * 1. The merged `annotations` slot in `_moduleMeta` (which already
724
+ * accounts for YAML > code precedence).
725
+ * 2. The module instance's `annotations` attribute (covers paths that
726
+ * bypassed the merge, e.g. `registerInternal`).
727
+ */
728
+ _isDiscoverable(moduleId) {
729
+ const meta = this._moduleMeta.get(moduleId);
730
+ if (meta != null) {
731
+ const ann = meta['annotations'];
732
+ if (ann != null) {
733
+ const dv = this._readDiscoverable(ann);
734
+ if (dv === false)
735
+ return false;
736
+ if (dv === true)
737
+ return true;
738
+ }
739
+ }
740
+ const mod = this._modules.get(moduleId);
741
+ if (mod == null)
742
+ return true;
743
+ const ann = mod['annotations'];
744
+ if (ann == null)
745
+ return true;
746
+ return this._readDiscoverable(ann) !== false;
747
+ }
748
+ _readDiscoverable(ann) {
749
+ if (ann == null)
750
+ return undefined;
751
+ if (typeof ann === 'object') {
752
+ // Accept both ModuleAnnotations instances and YAML-style dicts.
753
+ const rec = ann;
754
+ const v = rec['discoverable'];
755
+ if (v === true || v === false)
756
+ return v;
757
+ }
758
+ return undefined;
467
759
  }
468
- getDefinition(moduleId) {
760
+ getDefinition(moduleId, _versionHint) {
761
+ // `_versionHint` accepted for cross-language API parity with apcore-python
762
+ // (sync finding A-002 / §5.4). Ignored under the single-version registry;
763
+ // see `get()` for the rationale.
764
+ //
765
+ // D10-011: spec registry-system.md:382 says any error that `get(module_id)`
766
+ // raises is propagated. The empty-string guard mirrors `get()` (line 669)
767
+ // so callers using getDefinition see the same ModuleNotFoundError as
768
+ // get(), matching apcore-python where getDefinition routes through get().
769
+ if (moduleId === '') {
770
+ throw new ModuleNotFoundError('');
771
+ }
469
772
  const module = this._modules.get(moduleId);
470
773
  if (module == null)
471
774
  return null;
@@ -564,6 +867,29 @@ export class Registry {
564
867
  }
565
868
  }
566
869
  }
870
+ /**
871
+ * Watch the configured extension roots for filesystem changes and
872
+ * unregister any module whose source file is modified or deleted.
873
+ *
874
+ * **Cross-language divergence (sync finding A-D-104):** unlike apcore-python
875
+ * (which re-imports the file via `importlib.reload`) and apcore-rust (which
876
+ * triggers full rediscovery), the TypeScript SDK is **event-only**. On a
877
+ * file change the registry:
878
+ * 1. unregisters the previously-loaded module (calling its `onUnload`),
879
+ * 2. emits a `'file_changed'` event with `{ filePath }` payload.
880
+ *
881
+ * Consumers are expected to subscribe and re-register the module
882
+ * themselves (e.g. by calling `registry.discover()` or registering a fresh
883
+ * import). ES module specifiers are immutable in Node — there is no
884
+ * portable "reload from disk" primitive — so a transparent dynamic
885
+ * `import()` would silently return the cached old module on every
886
+ * invocation. A workaround using a cache-busting query (`?v=Date.now()`)
887
+ * leaks the old module each reload and breaks browser bundlers, so it is
888
+ * intentionally **not** offered here.
889
+ *
890
+ * If your application needs Python-style hot-reload semantics, listen for
891
+ * `'file_changed'` and re-discover or re-import explicitly.
892
+ */
567
893
  async watch() {
568
894
  if (this._watchers && this._watchers.length > 0) {
569
895
  return; // Already watching
@@ -684,13 +1010,42 @@ export class Registry {
684
1010
  * `Registry::register_internal`.
685
1011
  */
686
1012
  registerInternal(moduleId, module) {
1013
+ // RFC `apcore/docs/spec/rfc-ephemeral-modules.md` "register_internal()
1014
+ // interaction": ephemeral.* IDs MUST be rejected here. Namespace →
1015
+ // registration-mechanism is a 1:1 mapping; mixing blurs the audit-trail
1016
+ // distinction between framework-emitted (`system.*`) and caller-emitted
1017
+ // (`ephemeral.*`) modules.
1018
+ if (isEphemeralModuleId(moduleId)) {
1019
+ throw new InvalidInputError(`ephemeral.* module IDs must be registered via Registry.register(), ` +
1020
+ `not registerInternal() (got: '${moduleId}'). See apcore ` +
1021
+ `docs/spec/rfc-ephemeral-modules.md "register_internal() ` +
1022
+ `interaction" for rationale.`);
1023
+ }
687
1024
  validateModuleId(moduleId, true);
688
- if (this._modules.has(moduleId)) {
689
- throw new InvalidInputError(`Module ID '${moduleId}' is already registered`);
1025
+ // D11-007: route duplicate detection through detectIdConflicts (with an
1026
+ // empty reserved-words set so the bypass for system.* prefixes is
1027
+ // preserved). This restores the case-collision branch present in
1028
+ // apcore-python (registry.py:1674) and apcore-rust (registry.rs:727).
1029
+ // The lowercase-only EBNF in validateModuleId makes case collisions
1030
+ // unreachable today, but the contract surface stays aligned across SDKs.
1031
+ const conflict = detectIdConflicts(moduleId, new Set(this._modules.keys()), new Set(), this._lowercaseMap);
1032
+ if (conflict !== null) {
1033
+ if (conflict.severity === 'error') {
1034
+ throw new InvalidInputError(conflict.message);
1035
+ }
1036
+ else {
1037
+ console.warn(`[apcore:registry] ID conflict: ${conflict.message}`);
1038
+ }
690
1039
  }
691
1040
  this._modules.set(moduleId, module);
692
1041
  const modObj = module;
693
1042
  this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, {}));
1043
+ // Mirror apcore-python register_internal and apcore-rust register_core:
1044
+ // every registration site (including sys/internal) populates the lowercase
1045
+ // index. The lowercase-only EBNF pattern enforced by validateModuleId makes
1046
+ // case collisions unreachable today, but keeping _lowercaseMap consistent
1047
+ // with _modules preserves the invariant for downstream conflict detection.
1048
+ this._lowercaseMap.set(moduleId.toLowerCase(), moduleId);
694
1049
  if (typeof modObj['onLoad'] === 'function') {
695
1050
  try {
696
1051
  modObj['onLoad']();
@@ -698,6 +1053,7 @@ export class Registry {
698
1053
  catch (e) {
699
1054
  this._modules.delete(moduleId);
700
1055
  this._moduleMeta.delete(moduleId);
1056
+ this._lowercaseMap.delete(moduleId.toLowerCase());
701
1057
  throw e;
702
1058
  }
703
1059
  }
@@ -727,7 +1083,16 @@ export class Registry {
727
1083
  clearCache() {
728
1084
  this._schemaCache.clear();
729
1085
  }
730
- // ── Safe Hot-Reload (F09 / Algorithm A21) ───────────────────────
1086
+ discoverMultiClass(filePath, classes, extensionsRoot = 'extensions', multiClassEnabled) {
1087
+ if (multiClassEnabled !== undefined) {
1088
+ warnMultiClassEnabledDeprecated();
1089
+ // The argument is functionally inert under D-06: the per-class
1090
+ // `multiClass` field is the source of truth. We intentionally ignore
1091
+ // `multiClassEnabled` and recompute from the descriptors below.
1092
+ }
1093
+ const enabled = classes.some((c) => c.implementsModule && c.multiClass === true);
1094
+ return _discoverMultiClass(filePath, classes, extensionsRoot, enabled);
1095
+ }
731
1096
  /**
732
1097
  * Number of in-flight executions per module.
733
1098
  */
@@ -841,6 +1206,110 @@ export class Registry {
841
1206
  *
842
1207
  * Returns true if drained cleanly, false if force-unloaded after timeout.
843
1208
  */
1209
+ // ── Ephemeral namespace pilot (RFC: rfc-ephemeral-modules) ──────
1210
+ /**
1211
+ * Wire an EventEmitter for registry-side audit emits.
1212
+ *
1213
+ * Per the apcore RFC `apcore/docs/spec/rfc-ephemeral-modules.md`
1214
+ * "Audit-event single-emit rule", `ephemeral.*` registrations and
1215
+ * unregistrations emit a single `apcore.registry.module_registered` /
1216
+ * `apcore.registry.module_unregistered` event with the D-35 contextual
1217
+ * payload. When no emitter is wired, the event is logged at INFO so it
1218
+ * never silently disappears — useful for the v1 pilot where most
1219
+ * callers will not have an emitter attached.
1220
+ *
1221
+ * Pilot scope: only `ephemeral.*` registrations trigger registry-side
1222
+ * emits. The pre-existing bridge in
1223
+ * `sys-modules/registration.ts` continues to emit empty-payload events
1224
+ * for non-ephemeral modules and short-circuits for ephemerals so the
1225
+ * single-emit rule holds.
1226
+ */
1227
+ setEventEmitter(emitter) {
1228
+ this._eventEmitter = emitter;
1229
+ }
1230
+ /**
1231
+ * Soft-warn when an `ephemeral.*` module is registered without
1232
+ * `requiresApproval: true`. Per the ephemeral-modules RFC pilot,
1233
+ * agent-synthesized modules SHOULD declare `requiresApproval` so a
1234
+ * human gates execution. The registry only warns; it does not refuse
1235
+ * the registration.
1236
+ */
1237
+ _warnIfMissingApproval(moduleId, module) {
1238
+ const ann = module?.annotations;
1239
+ let requiresApproval = false;
1240
+ if (ann != null && typeof ann === 'object') {
1241
+ const rec = ann;
1242
+ requiresApproval = rec['requiresApproval'] === true || rec['requires_approval'] === true;
1243
+ }
1244
+ if (!requiresApproval) {
1245
+ console.warn(`[apcore:registry] ephemeral.* module '${moduleId}' registered without ` +
1246
+ `requiresApproval=true. The apcore RFC docs/spec/rfc-ephemeral-modules.md ` +
1247
+ `recommends setting ModuleAnnotations.requiresApproval=true so ` +
1248
+ `agent-synthesized code does not run unattended.`);
1249
+ }
1250
+ }
1251
+ /**
1252
+ * Build the D-35 audit payload (`caller_id` plus optional `identity`
1253
+ * snapshot) for an ephemeral.* register/unregister. Mirrors the shape
1254
+ * `extractAuditIdentity` produces for `system.control.*` events so
1255
+ * subscribers can apply identical redaction rules.
1256
+ */
1257
+ _buildEphemeralAuditPayload(context) {
1258
+ const callerIdRaw = context?.callerId;
1259
+ const callerId = callerIdRaw == null || callerIdRaw === '' ? '@external' : callerIdRaw;
1260
+ const ident = context?.identity ?? null;
1261
+ if (!ident) {
1262
+ return { caller_id: callerId, identity: null, namespace_class: 'ephemeral' };
1263
+ }
1264
+ const snapshot = {
1265
+ id: ident.id,
1266
+ type: ident.type,
1267
+ roles: [...ident.roles],
1268
+ };
1269
+ const displayName = ident.attrs['display_name'];
1270
+ if (typeof displayName === 'string' && displayName.length > 0) {
1271
+ snapshot['display_name'] = displayName;
1272
+ }
1273
+ return { caller_id: callerId, identity: snapshot, namespace_class: 'ephemeral' };
1274
+ }
1275
+ /**
1276
+ * Emit the canonical ephemeral.* audit event to the wired EventEmitter
1277
+ * (or warn-log if none is wired). The bridge in
1278
+ * `sys-modules/registration.ts` short-circuits on ephemeral.* IDs so
1279
+ * exactly one event lands per registration / unregistration.
1280
+ */
1281
+ _emitEphemeralAudit(eventType, moduleId, context) {
1282
+ let payload;
1283
+ try {
1284
+ payload = this._buildEphemeralAuditPayload(context);
1285
+ }
1286
+ catch (e) {
1287
+ console.warn(`[apcore:registry] Failed to extract audit payload for ephemeral '${moduleId}': ${e.message}. ` +
1288
+ `Falling back to caller_id='@external'.`);
1289
+ payload = { caller_id: '@external', identity: null, namespace_class: 'ephemeral' };
1290
+ }
1291
+ const emitter = this._eventEmitter;
1292
+ if (emitter === null) {
1293
+ console.info(`[apcore:registry] ephemeral audit event ${eventType} module_id=${moduleId} ` +
1294
+ `payload=${JSON.stringify(payload)} (no EventEmitter wired; call ` +
1295
+ `Registry.setEventEmitter to capture)`);
1296
+ return;
1297
+ }
1298
+ const event = {
1299
+ eventType,
1300
+ moduleId,
1301
+ timestamp: new Date().toISOString(),
1302
+ severity: 'info',
1303
+ data: payload,
1304
+ };
1305
+ try {
1306
+ emitter.emit(event);
1307
+ }
1308
+ catch (e) {
1309
+ console.error(`[apcore:registry] EventEmitter.emit failed for ephemeral audit event ` +
1310
+ `${eventType} on '${moduleId}':`, e);
1311
+ }
1312
+ }
844
1313
  async safeUnregister(moduleId, timeoutMs = 5000) {
845
1314
  if (!this._modules.has(moduleId))
846
1315
  return false;