@passioncode-ai/fabric-agent-adapter 0.78.0 → 0.80.2

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 (105) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +14 -7
  3. package/bin/fabric-agent-adapter.js +26 -4
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +42 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +23 -8
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +11 -6
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +42 -7
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +6 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +14 -4
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +6 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +15 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +3 -2
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +48 -5
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +8 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +55 -14
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +16 -7
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +4 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +49 -3
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +24 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -16
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +543 -37
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +12 -5
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +42 -7
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +32 -0
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
  91. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  92. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -26
  93. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  94. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  95. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +59 -38
  96. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +52 -26
  97. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  98. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  99. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  100. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  101. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  102. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +169 -46
  103. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +146 -19
  104. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  105. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -12,9 +12,11 @@
12
12
 
13
13
  import crypto from 'node:crypto';
14
14
  import fs from 'node:fs';
15
+ import http from 'node:http';
15
16
  import net from 'node:net';
16
17
  import { hubKeyCertified } from './fabric-estate.mjs';
17
18
  import { atomicWrite } from './fabric-paths.mjs'; // the kit's private writer (FAA-85)
19
+ import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs'; // a loopback key set from this account's listener only (FAA-255)
18
20
  import { effectClosure } from './fabric-mandate.mjs'; // defined once beside the mandate that reads it too (DEC-0125)
19
21
 
20
22
  export const REFUSAL_CODES = Object.freeze([
@@ -411,8 +413,34 @@ export const actionDigest = (action) => `sha256:${crypto.createHash('sha256').up
411
413
 
412
414
  const loopback = (host) => host === 'localhost' || (net.isIP(host.replace(/^\[|\]$/g, '')) !== 0 && (/^127\./.test(host) || host.replace(/^\[|\]$/g, '') === '::1'));
413
415
 
416
+ const KEY_SET_MAX = 65536;
417
+ const KEY_SET_AGENT = new CheckedAgent({ keepAlive: false });
418
+
419
+ // Plain http, which a key set source allows on loopback only: through a CheckedAgent, so the listener's owner is told
420
+ // before a byte is written and keys another OS account's process serves are never read (DEC-0154 HA-2, FAA-255).
421
+ function loopbackKeySet(source, agent) {
422
+ return new Promise((resolve, reject) => {
423
+ const request = http.get(source.split('#')[0], { agent, headers: { Accept: 'application/json' }, timeout: 5000 }, (response) => {
424
+ if (response.statusCode < 200 || response.statusCode > 299) { response.resume(); reject(new Error(`HTTP ${response.statusCode}`)); return; }
425
+ let text = '';
426
+ response.setEncoding('utf8');
427
+ response.on('data', (chunk) => { text += chunk; if (text.length > KEY_SET_MAX) { request.destroy(); reject(new Error('the key set is too large')); } });
428
+ response.on('end', () => { try { resolve(JSON.parse(text)); } catch (e) { reject(e); } });
429
+ response.on('error', reject);
430
+ });
431
+ request.on('timeout', () => request.destroy(new Error('the key set did not arrive within 5 s')));
432
+ request.on('error', (e) => reject(e instanceof ListenerRefused ? new Error(`foreign-user: ${e.message}`) : e));
433
+ });
434
+ }
435
+
436
+ /** Read a key set source: an https URL (fetch, no redirect), a loopback http URL (checked listener) or a file. */
437
+ export async function fetchKeySet(source, { agent = KEY_SET_AGENT } = {}) {
438
+ if (/^http:\/\//.test(source)) return loopbackKeySet(source, agent);
439
+ return defaultFetch(source);
440
+ }
441
+
414
442
  async function defaultFetch(source) {
415
- if (/^https?:\/\//.test(source)) {
443
+ if (/^https:\/\//.test(source)) {
416
444
  const response = await fetch(source, { headers: { Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(5000) });
417
445
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
418
446
  if (Number(response.headers.get('content-length') ?? 0) > 65536) throw new Error('the key set is too large');
@@ -428,7 +456,7 @@ async function defaultFetch(source) {
428
456
  * `await get(kid)` answers null when no set younger than 10 minutes can be had (issuer-unavailable).
429
457
  */
430
458
  export class JwksCache {
431
- constructor(source, { fetch: fetcher = defaultFetch, clock = () => Date.now() / 1000 } = {}) {
459
+ constructor(source, { fetch: fetcher = fetchKeySet, clock = () => Date.now() / 1000 } = {}) {
432
460
  if (typeof source !== 'string' || !source) throw new Error('the key set needs a source');
433
461
  if (/^https?:\/\//.test(source)) {
434
462
  const url = new URL(source);
@@ -1,10 +1,12 @@
1
1
  // How a service is found (fabric-service/0.1): its descriptor, peers and app descriptors (DEC-0061, DEC-0069), build identity and the well-known document.
2
2
  // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_descriptor.py. Node 20+, no dependency.
3
+ import { spawnSync } from 'node:child_process';
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import { credentialName, repositoryCredential, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
6
- import { ID, INSTANCE, LOCAL_PATH, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
7
+ import { ID, INSTANCE, LOCAL_PATH, PRIMARY_CAPABILITY, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
7
8
  import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
9
+ import { ANOTHER_ACCOUNT } from './fabric-peer-owner.mjs'; // L0: who listens on a port (DEC-0154 HA-2, FAA-225)
8
10
  import { protocolsFindings } from './fabric-protocols.mjs';
9
11
  import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
10
12
  import { ACTIONS_EXTENSION_KEY, actionsExtension, actionsExtensionRules } from './fabric-actions.mjs';
@@ -15,7 +17,7 @@ import { ROUTES } from './fabric-usage.mjs';
15
17
  // document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
16
18
  export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
17
19
 
18
- export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.5';
20
+ export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.6';
19
21
 
20
22
  export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
21
23
 
@@ -313,9 +315,43 @@ export function sourceRefusal(d, dir = servicesDir()) {
313
315
  return `Descriptor ${d?.id}.${instance} ${NO_SOURCE_REPOSITORY}. ${PASS_SOURCE}`;
314
316
  }
315
317
 
316
- // FAA-198: a new descriptor names its source.repository; rewriting one already installed without it only warns
317
- // (process.emitWarning, code FABRIC_NO_SOURCE_REPOSITORY), so an installed agent's update keeps working.
318
- export function writeDescriptor(d, dir = servicesDir()) {
318
+ // The child that answers portOwner: a plain listen on 127.0.0.1:<port> that succeeds means nothing holds it (the table
319
+ // is not read); one that fails asks fabric-peer-owner's listener. A child, because writeDescriptor is synchronous.
320
+ const PORT_OWNER_CHILD = `
321
+ import net from 'node:net';
322
+ const [port, url] = process.argv.slice(1);
323
+ const say = (value) => process.stdout.write(JSON.stringify(value));
324
+ const server = net.createServer();
325
+ server.once('error', async () => {
326
+ try { const { listener } = await import(url); const who = await listener(Number(port)); say([who.kind, Number.isInteger(who.uid) ? who.uid : null]); }
327
+ catch { say(['unknown', null]); }
328
+ });
329
+ server.listen({ port: Number(port), host: '127.0.0.1', exclusive: true }, () => server.close(() => say(null)));
330
+ `;
331
+
332
+ /** Who listens on 127.0.0.1:<port> now, as [kind, uid] from the kit's fabric-peer-owner, or null when nothing holds it
333
+ * (FAA-225, DEC-0154 HA-2). */
334
+ export function portOwner(port) {
335
+ const done = spawnSync(process.execPath, ['--input-type=module', '-e', PORT_OWNER_CHILD, String(port),
336
+ new URL('./fabric-peer-owner.mjs', import.meta.url).href], { encoding: 'utf8', timeout: 10000 });
337
+ try { return JSON.parse(done.stdout); } catch { return ['unknown', null]; }
338
+ }
339
+
340
+ /** Why an install may not take `port`: another OS account listens on it (FAA-225). This account, the system (launchd
341
+ * holds a socket-activated job's socket), nobody and an unreadable table pass. The recorded port per account and its
342
+ * claim file wait on contract DEC-0160. */
343
+ export function portListenerRefusal(port, reader = portOwner) {
344
+ const found = reader(port);
345
+ if (!Array.isArray(found) || found[0] !== ANOTHER_ACCOUNT) return null;
346
+ const uid = Number.isInteger(found[1]) ? ` (uid ${found[1]})` : '';
347
+ return `Port ${port} is listened on by another OS account${uid}: choose a port no other account holds (DEC-0154, FAA-225).`;
348
+ }
349
+
350
+ /** Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim), and a port another OS
351
+ * account listens on (FAA-225; `portOwner(port) -> [kind, uid] | null` reads it, the kit's own by default).
352
+ * FAA-198: a new descriptor names its source.repository; rewriting one already installed without it only warns
353
+ * (process.emitWarning, code FABRIC_NO_SOURCE_REPOSITORY), so an installed agent's update keeps working. */
354
+ export function writeDescriptor(d, dir = servicesDir(), { portOwner: reader = portOwner } = {}) {
319
355
  const problems = validateDescriptor(d);
320
356
  if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
321
357
  const me = `${d.id}.${d.instance}`;
@@ -334,6 +370,8 @@ export function writeDescriptor(d, dir = servicesDir()) {
334
370
  try { otherPort = portOf(String(other.origin ?? '')); } catch { continue; }
335
371
  if (otherPort === port) throw new ServiceError(`Port ${port} is already claimed by ${key} (${file}).`);
336
372
  }
373
+ const held = port === null ? null : portListenerRefusal(port, reader);
374
+ if (held) throw new ServiceError(held);
337
375
  ensurePrivateDir(dir);
338
376
  const target = descriptorPath(d.id, d.instance, dir);
339
377
  atomicWrite(target, `${JSON.stringify(d, null, 2)}\n`, 0o600);
@@ -418,6 +456,9 @@ export function configurationProblems(doc) {
418
456
  // `a2a` (DEC-0122) is surfaces.a2a, where the service serves its public Agent Card (a2aSurface builds one); passed here
419
457
  // or in `surfaces`, never both, and refused when it is not the schema's. A reader pinned before DEC-0122 refuses the
420
458
  // member, so a service carries it only once its readers are pinned at or after it.
459
+ // FAA-244: DEC-0140 PR-1 reserves the primary responder's capability; a service never publishes it.
460
+ const reserved = (where) => `${where}: ${PRIMARY_CAPABILITY} is reserved for a Project's primary responder, a session (DEC-0140 PR-1); a service never serves it as a tool or declares it.`;
461
+
421
462
  export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols, actions, a2a }) {
422
463
  if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
423
464
  if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
@@ -430,6 +471,7 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
430
471
  const problems = a2aSurfaceProblems(surfaces.a2a);
431
472
  if (problems.length) throw new ServiceError(`surfaces.a2a: ${problems.join('; ')}.`);
432
473
  }
474
+ if (Array.isArray(surfaces.mcp?.capabilities) && surfaces.mcp.capabilities.includes(PRIMARY_CAPABILITY)) throw new ServiceError(reserved('surfaces.mcp.capabilities'));
433
475
  if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
434
476
  const doc = {
435
477
  protocol: PROTOCOL,
@@ -461,6 +503,7 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
461
503
  // compared with the capabilities this document's MCP surface lists.
462
504
  const findings = actionsExtensionRules({ extensions: { [ACTIONS_EXTENSION_KEY]: extensions[ACTIONS_EXTENSION_KEY] }, surfaces });
463
505
  if (findings.length) throw new ServiceError(`Actions declaration: ${findings.map((f) => f.message).join('; ')}.`);
506
+ if ((extensions[ACTIONS_EXTENSION_KEY]?.actions ?? []).some((a) => a?.tool === PRIMARY_CAPABILITY)) throw new ServiceError(reserved("an action's tool"));
464
507
  }
465
508
  doc.extensions = { ...extensions };
466
509
  }
@@ -16,9 +16,11 @@
16
16
  // The transport is injected in tests: async ({ method, url, headers, body, timeoutS, maxBytes }) =>
17
17
  // { status, headers, body: Uint8Array }, throwing on a connection error (a `TimeoutError` name is a timeout) or an
18
18
  // HttpError. Node 20 or newer, no dependencies. Part of the service kit (FAA-65, layer L4).
19
+ //
20
+ // Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
19
21
 
20
22
  import http from 'node:http';
21
- import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
23
+ import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED } from './fabric-peer-owner.mjs';
22
24
 
23
25
  export const RETRY_STATUSES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
24
26
  export const IDEMPOTENT_METHODS = Object.freeze(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
@@ -27,7 +29,7 @@ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
27
29
 
28
30
  export class HttpError extends Error {
29
31
  constructor(message, { status = null, url = '', attempts = 0, retryAfterS = null, body = new Uint8Array(0),
30
- kind = 'http' } = {}) {
32
+ kind = 'http', reason = null } = {}) {
31
33
  super(message);
32
34
  this.name = 'HttpError';
33
35
  this.status = status;
@@ -36,6 +38,8 @@ export class HttpError extends Error {
36
38
  this.retryAfterS = retryAfterS;
37
39
  this.body = body.slice(0, 2048);
38
40
  this.kind = kind; // http, connection, timeout, too-large, refused, foreign-user
41
+ // For foreign-user, why (FAA-272): table-restricted (never clears), the owner's kind, or the check's reason.
42
+ this.reason = reason;
39
43
  }
40
44
  }
41
45
 
@@ -184,7 +188,9 @@ export function checkedLoopbackTransport({ method, url, headers, body, timeoutS,
184
188
  req.on('error', (err) => {
185
189
  clearTimeout(timer);
186
190
  if (err instanceof ListenerRefused) {
187
- reject(new HttpError(`${method} ${redactUrl(url)}: foreign-user: ${err.message}`, { url: redactUrl(url), kind: 'foreign-user' }));
191
+ const why = String(err.owner?.reason ?? err.owner?.kind ?? err.code ?? 'unknown');
192
+ const reason = why.startsWith(TABLE_RESTRICTED) ? TABLE_RESTRICTED : why.split(':', 1)[0];
193
+ reject(new HttpError(`${method} ${redactUrl(url)}: foreign-user: ${err.message}`, { url: redactUrl(url), kind: 'foreign-user', reason }));
188
194
  } else reject(err);
189
195
  });
190
196
  if (body) req.write(body);
@@ -27,7 +27,7 @@ import os from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { atomicWrite, ensurePrivateDir, readToken } from './fabric-service.mjs';
29
29
  import { hubKeyCertified } from './fabric-estate.mjs';
30
- import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
30
+ import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED } from './fabric-peer-owner.mjs';
31
31
 
32
32
  // DEC-0154 HA-2: every connection to the door is checked before a byte goes out; kept alive, so a busy session reads the
33
33
  // socket table once per connection, not once per call. A free socket is closed after 2 s idle, before a server's own
@@ -53,6 +53,20 @@ export class HubError extends Error {
53
53
  }
54
54
  }
55
55
 
56
+ /**
57
+ * The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned contract's
58
+ * state name, with the owner's reason in the sentence and in `data.reason` (FAA-272): `table-restricted` when this
59
+ * runtime's socket table hides other processes' sockets, which never clears, so it is not retryable; otherwise the
60
+ * owner's kind or the check's reason (`another-account`, `no-row`), retryable as before.
61
+ */
62
+ export function listenerRefusal(e) {
63
+ const kind = e?.owner?.kind ?? null;
64
+ const why = String(e?.owner?.reason ?? kind ?? e?.code ?? 'unknown');
65
+ const reason = why.startsWith(TABLE_RESTRICTED) ? TABLE_RESTRICTED : why.split(':', 1)[0];
66
+ const shown = kind && e?.owner?.reason ? `${kind}: ${e.owner.reason}` : String(kind ?? why);
67
+ return new HubError('foreign-user', `The hub's port is not held by this OS account's process (${shown}); nothing was sent.`, reason !== TABLE_RESTRICTED, { reason });
68
+ }
69
+
56
70
  /** Fabric's registry root, where hub.json lives (Fabric agentRegistry.ts#registryDirs). */
57
71
  export function fabricRoot(platform = process.platform, env = process.env) {
58
72
  const home = env.HOME || env.USERPROFILE || os.homedir();
@@ -202,7 +216,7 @@ export function postMcp(origin, body, bearer, { session, timeout = 15000, method
202
216
  });
203
217
  req.on('timeout', () => req.destroy(new Error('timeout')));
204
218
  req.on('error', (e) => reject(e instanceof ListenerRefused
205
- ? new HubError('foreign-user', `The hub's port is not held by this OS account's process (${e.owner?.kind ?? e.code}); nothing was sent.`, true)
219
+ ? listenerRefusal(e)
206
220
  : new HubError('hub-unavailable', `Fabric's hub did not answer (${e.code ?? e.message}).`, true)));
207
221
  if (payload) req.write(payload);
208
222
  req.end();
@@ -0,0 +1,344 @@
1
+ // The call queue and the load report of DEC-0104 (contract src/agent-network.ts, FAC-SEM-131 to FAC-SEM-133), as the
2
+ // contract's reference answers them: a node that hosts a job-backed agent queues its jobs per (agentRef, context) with
3
+ // these and reports its load as service-load/1 under extensions/load/0.1. Part of the Node service kit (FAA-289, layer
4
+ // L3); the twin of fabric_load.py. Node 20+, no dependency. Nothing here throws on what it reads, except
5
+ // loadExtension, which refuses a declaration a reader would treat as not declared.
6
+ //
7
+ // - jobCallerKey(accepted, credential): the opaque key of a job's caller (an accepted delegation's principal, its
8
+ // outermost actor and a partner organization ending the chain; else the credential the node knows the caller by).
9
+ // - nextQueuedJob(queue, running, limits): the job the node starts next, or undefined (fairness between callers).
10
+ // - admitJob(queue, running, job, limits, retryAfterSeconds): {admit: true}, or the queue-full refusal with its data.
11
+ // - queueStatusMessage(position, length), readQueuePosition(statusMessage): `queued <position> of <length>`.
12
+ // - jobQueueRules(value): FAC-SEM-131 over {queue, running, limits, started}.
13
+ // - loadReportRules(report): FAC-SEM-132 on a service-load/1 report; loadReportProblems(report): its schema.
14
+ // - loadView(wellKnown), loadExtensionRules(wellKnown): the declaration as a reader reads it, and FAC-SEM-133.
15
+ // - loadExtension({ path, windowSeconds, limits }): the `extensions` entry a service declares.
16
+ //
17
+ // The kit has no JSON Schema validator: the two shapes are written here from the schemas as the contract's validator
18
+ // reads them (lengths in code points, date-time as ajv-formats' full mode). Times in the queue are UTC instants
19
+ // (interop-common#/$defs/utcInstant); times in the report are RFC 3339 read strictly (the contract's strictTime). The
20
+ // wording of a schema problem is the kit's, not ajv's.
21
+ import { ServiceError } from './fabric-base.mjs';
22
+ import { dateTime } from './fabric-connections.mjs'; // ajv-formats' date-time, as the contract's validator reads it
23
+ import { isObject, isString, objects, show } from './fabric-jsvalue.mjs';
24
+ import { parseRfc3339Ms } from './fabric-time.mjs'; // the contract's strictTime
25
+
26
+ /**
27
+ * @typedef {{ id: string, caller: string, at: string }} QueuedJob A job waiting at the hosting node: its id, its
28
+ * caller's key (jobCallerKey) and when it arrived (a UTC instant).
29
+ * @typedef {{ id: string, caller?: string }} RunningJob A job running: its id and caller.
30
+ * @typedef {{ concurrency: number, perCaller: number, queueMax: number }} QueueLimits The queue's limits per
31
+ * (agentRef, context), as the load extension declares them.
32
+ * @typedef {{ admit: true } | { admit: false, error: { code: 'queue-full', message: string,
33
+ * data: { retryable: true, retryAfterSeconds: number } } }} Admission
34
+ * @typedef {{ code: string, instancePath: string, message: string }} Finding
35
+ * @typedef {{ declared: boolean, valid: boolean, load: { path: string, windowSeconds: number, limits: QueueLimits } | null,
36
+ * errors: string[] }} LoadView
37
+ */
38
+
39
+ export const LOAD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/load/0.1';
40
+ export const LOAD_REPORT_TYPE = 'service-load/1';
41
+ // The refusal code, the same as fabric-interop-rules.mjs's QUEUE_FULL (a test holds them equal); not imported because
42
+ // the facade fabric-service.mjs, which re-exports this module, comes before the interop rules in the kit's order.
43
+ const QUEUE_FULL = 'queue-full';
44
+ export const QUEUE_FULL_MESSAGE = "the agent's queue for this context is full: start again later";
45
+ /** The longest queue a status message names (`queued <position> of <length>`, six digits). */
46
+ export const QUEUE_LENGTH_MAX = 999999;
47
+ export const DEFAULT_RETRY_AFTER_SECONDS = 60;
48
+
49
+ const UTC_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[0-5]\d(?:\.\d{1,3})?Z$/;
50
+ const QUEUED = /^queued ([1-9]\d{0,5}) of ([1-9]\d{0,5})$/;
51
+ const ORIGIN_PATH = /^\/(?!\/)[^\s]*$/u;
52
+ const CONTEXT = /^(?:personal|org:[A-Za-z0-9][A-Za-z0-9_-]{0,127})$/;
53
+ const KEY_PATH = `/extensions/${LOAD_EXTENSION_KEY.replace(/~/g, '~0').replace(/\//g, '~1')}`;
54
+ const LIMITS = [['concurrency', 1, 1024], ['perCaller', 1, 1024], ['queueMax', 0, 100000]];
55
+ const ROW_COUNTS = ['queued', 'running', 'completed', 'failed'];
56
+
57
+ /** An own member, or undefined. */
58
+ const member = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
59
+ /** A UTC instant (`T`, `Z`, at most three fraction digits, a day that exists) in milliseconds, else NaN. */
60
+ const utcMs = (value) => (isString(value) && UTC_INSTANT.test(value) ? parseRfc3339Ms(value) : Number.NaN);
61
+ const strictMs = (value) => (isString(value) ? parseRfc3339Ms(value) : Number.NaN);
62
+ const codePoints = (value) => [...value].length;
63
+
64
+ // --- The call queue (fabric-interop, DEC-0104) ---
65
+
66
+ /**
67
+ * The key of a job's caller at a node that queues (DEC-0104), as the contract's jobCallerKey: for a call whose
68
+ * delegation the node accepted (verifyDelegation's answer), its `principal` and `caller` and the partner organization
69
+ * when the chain ends in one (DEC-0090); for a call with no token, the credential the node knows the caller by (a
70
+ * gate's pass with `principal: null` is that case too). A refused verdict has no key, and neither has an unreadable
71
+ * call: undefined. The key is opaque; only equality matters.
72
+ * @param {unknown} accepted
73
+ * @param {unknown} credential
74
+ * @returns {string | undefined}
75
+ */
76
+ export function jobCallerKey(accepted, credential) {
77
+ if (isObject(accepted) && !(member(accepted, 'ok') === true && member(accepted, 'principal') === null)) {
78
+ const principal = member(accepted, 'principal');
79
+ const caller = member(accepted, 'caller');
80
+ if (member(accepted, 'ok') !== true || !isString(principal) || principal === '' || !isString(caller) || caller === '') return undefined;
81
+ const given = member(accepted, 'chain');
82
+ const chain = Array.isArray(given) ? given : [];
83
+ const last = chain[chain.length - 1];
84
+ const partner = chain.length > 1 && isString(last) && last.startsWith('org:') ? [last] : [];
85
+ return JSON.stringify(['delegated', principal, caller, ...partner]);
86
+ }
87
+ return isString(credential) && credential !== '' ? JSON.stringify(['credential', credential]) : undefined;
88
+ }
89
+
90
+ const limitsOk = (limits) => isObject(limits)
91
+ && [member(limits, 'concurrency'), member(limits, 'perCaller')].every((n) => Number.isSafeInteger(n) && n >= 1)
92
+ && Number.isSafeInteger(member(limits, 'queueMax')) && limits.queueMax >= 0;
93
+ const waitingJob = (job) => isObject(job) && isString(member(job, 'id')) && isString(member(job, 'caller')) && Number.isFinite(utcMs(member(job, 'at')));
94
+
95
+ /**
96
+ * The job the hosting node starts next for one (agentRef, context), or undefined (DEC-0104). Nothing starts while
97
+ * `concurrency` jobs run (every running job holds a slot, its caller readable or not). Of the waiting jobs whose caller
98
+ * runs fewer than `perCaller`, the one whose caller runs the fewest wins; between those callers, the one whose oldest
99
+ * waiting job arrived first; within a caller, its oldest. Arrival ties break by id (UTF-16 order).
100
+ * @param {unknown} queue
101
+ * @param {unknown} running
102
+ * @param {unknown} limits
103
+ * @returns {string | undefined}
104
+ */
105
+ export function nextQueuedJob(queue, running, limits) {
106
+ if (!limitsOk(limits)) return undefined;
107
+ if ((Array.isArray(running) ? running.length : 0) >= limits.concurrency) return undefined;
108
+ const runningOf = new Map();
109
+ for (const job of objects(running)) if (isString(member(job, 'caller'))) runningOf.set(job.caller, (runningOf.get(job.caller) ?? 0) + 1);
110
+ const waiting = objects(queue)
111
+ .filter((job) => waitingJob(job) && (runningOf.get(job.caller) ?? 0) < limits.perCaller)
112
+ .sort((a, b) => utcMs(a.at) - utcMs(b.at) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
113
+ let best;
114
+ for (const job of waiting) if (best === undefined || (runningOf.get(job.caller) ?? 0) < (runningOf.get(best.caller) ?? 0)) best = job;
115
+ return best?.id;
116
+ }
117
+
118
+ /**
119
+ * Whether a start is admitted (DEC-0104): it is when the job would start at once (nextQueuedJob over the queue with it
120
+ * added answers its id), or when fewer than `queueMax` jobs wait; otherwise {admit: false, error} with the queue-full
121
+ * refusal and its `retryAfterSeconds` (1 to 3600, else 60). Unreadable limits or job refuse.
122
+ * @param {unknown} queue
123
+ * @param {unknown} running
124
+ * @param {unknown} job
125
+ * @param {unknown} limits
126
+ * @param {unknown} retryAfterSeconds
127
+ * @returns {Admission}
128
+ */
129
+ export function admitJob(queue, running, job, limits, retryAfterSeconds) {
130
+ const waiting = objects(queue);
131
+ if (limitsOk(limits) && waitingJob(job) && (nextQueuedJob([...waiting, job], running, limits) === job.id || waiting.length < limits.queueMax)) return { admit: true };
132
+ const after = Number.isSafeInteger(retryAfterSeconds) && retryAfterSeconds >= 1 && retryAfterSeconds <= 3600 ? retryAfterSeconds : DEFAULT_RETRY_AFTER_SECONDS;
133
+ return { admit: false, error: { code: QUEUE_FULL, message: QUEUE_FULL_MESSAGE, data: { retryable: true, retryAfterSeconds: after } } };
134
+ }
135
+
136
+ /**
137
+ * The `statusMessage` of a waiting job (DEC-0104): `queued <position> of <length>`, 1 is next, 1 <= position <= length
138
+ * <= 999999; undefined for anything else.
139
+ * @param {unknown} position
140
+ * @param {unknown} length
141
+ * @returns {string | undefined}
142
+ */
143
+ export const queueStatusMessage = (position, length) => (Number.isSafeInteger(position) && Number.isSafeInteger(length)
144
+ && position >= 1 && position <= length && length <= QUEUE_LENGTH_MAX ? `queued ${position} of ${length}` : undefined);
145
+
146
+ /**
147
+ * A waiting job's place, read from its `statusMessage`, or undefined when it is not the queue's grammar.
148
+ * @param {unknown} statusMessage
149
+ * @returns {{ position: number, length: number } | undefined}
150
+ */
151
+ export function readQueuePosition(statusMessage) {
152
+ const match = isString(statusMessage) ? QUEUED.exec(statusMessage) : null;
153
+ if (!match) return undefined;
154
+ const position = Number(match[1]);
155
+ const length = Number(match[2]);
156
+ return position >= 1 && position <= length ? { position, length } : undefined;
157
+ }
158
+
159
+ const finding = (code, instancePath, message) => ({ code, instancePath, message });
160
+
161
+ /**
162
+ * FAC-SEM-131 on {queue, running, limits, started} (`started`: the id the node started, or null): it is nextQueuedJob's.
163
+ * @param {unknown} value
164
+ * @returns {Finding[]}
165
+ */
166
+ export function jobQueueRules(value) {
167
+ const want = nextQueuedJob(member(value, 'queue'), member(value, 'running'), member(value, 'limits')) ?? null;
168
+ if ((member(value, 'started') ?? null) === want) return [];
169
+ return [finding('FAC-SEM-131', '/started', want === null ? 'nothing may start now' : `the job to start is ${show(want)}`)];
170
+ }
171
+
172
+ // --- The load report (DEC-0104) ---
173
+
174
+ /**
175
+ * FAC-SEM-132 on a service-load/1 report: windows in order one `windowSeconds` apart, none after `generatedAt`, one
176
+ * row per context, p50 at most p95, a run time only where a job ended.
177
+ * @param {unknown} report
178
+ * @returns {Finding[]}
179
+ */
180
+ export function loadReportRules(report) {
181
+ const findings = [];
182
+ const flag = (instancePath, message) => findings.push(finding('FAC-SEM-132', instancePath, message));
183
+ const seconds = member(report, 'windowSeconds');
184
+ const step = Number.isSafeInteger(seconds) ? seconds * 1000 : Number.NaN;
185
+ const generated = strictMs(member(report, 'generatedAt'));
186
+ let previous;
187
+ objects(member(report, 'windows')).forEach((window, index) => {
188
+ const start = strictMs(member(window, 'start'));
189
+ if (start > generated) flag(`/windows/${index}/start`, 'a window starts after the report was generated');
190
+ if (previous !== undefined && start - previous !== step) flag(`/windows/${index}/start`, 'windows are not in order one windowSeconds apart');
191
+ previous = start;
192
+ const seen = new Set();
193
+ objects(member(window, 'byContext')).forEach((row, at) => {
194
+ const path = `/windows/${index}/byContext/${at}`;
195
+ const context = member(row, 'context');
196
+ if (isString(context) && seen.has(context)) flag(`${path}/context`, `context ${show(context)} appears twice in one window`);
197
+ if (isString(context)) seen.add(context);
198
+ for (const name of ['waitMs', 'runMs']) {
199
+ const pct = member(row, name);
200
+ if (isObject(pct) && typeof member(pct, 'p50') === 'number' && typeof member(pct, 'p95') === 'number' && pct.p50 > pct.p95) flag(`${path}/${name}`, 'p50 is above p95');
201
+ }
202
+ if (isObject(member(row, 'runMs')) && member(row, 'completed') === 0 && member(row, 'failed') === 0) flag(`${path}/runMs`, 'a run time with no job ended in the window');
203
+ });
204
+ });
205
+ return findings;
206
+ }
207
+
208
+ /** JSON Schema's integer with its bounds, as ajv reads it: a number with no fraction, never a boolean. */
209
+ function integer(value, low, high, at, errors) {
210
+ if (typeof value !== 'number' || !Number.isFinite(value) || value % 1 !== 0) errors.push(`${at} type`);
211
+ else if (value < low) errors.push(`${at} minimum`);
212
+ else if (high !== undefined && value > high) errors.push(`${at} maximum`);
213
+ }
214
+
215
+ function members(value, allowed, required, at, errors) {
216
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) errors.push(`${at}/${key} additionalProperties`);
217
+ for (const key of required) if (!Object.hasOwn(value, key)) errors.push(`${at || '/'} required ${key}`);
218
+ }
219
+
220
+ function timestamp(value, at, errors) {
221
+ if (!isString(value)) errors.push(`${at} type`);
222
+ else if (!dateTime(value)) errors.push(`${at} format`);
223
+ }
224
+
225
+ function percentiles(value, at, errors) {
226
+ if (!isObject(value)) {
227
+ errors.push(`${at} type`);
228
+ return;
229
+ }
230
+ members(value, ['p50', 'p95'], ['p50', 'p95'], at, errors);
231
+ for (const name of ['p50', 'p95']) if (Object.hasOwn(value, name)) integer(value[name], 0, undefined, `${at}/${name}`, errors);
232
+ }
233
+
234
+ function rowProblems(row, at, errors) {
235
+ if (!isObject(row)) {
236
+ errors.push(`${at} type`);
237
+ return;
238
+ }
239
+ members(row, ['context', ...ROW_COUNTS, 'waitMs', 'runMs'], ['context', ...ROW_COUNTS], at, errors);
240
+ if (Object.hasOwn(row, 'context') && !(isString(row.context) && CONTEXT.test(row.context))) errors.push(`${at}/context ${isString(row.context) ? 'pattern' : 'type'}`);
241
+ for (const name of ROW_COUNTS) if (Object.hasOwn(row, name)) integer(row[name], 0, undefined, `${at}/${name}`, errors);
242
+ for (const name of ['waitMs', 'runMs']) if (Object.hasOwn(row, name)) percentiles(row[name], `${at}/${name}`, errors);
243
+ }
244
+
245
+ /**
246
+ * service-load.schema.json: what makes `report` not a service-load/1 report, each as `<path> <keyword>`; empty for a
247
+ * valid one. Counts and durations only: a member the schema does not name (a caller, a job id) is refused.
248
+ * @param {unknown} report
249
+ * @returns {string[]}
250
+ */
251
+ export function loadReportProblems(report) {
252
+ if (!isObject(report)) return ['/ type'];
253
+ const errors = [];
254
+ const names = ['type', 'generatedAt', 'windowSeconds', 'windows'];
255
+ members(report, names, names, '', errors);
256
+ if (Object.hasOwn(report, 'type') && report.type !== LOAD_REPORT_TYPE) errors.push('/type const');
257
+ if (Object.hasOwn(report, 'generatedAt')) timestamp(report.generatedAt, '/generatedAt', errors);
258
+ if (Object.hasOwn(report, 'windowSeconds')) integer(report.windowSeconds, 60, 86400, '/windowSeconds', errors);
259
+ if (Object.hasOwn(report, 'windows') && !Array.isArray(report.windows)) errors.push('/windows type');
260
+ else if (Object.hasOwn(report, 'windows')) {
261
+ if (report.windows.length > 168) errors.push('/windows maxItems');
262
+ report.windows.forEach((window, index) => {
263
+ const at = `/windows/${index}`;
264
+ if (!isObject(window)) {
265
+ errors.push(`${at} type`);
266
+ return;
267
+ }
268
+ members(window, ['start', 'byContext'], ['start', 'byContext'], at, errors);
269
+ if (Object.hasOwn(window, 'start')) timestamp(window.start, `${at}/start`, errors);
270
+ if (Object.hasOwn(window, 'byContext') && !Array.isArray(window.byContext)) errors.push(`${at}/byContext type`);
271
+ else if (Object.hasOwn(window, 'byContext')) {
272
+ if (window.byContext.length > 64) errors.push(`${at}/byContext maxItems`);
273
+ window.byContext.forEach((row, rowIndex) => rowProblems(row, `${at}/byContext/${rowIndex}`, errors));
274
+ }
275
+ });
276
+ }
277
+ return errors;
278
+ }
279
+
280
+ /** service-load-extension.schema.json, each problem as `<path> <keyword>`; empty for a valid declaration. */
281
+ function declarationProblems(value) {
282
+ if (!isObject(value)) return ['/ type'];
283
+ const errors = [];
284
+ members(value, ['path', 'windowSeconds', 'limits'], ['path', 'windowSeconds', 'limits'], '', errors);
285
+ if (Object.hasOwn(value, 'path')) {
286
+ if (!isString(value.path)) errors.push('/path type');
287
+ else {
288
+ if (codePoints(value.path) > 256) errors.push('/path maxLength'); // the originPath's own 2048 is wider
289
+ if (!ORIGIN_PATH.test(value.path)) errors.push('/path pattern');
290
+ }
291
+ }
292
+ if (Object.hasOwn(value, 'windowSeconds')) integer(value.windowSeconds, 60, 86400, '/windowSeconds', errors);
293
+ if (Object.hasOwn(value, 'limits')) {
294
+ const { limits } = value;
295
+ if (!isObject(limits)) errors.push('/limits type');
296
+ else {
297
+ const names = LIMITS.map(([name]) => name);
298
+ members(limits, names, names, '/limits', errors);
299
+ for (const [name, low, high] of LIMITS) if (Object.hasOwn(limits, name)) integer(limits[name], low, high, `/limits/${name}`, errors);
300
+ }
301
+ }
302
+ return errors;
303
+ }
304
+
305
+ /**
306
+ * The load declaration as a reader reads it (DEC-0104): `declared` when the extension key is present, `valid` when its
307
+ * value is the schema's, `load` the declaration when valid (else null) and `errors` its problems. A reader treats an
308
+ * invalid value as not declared and reports it (FAC-SEM-133).
309
+ * @param {unknown} wellKnown
310
+ * @returns {LoadView}
311
+ */
312
+ export function loadView(wellKnown) {
313
+ const extensions = member(wellKnown, 'extensions');
314
+ if (!isObject(extensions) || !Object.hasOwn(extensions, LOAD_EXTENSION_KEY)) return { declared: false, valid: false, load: null, errors: [] };
315
+ const value = extensions[LOAD_EXTENSION_KEY];
316
+ const errors = declarationProblems(value);
317
+ return { declared: true, valid: errors.length === 0, load: errors.length === 0 ? value : null, errors };
318
+ }
319
+
320
+ /**
321
+ * FAC-SEM-133 on a well-known document: a value under the load extension key is valid against
322
+ * service-load-extension.schema.json; one finding at the key otherwise.
323
+ * @param {unknown} wellKnown
324
+ * @returns {Finding[]}
325
+ */
326
+ export function loadExtensionRules(wellKnown) {
327
+ const view = loadView(wellKnown);
328
+ if (!view.declared || view.valid) return [];
329
+ return [finding('FAC-SEM-133', KEY_PATH, `the load declaration is invalid (${view.errors.join('; ')}); a reader treats it as not declared and reports it (DEC-0104)`)];
330
+ }
331
+
332
+ /**
333
+ * The `extensions` entry a service declares (DEC-0104): where it serves its service-load/1 report (an origin path,
334
+ * served under the descriptor token like the usage report), the report's window and the queue's limits per
335
+ * (agentRef, context). Throws ServiceError on a declaration a reader would treat as not declared.
336
+ * @param {{ path: string, windowSeconds: number, limits: QueueLimits }} declaration
337
+ * @returns {Record<string, { path: string, windowSeconds: number, limits: QueueLimits }>}
338
+ */
339
+ export function loadExtension({ path, windowSeconds, limits } = {}) {
340
+ const value = { path, windowSeconds, limits: isObject(limits) ? { ...limits } : limits };
341
+ const problems = declarationProblems(value);
342
+ if (problems.length) throw new ServiceError(`Load declaration: ${problems.join('; ')}.`);
343
+ return { [LOAD_EXTENSION_KEY]: value };
344
+ }