@passioncode-ai/passioncode 0.2.39 → 0.2.40

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 (60) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +5 -2
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +8 -6
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -3
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +5 -2
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +60 -1
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +7 -2
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -2
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +35 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +22 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +47 -1
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +31 -2
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +14 -1
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +29 -6
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +29 -1
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +30 -2
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +40 -2
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -12
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +16 -2
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +99 -14
  46. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +32 -7
  47. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
  48. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
  49. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
  50. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  51. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +35 -0
  52. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
  53. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +302 -22
  54. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  55. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +5 -2
  56. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
  57. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +54 -9
  58. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +10 -0
  59. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  60. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -74,7 +74,9 @@ def login_path(path):
74
74
 
75
75
  def safe_path(path):
76
76
  return (isinstance(path, str) and path.startswith('/') and not path.startswith('//')
77
- and len(path) <= 2048 and not re.search(r'[\\\x00-\x1f\x7f]', path))
77
+ and len(path) <= 2048 and not re.search(r'[\\\x00-\x1f\x7f]', path)
78
+ # JavaScript's whitespace (\s), which the contract's parseDashboardsLink refuses in a page (DEC-0123)
79
+ and not re.search('[ \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]', path))
78
80
 
79
81
 
80
82
  def check(data):
@@ -87,11 +89,11 @@ def check(data):
87
89
  require(not re.search(r'[\\\x00-\x20\x7f]', native + http), 'unsafe_url')
88
90
  deep, plain = urlsplit(native), urlsplit(http)
89
91
  require(deep.scheme == 'fabric-dashboards' and not deep.username and not deep.password and not deep.port and not deep.fragment, 'invalid_deep_link')
90
- query = parse_qs(deep.query, keep_blank_values=True, strict_parsing=True)
91
- if deep.netloc == 'service':
92
- require(deep.path in ('/' + service, '/' + service + '/') and set(query) <= {'path'}, 'wrong_service_or_parameter')
93
- else:
94
- require(deep.netloc == 'open' and deep.path in ('', '/') and query.get('service') == [service] and set(query) <= {'service', 'path'}, 'invalid_legacy_link')
92
+ # An empty query is no fields: Python 3.9's strict parse_qs raises on '' where 3.11 and later answer {}.
93
+ query = parse_qs(deep.query, keep_blank_values=True, strict_parsing=True) if deep.query else {}
94
+ # DEC-0123 PL-1: only the service form; the legacy open?service= link is refused (FAC-SEM-143, since 0.51.0).
95
+ require(deep.netloc == 'service', 'not_service_link')
96
+ require(deep.path in ('/' + service, '/' + service + '/') and set(query) <= {'path'}, 'wrong_service_or_parameter')
95
97
  require('path' not in query or len(query['path']) == 1, 'duplicate_path')
96
98
  page = query.get('path', [None])[0]
97
99
  require(page is None or safe_path(page), 'unsafe_path')
@@ -82,6 +82,8 @@ from check_service_rules import same_repository # noqa: E402,F401 - moved (FAA-
82
82
  from check_service_rules import merge_headers, session_cookie # noqa: E402,F401 - FAA-115
83
83
  from check_service_rules import well_known_actions_verdict, action_tools_verdict, actions_declared_verdict # noqa: E402 - DEC-0121
84
84
  from check_service_rules import opens_in_host_verdict, service_checkout # noqa: E402 - FAA-118
85
+ from check_service_rules import well_known_a2a_verdict, a2a_card_verdict # noqa: E402 - DEC-0122
86
+ import fabric_a2a as fa2 # noqa: E402 - DEC-0122
85
87
 
86
88
 
87
89
  class _PinnedHTTPS(http.client.HTTPSConnection):
@@ -275,6 +277,8 @@ class Probe:
275
277
  "%d connection(s), %d named in degraded[]" % (len(view["connections"]), failing))
276
278
  # DEC-0121, FAC-SEM-142: every button's agent twin, declared; no declaration is NOT_RUN (D10).
277
279
  self.add("well-known.actions", *well_known_actions_verdict(wk))
280
+ # DEC-0122: where the public Agent Card is; no member is NOT_RUN.
281
+ self.add("well-known.a2a", *well_known_a2a_verdict(wk))
278
282
  self.configuration_rule()
279
283
  svc = wk.get("service") or {}
280
284
  answered = "%s.%s" % (svc.get("id"), svc.get("instance"))
@@ -422,6 +426,23 @@ class Probe:
422
426
  self.add("usage.report", "FAIL" if problems else "PASS",
423
427
  "; ".join(problems[:5]) if problems else "%d day(s), unknown cost kept null" % len(days or []))
424
428
 
429
+ # A2A (DEC-0122) ---------------------------------------------------------------
430
+ def a2a_rules(self) -> None:
431
+ """`a2a.card`: the public card read with no Authorization header and never the descriptor's token (D5); its
432
+ interfaces are never called. The verdict is check_service_rules.a2a_card_verdict."""
433
+ view = fa2.a2a_view(self.wk)
434
+ status, body, error = 0, b"", None
435
+ if view["valid"]:
436
+ # Non-ASCII and control characters the schema's path admits are sent percent-encoded.
437
+ target = urllib.parse.quote(view["path"], safe="".join(chr(c) for c in range(0x21, 0x7F)))
438
+ try:
439
+ status, _, body = self.request("GET", target) # Host only: no token header is added here
440
+ except (OSError, ssl.SSLError, http.client.HTTPException) as exc:
441
+ error = str(exc) or type(exc).__name__
442
+ header, _ = fs.token_header(self.d.get("auth"), "")
443
+ self.add("a2a.card", *a2a_card_verdict(self.wk, status, body, remote=self.remote, origin=str(self.d.get("origin") or ""),
444
+ descriptor_header=header, error=error))
445
+
425
446
  # dashboard (fabric-dashboard/0.1, DEC-0036) --------------------------------
426
447
  def dashboard_rules(self) -> None:
427
448
  wk = self.wk or {}
@@ -851,6 +872,7 @@ class Probe:
851
872
  self.network_rules()
852
873
  self.auth_rules()
853
874
  self.usage_rules()
875
+ self.a2a_rules()
854
876
  self.dashboard_rules()
855
877
  self.dashboard_page_rules()
856
878
  self.login_rules()
@@ -1,7 +1,7 @@
1
1
  """The probe's rules that read one thing and need no running service (FAA-65): a plist's marker and priority, the
2
2
  network guard's verdict, the board responder's fixture conversation, a session cookie's lifetime, a usage report,
3
3
  whether two repository URLs name one repository, a service's actions declaration (DEC-0121) and whether its checkout
4
- opens its pages in a browser (FAA-118). ``check_service.py`` runs them against a live service.
4
+ opens its pages in a browser (FAA-118), and its A2A member and public Agent Card (DEC-0122). ``check_service.py`` runs them against a live service.
5
5
  Standard library only, Python 3.9+."""
6
6
  from __future__ import annotations
7
7
 
@@ -19,6 +19,7 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
19
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
20
20
  import fabric_service as fs # noqa: E402 - beside this file
21
21
  import fabric_actions as fa # noqa: E402 - beside this file (DEC-0121)
22
+ import fabric_a2a as fa2 # noqa: E402 - beside this file (DEC-0122)
22
23
 
23
24
 
24
25
  def plist_problems(plist: dict, label: object, token: object = None) -> list:
@@ -369,6 +370,51 @@ def actions_declared_verdict(wk: Any) -> Tuple[str, str]:
369
370
  return "PASS", "the dashboard declares %d action(s) with their tools (DEC-0121 AP-1)" % len(view["actions"])
370
371
 
371
372
 
373
+
374
+ # A2A (DEC-0122; decisions D4 to D6 of the 0.51.0 brief) -------------------------------------------------------------
375
+ def well_known_a2a_verdict(wk: Any) -> Tuple[str, str]:
376
+ """`well-known.a2a`: a present `surfaces.a2a` is the schema's (`{path, protocolVersion: "1.0"}`, nothing else). No
377
+ member is NOT_RUN: the member is a SHOULD for a service that serves a card, and nothing says this one does."""
378
+ view = fa2.a2a_view(wk)
379
+ if not view["declared"]:
380
+ return "NOT_RUN", "no surfaces.a2a: the service names no A2A Agent Card (DEC-0122)"
381
+ if not view["valid"]:
382
+ return "FAIL", "; ".join("%s %s: %s" % (f["instancePath"], f["keyword"], f["message"]) for f in view["findings"][:5])
383
+ return "PASS", "the public Agent Card at %s, A2A %s" % (view["path"], view["protocolVersion"])
384
+
385
+
386
+ def a2a_card_verdict(wk: Any, status: int, body: bytes, *, remote: bool, origin: str, descriptor_header: str,
387
+ error: Optional[str] = None) -> Tuple[str, str]:
388
+ """`a2a.card`: the public card, fetched at `surfaces.a2a.path` with no Authorization header and no descriptor
389
+ token (D5; the caller sends none). On a local placement it answers 200 with a JSON card; on a remote one 401 or
390
+ 403 is a card that is not open, NOT_RUN, never a failure (DEC-0122 item 2: a host and a probe never report a
391
+ refusal as nonconformance). A card read is judged by fabric_a2a.agent_card_findings (D5, D6). The card's interfaces
392
+ are never called."""
393
+ view = fa2.a2a_view(wk)
394
+ if not view["declared"]:
395
+ return "NOT_RUN", "no surfaces.a2a: no card to read"
396
+ if not view["valid"]:
397
+ return "NOT_RUN", "surfaces.a2a is not readable (well-known.a2a): a host does not know where the card is"
398
+ where = "%s without the token" % view["path"]
399
+ if error is not None:
400
+ return "FAIL", "no answer at %s: %s" % (where, error)
401
+ if remote and status in (401, 403):
402
+ return "NOT_RUN", ("HTTP %d at %s: a card that is not open, gated by the service's own credentials or an "
403
+ "identity proxy; a refusal is not nonconformance (DEC-0122 item 2)" % (status, where))
404
+ if status != 200:
405
+ return "FAIL", ("HTTP %d at %s: %s" % (status, where, "the public card answers 200" if remote else
406
+ "on a local placement the public card answers without authentication (DEC-0122 item 2)"))
407
+ try:
408
+ card = json.loads(body)
409
+ except (ValueError, RecursionError):
410
+ return "FAIL", "the answer at %s is not JSON" % where
411
+ findings = fa2.agent_card_findings(card, origin=origin or None, remote=remote, descriptor_header=descriptor_header)
412
+ if findings:
413
+ return "FAIL", "; ".join("%s: %s" % (f["code"], f["message"]) for f in findings[:5]) + (
414
+ " (%d more)" % (len(findings) - 5) if len(findings) > 5 else "")
415
+ return "PASS", "%d interface(s), A2A %s, read at %s; no process member, secret or descriptor token scheme" % (
416
+ len(card["supportedInterfaces"]), fa2.A2A_PROTOCOL_VERSION, where)
417
+
372
418
  # dashboard.opens-in-host (FAA-118; the operator rule of 2026-10-24; decision D13) ----------------------------------
373
419
  # A service's own pages open only through Fabric Dashboards: the `open_link` of the fabric-dashboards MCP `link` tool,
374
420
  # opened with the host's `open` and `fallback=never`; a browser only on a confirmed `not_installed` host. The rule reads
@@ -0,0 +1,170 @@
1
+ // The reading side of an A2A Agent Card (DEC-0122): the well-known document's surfaces.a2a member, read as the contract's
2
+ // schema reads it (a2aSurfaceFindings, a2aView; a2aSurface builds one), and what a public card must not carry
3
+ // (agentCardFindings): no member about the running process, no secret, no securitySchemes entry naming the descriptor's
4
+ // token header, every Fabric extension required: false (or absent, which A2A reads as false), and interface URLs where the placement puts them. Serving a
5
+ // card, the A2A binding and a client are not here (FAA-10).
6
+ // Part of the Node service kit (layer L3); the twin of fabric_a2a.py. Node 20+, no dependency.
7
+ import { ServiceError } from './fabric-base.mjs';
8
+ import { isObject } from './fabric-jsvalue.mjs';
9
+ import { secretValue } from './fabric-secrets.mjs';
10
+
11
+ /** A2A 1.0 section 8.2: where a peer looks for the public card. */
12
+ export const A2A_SURFACE_PATH = '/.well-known/agent-card.json';
13
+ /** The A2A version the contract pins (the A2A peer profile); surfaces.a2a.protocolVersion is this constant. */
14
+ export const A2A_PROTOCOL_VERSION = '1.0';
15
+ /** The contract's extension namespace (decision D6): an extension whose uri starts here is a Fabric fact. */
16
+ export const FABRIC_EXTENSION_PREFIX = 'https://fabric.passioncode.ai/';
17
+ /** The well-known document's members about the running process, which a public card never carries (DEC-0122 item 2). */
18
+ export const PROCESS_MEMBERS = Object.freeze(['build', 'process', 'pid', 'status', 'summary', 'degraded']);
19
+ export const MAX_PATH = 2048; // service-common.schema.json originPath maxLength, counted in code points as ajv counts it
20
+
21
+ // The schema's originPath pattern, under the `u` flag as ajv compiles it.
22
+ const ORIGIN_PATH = /^\/(?!\/)[^\s]*$/u;
23
+ const HTTPS = /^https:\/\//i;
24
+ const MEMBERS = ['path', 'protocolVersion'];
25
+
26
+ const pointer = (...parts) => parts.map((p) => '/' + String(p).replaceAll('~', '~0').replaceAll('/', '~1')).join('');
27
+ const asciiLower = (text) => text.replace(/[A-Z]/g, (c) => c.toLowerCase());
28
+ const finding = (instancePath, keyword, message) => ({ instancePath, keyword, message });
29
+
30
+ /** surfaces.a2a against service-well-known.schema.json at the pin, in ajv's order: each finding's instancePath and keyword
31
+ * are the schema error's (test/fixtures/contract/a2a-vectors.json), with a sentence. */
32
+ export function a2aSurfaceFindings(value, base = '/surfaces/a2a') {
33
+ if (!isObject(value)) return [finding(base, 'type', 'surfaces.a2a is an object {path, protocolVersion}')];
34
+ const found = MEMBERS.filter((k) => !Object.hasOwn(value, k)).map((k) => finding(base, 'required', `surfaces.a2a has no ${k}`));
35
+ for (const k of Object.keys(value)) {
36
+ if (!MEMBERS.includes(k)) found.push(finding(base, 'additionalProperties', `surfaces.a2a has no member ${k}: the card names its interfaces, bindings and security schemes (DEC-0122)`));
37
+ }
38
+ if (Object.hasOwn(value, 'path')) {
39
+ const path = value.path;
40
+ if (typeof path !== 'string') found.push(finding(`${base}/path`, 'type', 'surfaces.a2a.path is a string'));
41
+ else {
42
+ if ([...path].length > MAX_PATH) found.push(finding(`${base}/path`, 'maxLength', `surfaces.a2a.path is longer than ${MAX_PATH} characters`));
43
+ if (!ORIGIN_PATH.test(path)) found.push(finding(`${base}/path`, 'pattern', 'surfaces.a2a.path is not a path on the service origin (one leading /, no whitespace)'));
44
+ }
45
+ }
46
+ if (Object.hasOwn(value, 'protocolVersion') && value.protocolVersion !== A2A_PROTOCOL_VERSION) {
47
+ found.push(finding(`${base}/protocolVersion`, 'const', `surfaces.a2a.protocolVersion is ${A2A_PROTOCOL_VERSION}, the A2A version the contract pins`));
48
+ }
49
+ return found;
50
+ }
51
+
52
+ /** The findings of a2aSurfaceFindings as sentences; empty when the member is valid. */
53
+ export const a2aSurfaceProblems = (value) => a2aSurfaceFindings(value).map((f) => f.message);
54
+
55
+ /** The surfaces.a2a member for a service that serves its public card at `path` (DEC-0122 item 1). */
56
+ export function a2aSurface(path = A2A_SURFACE_PATH) {
57
+ const surface = { path, protocolVersion: A2A_PROTOCOL_VERSION };
58
+ const problems = a2aSurfaceProblems(surface);
59
+ if (problems.length) throw new ServiceError(`surfaces.a2a: ${problems.join('; ')}.`);
60
+ return surface;
61
+ }
62
+
63
+ /** What a host reads of a well-known document's A2A member; never throws. `declared` is whether surfaces.a2a is present,
64
+ * `valid` whether it is the schema's, `path` the card's path when it is, `findings` the schema's. */
65
+ export function a2aView(wk) {
66
+ const surfaces = isObject(wk) ? wk.surfaces : undefined;
67
+ if (!isObject(surfaces) || !Object.hasOwn(surfaces, 'a2a')) return { declared: false, valid: false, path: null, protocolVersion: null, findings: [] };
68
+ const findings = a2aSurfaceFindings(surfaces.a2a);
69
+ const valid = findings.length === 0;
70
+ return { declared: true, valid, path: valid ? surfaces.a2a.path : null, protocolVersion: valid ? A2A_PROTOCOL_VERSION : null, findings };
71
+ }
72
+
73
+ const card = (code, instancePath, message) => ({ code, instancePath, message });
74
+
75
+ // Every string the card holds that is shaped like a credential, by its place; walked without recursion, in the order
76
+ // JavaScript lists keys.
77
+ function secrets(root) {
78
+ const found = [];
79
+ const stack = [[root, '']];
80
+ while (stack.length) {
81
+ const [node, path] = stack.pop();
82
+ if (typeof node === 'string') {
83
+ if (secretValue(node)) found.push(card('secret', path, `${path || 'the card'} holds a value shaped like a credential: a public card carries no secret (DEC-0122 item 2)`));
84
+ } else if (Array.isArray(node)) {
85
+ for (let i = node.length - 1; i >= 0; i -= 1) stack.push([node[i], path + pointer(i)]);
86
+ } else if (isObject(node)) {
87
+ const keys = Object.keys(node);
88
+ for (let i = keys.length - 1; i >= 0; i -= 1) stack.push([node[keys[i]], path + pointer(keys[i])]);
89
+ }
90
+ }
91
+ return found;
92
+ }
93
+
94
+ // D5: a securitySchemes entry that is an API key in the descriptor's own header. With Authorization (the default) the
95
+ // card cannot show the token, so nothing is reported.
96
+ function descriptorSchemes(root, header) {
97
+ const schemes = root.securitySchemes;
98
+ if (!isObject(schemes) || !header || asciiLower(header) === 'authorization') return [];
99
+ const found = [];
100
+ for (const name of Object.keys(schemes)) {
101
+ const scheme = schemes[name];
102
+ if (!isObject(scheme)) continue;
103
+ let location;
104
+ let named;
105
+ if (isObject(scheme.apiKeySecurityScheme)) ({ location, name: named } = scheme.apiKeySecurityScheme); // A2A 1.0
106
+ else if (scheme.type === 'apiKey') ({ in: location, name: named } = scheme); // the OpenAPI shape of A2A before 1.0
107
+ else continue;
108
+ if (location === 'header' && typeof named === 'string' && asciiLower(named) === asciiLower(header)) {
109
+ found.push(card('descriptor-token-scheme', pointer('securitySchemes', name), `securitySchemes.${name} is an API key in ${header}, the descriptor's token header: an A2A interface never takes the descriptor's token (DEC-0122 item 3)`));
110
+ }
111
+ }
112
+ return found;
113
+ }
114
+
115
+ // D6: an extension under the contract's namespace is not required (A2A 1.0 section 4.4.4): `required` false or absent,
116
+ // which A2A reads as false (a protobuf-written card drops a false default); true or a non-boolean is a finding.
117
+ function fabricExtensions(root) {
118
+ const extensions = isObject(root.capabilities) ? root.capabilities.extensions : undefined;
119
+ if (!Array.isArray(extensions)) return [];
120
+ const found = [];
121
+ extensions.forEach((extension, i) => {
122
+ const uri = isObject(extension) ? extension.uri : undefined;
123
+ if (typeof uri !== 'string' || asciiLower(uri.slice(0, FABRIC_EXTENSION_PREFIX.length)) !== FABRIC_EXTENSION_PREFIX) return;
124
+ if (Object.hasOwn(extension, 'required') && extension.required !== false) {
125
+ found.push(card('fabric-extension-required', pointer('capabilities', 'extensions', i), `the Fabric extension at capabilities.extensions[${i}] is required, so a peer that does not know it cannot call; it carries required: false (DEC-0122 item 4)`));
126
+ }
127
+ });
128
+ return found;
129
+ }
130
+
131
+ // Where the card's interfaces are (DEC-0122 item 2): on a local placement on the service's loopback origin as written, on
132
+ // a remote one https; every one A2A 1.0.
133
+ function interfaces(root, origin, remote) {
134
+ const list = root.supportedInterfaces;
135
+ if (!Array.isArray(list) || list.length === 0) return [card('interfaces-missing', '/supportedInterfaces', "the card names no supportedInterfaces: A2A 1.0 lists a card's interfaces there")];
136
+ const found = [];
137
+ list.forEach((entry, i) => {
138
+ const place = pointer('supportedInterfaces', i);
139
+ const url = isObject(entry) ? entry.url : undefined;
140
+ if (typeof url !== 'string') {
141
+ found.push(card('interface-shape', place, `supportedInterfaces[${i}] is not an interface with a url`));
142
+ return;
143
+ }
144
+ if (remote) {
145
+ if (!HTTPS.test(url)) found.push(card('interface-scheme', `${place}/url`, `supportedInterfaces[${i}].url is not https: a loopback or plain-http interface is admitted on a local placement only`));
146
+ } else if (origin && !(url === origin || url.startsWith(`${origin}/`))) {
147
+ found.push(card('interface-origin', `${place}/url`, `supportedInterfaces[${i}].url is not on the service origin ${origin}, where a peer on this computer reaches it`));
148
+ }
149
+ if (entry.protocolVersion !== A2A_PROTOCOL_VERSION) {
150
+ found.push(card('interface-version', `${place}/protocolVersion`, `supportedInterfaces[${i}] does not expose A2A ${A2A_PROTOCOL_VERSION}, the version surfaces.a2a names`));
151
+ }
152
+ });
153
+ return found;
154
+ }
155
+
156
+ /** What a public Agent Card must not carry or say (DEC-0122 items 2 to 4; decisions D5, D6), each finding
157
+ * {code, instancePath, message} naming the place and never the value. `origin` is the service origin the card was read
158
+ * on (its interfaces' place on a local placement; omitted, the place is not judged), `remote` the placement,
159
+ * `descriptorHeader` the descriptor's auth.header (default Authorization). It reads the card only: whether an interface
160
+ * accepts the token cannot be seen without sending it, which DEC-0122 forbids. */
161
+ export function agentCardFindings(value, { origin = null, remote = false, descriptorHeader = 'Authorization' } = {}) {
162
+ if (!isObject(value)) return [card('card-shape', '', 'the card is not a JSON object')];
163
+ const found = PROCESS_MEMBERS.filter((k) => Object.hasOwn(value, k))
164
+ .map((k) => card('process-member', pointer(k), `the card carries ${k}, a member about the running process: a public card carries none (DEC-0122 item 2)`));
165
+ found.push(...secrets(value));
166
+ found.push(...descriptorSchemes(value, typeof descriptorHeader === 'string' ? descriptorHeader : ''));
167
+ found.push(...fabricExtensions(value));
168
+ found.push(...interfaces(value, origin, remote));
169
+ return found;
170
+ }
@@ -13,12 +13,15 @@
13
13
  // refused when decision.schema.json would refuse it.
14
14
  // - actionCaller(tier, authenticated): the caller a service records (AP-2): the identity it authenticated, else
15
15
  // local:T0 / local:T1 for a caller its local floor authenticated, never a name the call carries.
16
+ // - DEC-0125: actionDecision reads a call's mandate (_meta.mandate) through fabric-mandate.mjs's mandateCovers when the
17
+ // call carries no decision.
16
18
  //
17
19
  // The kit has no JSON Schema validator: the two shapes are written here from the schemas, lengths in code points and
18
20
  // patterns over the whole string, as the contract's validator reads them. Members are an object's own members (a JSON
19
21
  // text has no other kind; an inherited member is not read). Never throws on what it reads. Node 20+, no dependency.
20
22
  import { ServiceError } from './fabric-base.mjs';
21
23
  import { isObject, jsonEqual } from './fabric-jsvalue.mjs';
24
+ import { mandateCovers } from './fabric-mandate.mjs'; // DEC-0125
22
25
  import { formatRfc3339, parseRfc3339Ms } from './fabric-time.mjs';
23
26
 
24
27
  export const ACTIONS_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/actions/0.1';
@@ -113,8 +116,12 @@ const refuse = (code) => ({ ok: false, code });
113
116
  * accepted approval is the approver's; a T2 caller without one gets approval-required with the digest; an accepted
114
117
  * elicitation is the operator's; no decision is operator-decision-required; a decision that is not the schema's, is for
115
118
  * another action or object, or whose `at` is more than 60 s ahead of `now` or more than 24 h behind it is
116
- * decision-invalid; otherwise the operator's act, recorded with `caller`. `{fault}` (action-invalid, caller-missing,
117
- * now-unreadable, digest-missing, url-missing) is the service's own and is never sent to a caller.
119
+ * decision-invalid; otherwise the operator's act, recorded with `caller`. DEC-0125: a call with no decision that carries
120
+ * `mandate` (its `_meta.mandate`) is answered as mandateCovers answers, given `self` (the service's own agent id),
121
+ * `callerAgent` (only an agent id the service authenticated), `effect` (the action's effect class, the service's own
122
+ * knowledge), `spendUsd` and `project`: covered, {by: 'operator', via: 'mandate', task, chain, caller}. `{fault}`
123
+ * (action-invalid, caller-missing, now-unreadable, digest-missing, url-missing, effect-missing, self-missing) is the
124
+ * service's own and is never sent to a caller.
118
125
  */
119
126
  export function actionDecision(inputs) {
120
127
  const field = (key) => member(inputs, key);
@@ -140,6 +147,7 @@ export function actionDecision(inputs) {
140
147
  const clock = parseRfc3339Ms(field('now'));
141
148
  if (Number.isNaN(clock)) return { fault: 'now-unreadable' };
142
149
  const decision = field('decision');
150
+ if (decision === undefined && field('mandate') !== undefined) return byMandate(field, action, caller);
143
151
  if (decision === undefined) return refuse(OPERATOR_DECISION_REQUIRED);
144
152
  if (!decisionShape(decision)) return refuse(DECISION_INVALID);
145
153
  if (decision.action !== action.id || (has(decision, 'target') && decision.target !== field('target'))) return refuse(DECISION_INVALID);
@@ -148,6 +156,27 @@ export function actionDecision(inputs) {
148
156
  return { ok: true, recordAs: { by: 'operator', via: 'relayed-instruction', caller } };
149
157
  }
150
158
 
159
+ /**
160
+ * A relayed action's call that carries a mandate and no decision (DEC-0125 MD-3): the mandate decides, as the contract's
161
+ * byMandate reads it: the service's own `effect` and agent id `self` are required (else a fault), its estimate
162
+ * `spendUsd` is read when a number and `project` when a string.
163
+ */
164
+ function byMandate(field, action, caller) {
165
+ const effect = field('effect');
166
+ const self = field('self');
167
+ if (typeof effect !== 'string') return { fault: 'effect-missing' };
168
+ if (typeof self !== 'string') return { fault: 'self-missing' };
169
+ const spendUsd = field('spendUsd');
170
+ const project = field('project');
171
+ const answer = mandateCovers({
172
+ mandate: field('mandate'), tier: field('tier'), now: field('now'), self,
173
+ ...(field('callerAgent') === undefined ? {} : { callerAgent: field('callerAgent') }),
174
+ call: { capability: action.tool, action: action.id, effect, ...(typeof spendUsd === 'number' ? { spendUsd } : {}), ...(typeof project === 'string' ? { project } : {}) },
175
+ });
176
+ if ('recordAs' in answer) return { ok: true, recordAs: { ...answer.recordAs, caller } };
177
+ return answer; // never `ignored`: the tier was read first, so a T2 call answered approval-required before (MD-5)
178
+ }
179
+
151
180
  /**
152
181
  * The host reader of extensions/actions/0.1 (DEC-0121 AP-1), as the contract's actionsView: {declared: false},
153
182
  * {declared: true, valid: false, errors} or {declared: true, valid: true, actions}. Never throws.
@@ -0,0 +1,151 @@
1
+ // One instance per context (FAA-125, organization row N-11; contract DEC-0082, "One instance per context on a machine"):
2
+ // from a context, `personal` or `org:<orgId>`, the service instance, its folders, descriptor and instance lock.
3
+ // Part of the Node service kit (layer L3, after fabric-descriptor); the twin of fabric_context.py, answer for answer
4
+ // (test/fixtures/kit-context-cases.json). Node built-ins only. The facade fabric-service.mjs does not re-export it.
5
+ // A refusal exits 78 (EX_CONFIG) with nothing created; a second copy for the same context exits 75 as holdSingleInstance does.
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { INSTANCE, ServiceError } from './fabric-base.mjs';
10
+ import { descriptorPath } from './fabric-descriptor.mjs';
11
+ import { parseContext } from './fabric-estate.mjs';
12
+ import { InstanceLock, holdSingleInstance } from './fabric-lock.mjs';
13
+ import { atomicWrite, instanceDirs, servicesDir } from './fabric-paths.mjs';
14
+ import { instanceShareGrant } from './fabric-share.mjs';
15
+ import { clockMs } from './fabric-time.mjs';
16
+
17
+ /** EX_CONFIG: the context is refused; 75 stays "already running" (EXIT_ALREADY_RUNNING). */
18
+ export const EXIT_CONTEXT_REFUSED = 78;
19
+ export const CONTEXT_MARKER = 'fabric-context.json';
20
+ const READABLE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
+ const HASHED_TAIL = /-[0-9a-f]{10}$/;
22
+ const READABLE_MAX = 28;
23
+ const SLUG_MAX = 17;
24
+ /** What each refusal tells the person; the sentence never carries a record or grant value. */
25
+ const REASONS = Object.freeze({
26
+ 'context-invalid': 'the context is neither personal nor org:<orgId> as written',
27
+ 'record-required': "an organization's instance needs this machine's estate record, its machine id and its agentRef",
28
+ 'placement-missing': 'the estate record places no such agent on this machine in that context',
29
+ 'grant-refused': 'its placement names no unrevoked share grant for that context (FAC-SEM-111)',
30
+ 'grant-window': "now is outside its share grant's window",
31
+ 'context-mismatch': `its data folder belongs to another context or instance (${CONTEXT_MARKER})`,
32
+ });
33
+
34
+ /** A context this machine does not run; `code` is one of REASONS' keys. */
35
+ export class ContextRefused extends ServiceError {
36
+ constructor(code, detail = '') {
37
+ super(`${code}: ${detail || REASONS[code]}.`);
38
+ this.code = code;
39
+ this.detail = detail;
40
+ }
41
+ }
42
+
43
+ const shown = (context) => (parseContext(context) ? context : 'a context that is neither personal nor org:<orgId>');
44
+
45
+ /**
46
+ * The instance name of `context` (brief D15): `personal` is `personalInstance` (`default`); `org:<orgId>` is
47
+ * `org-<orgId>` when readable (lower-case words joined by single hyphens, at most 28 characters, not ending in a hyphen
48
+ * and 10 hex digits), else `org-<slug>-<first 10 hex digits of sha256(orgId)>`. Throws ContextRefused.
49
+ */
50
+ export function contextInstanceName(context, { personalInstance = 'default' } = {}) {
51
+ const parsed = parseContext(context);
52
+ if (!parsed) throw new ContextRefused('context-invalid');
53
+ if (parsed.kind === 'personal') {
54
+ if (typeof personalInstance !== 'string' || !INSTANCE.test(personalInstance) || personalInstance.startsWith('org-')) {
55
+ throw new ContextRefused('context-invalid', `the personal instance ${JSON.stringify(personalInstance)} is no instance name outside org-`);
56
+ }
57
+ return personalInstance;
58
+ }
59
+ const { org } = parsed;
60
+ if (READABLE.test(org) && org.length <= READABLE_MAX && !HASHED_TAIL.test(org)) return `org-${org}`;
61
+ const trim = (text) => text.replace(/^-+|-+$/g, '');
62
+ const slug = trim(trim(org.toLowerCase().replace(/[^a-z0-9]+/g, '-')).slice(0, SLUG_MAX)) || 'x';
63
+ return `org-${slug}-${crypto.createHash('sha256').update(org, 'utf8').digest('hex').slice(0, 10)}`;
64
+ }
65
+
66
+ /**
67
+ * What `context` runs as on this machine; pure (no folder, file or lock). Answers {context, kind, org?, instance, key,
68
+ * dirs: {data, logs, cache}, descriptorPath, grant?: {id, startsAt?, expiresAt?}}, never the record. `record` is the
69
+ * verified revision this machine applied; `machineId` and `agentRef` come from whoever installed the instance; `at` is
70
+ * the clock (a Date or epoch seconds; now when undefined). Throws ContextRefused.
71
+ */
72
+ export function contextInstance(id, context, {
73
+ record, machineId, agentRef, at, personalInstance = 'default', servicesDirectory, platform = process.platform,
74
+ } = {}) {
75
+ const instance = contextInstanceName(context, { personalInstance });
76
+ const parsed = parseContext(context);
77
+ const answer = {
78
+ context, kind: parsed.kind, instance, key: `${id}.${instance}`, dirs: instanceDirs(id, instance, platform),
79
+ descriptorPath: descriptorPath(id, instance, servicesDirectory ?? servicesDir(platform)),
80
+ };
81
+ const havePlace = record !== null && typeof record === 'object' && !Array.isArray(record) && typeof machineId === 'string' && typeof agentRef === 'string';
82
+ const atMs = at === undefined || at === null ? Date.now() : clockMs(at);
83
+ if (parsed.kind === 'personal') {
84
+ if (havePlace) { // brief D20: a personal placement that names a grant is refused; no placement is no refusal
85
+ const checked = instanceShareGrant(record, machineId, agentRef, context, atMs);
86
+ if (!checked.ok && checked.code === 'grant-refused') throw new ContextRefused('grant-refused', checked.detail);
87
+ }
88
+ return answer;
89
+ }
90
+ answer.org = parsed.org;
91
+ if (!havePlace) throw new ContextRefused('record-required');
92
+ const checked = instanceShareGrant(record, machineId, agentRef, context, atMs);
93
+ if (!checked.ok) throw new ContextRefused(checked.code, checked.detail);
94
+ answer.grant = checked.grant;
95
+ return answer;
96
+ }
97
+
98
+ function markerMatches(file, expected) {
99
+ let text;
100
+ try {
101
+ text = fs.readFileSync(file, 'utf8');
102
+ } catch (error) {
103
+ return error.code === 'ENOENT' ? undefined : false;
104
+ }
105
+ let found;
106
+ try { found = JSON.parse(text); } catch { return false; }
107
+ return found !== null && typeof found === 'object' && Object.entries(expected).every(([key, value]) => found[key] === value);
108
+ }
109
+
110
+ /**
111
+ * Run as `context`'s one instance, before any side effect (brief D17): contextInstance (a refusal writes one sentence
112
+ * naming the code and exits 78, nothing created); then holdSingleInstance on the instance's data folder (its markers,
113
+ * its supervised wait, its exit 75); then `<data>/fabric-context.json`: absent, written (0600, atomic); equal, kept;
114
+ * another context or instance, or unreadable, releases the lock and exits 78 (`context-mismatch`). Resolves to the
115
+ * instance with its held `lock`; `exit` is injectable as holdSingleInstance has it, and its answer is returned.
116
+ */
117
+ export async function holdContextInstance(id, context, {
118
+ record, machineId, agentRef, at, personalInstance = 'default', servicesDirectory, platform = process.platform,
119
+ waitSeconds, sleep, clock, exit = (code) => process.exit(code), env, argv,
120
+ } = {}) {
121
+ const refuse = (code) => {
122
+ process.stderr.write(`${id} will not run for ${shown(context)} (${code}): ${REASONS[code]}.\n`);
123
+ return exit(EXIT_CONTEXT_REFUSED);
124
+ };
125
+ let info;
126
+ try {
127
+ info = contextInstance(id, context, { record, machineId, agentRef, at, personalInstance, servicesDirectory, platform });
128
+ } catch (error) {
129
+ if (error instanceof ContextRefused) return refuse(error.code);
130
+ throw error;
131
+ }
132
+ const lock = await holdSingleInstance(info.dirs.data, {
133
+ waitSeconds, exit, ...(sleep ? { sleep } : {}), ...(clock ? { clock } : {}), ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }),
134
+ });
135
+ if (!(lock instanceof InstanceLock)) return lock; // an injected exit answered 75
136
+ const expected = { service: id, instance: info.instance, context };
137
+ const marker = path.join(info.dirs.data, CONTEXT_MARKER);
138
+ let matches;
139
+ try {
140
+ matches = markerMatches(marker, expected);
141
+ if (matches === undefined) atomicWrite(marker, `${JSON.stringify(expected, null, 1)}\n`, 0o600);
142
+ } catch (error) {
143
+ lock.release();
144
+ throw error;
145
+ }
146
+ if (matches === false) {
147
+ lock.release();
148
+ return refuse('context-mismatch');
149
+ }
150
+ return { ...info, lock };
151
+ }
@@ -15,6 +15,7 @@ import fs from 'node:fs';
15
15
  import net from 'node:net';
16
16
  import { hubKeyCertified } from './fabric-estate.mjs';
17
17
  import { atomicWrite } from './fabric-paths.mjs'; // the kit's private writer (FAA-85)
18
+ import { effectClosure } from './fabric-mandate.mjs'; // defined once beside the mandate that reads it too (DEC-0125)
18
19
 
19
20
  export const REFUSAL_CODES = Object.freeze([
20
21
  'unauthenticated', 'token-expired', 'wrong-audience', 'scope-not-granted', 'effect-above-ceiling', 'no-grant',
@@ -57,7 +58,10 @@ export const REFUSAL_MESSAGES = Object.freeze({
57
58
  'issuer-unavailable': "The estate's published keys cannot be read.",
58
59
  'unknown-state': 'The state cannot be established, so the call is refused.',
59
60
  // DEC-0121 (AP-2, AP-4): the refusals of an action call; each answers 403.
60
- 'operator-decision-required': "This action is the operator's decision; ask the operator and call again with it in _meta.decision.",
61
+ 'operator-decision-required': "This action is the operator's decision; ask the operator and call again with it in "
62
+ + "_meta.decision. A client that cannot set _meta: Fabric's agent.call with call.decision, once the hub forwards it; "
63
+ + "else this service's own elicitation, where the client shows one; else the action's page handed to the operator as "
64
+ + "Fabric Dashboards' open_link.",
61
65
  'decision-invalid': "The call's decision is not valid for this action, object or time.",
62
66
  'human-step-required': "This step is the person's own; hand them the link in error.data.url.",
63
67
  });
@@ -201,13 +205,8 @@ function chainOf(act) {
201
205
  return chain;
202
206
  }
203
207
 
204
- /** `none` is below every class and `draft` below every external one; external classes are not ordered. */
205
- export function effectClosure(ceiling) {
206
- const admitted = new Set(ceiling);
207
- if (ceiling.length > 0) admitted.add('none');
208
- if (ceiling.some((effect) => effect !== 'none')) admitted.add('draft');
209
- return admitted;
210
- }
208
+ /** `none` is below every class and `draft` below every external one: defined once in fabric-mandate.mjs (DEC-0125). */
209
+ export { effectClosure };
211
210
 
212
211
  export const effectWithin = (effect, ceiling) => effectClosure(ceiling).has(effect);
213
212
 
@@ -511,7 +510,16 @@ export function refusalFromAction(answer) {
511
510
  }
512
511
 
513
512
  /** The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key. */
514
- export const refusalError = (refusal) => ({ error: { code: refusal.code, message: REFUSAL_MESSAGES[refusal.code] ?? 'Refused.', data: { detail: refusal.detail, ...(refusal.data ?? {}) } } });
513
+ /** DEC-0125 MD-4: a refusal outside a mandate goes back up the chain; only the leading agent asks the operator. */
514
+ export const OUTSIDE_MANDATE_MESSAGE = 'Outside the operator\'s mandate for this call. Call again within your own mandate if it covers this part; else pass this refusal to the agent that called you. Only the leading agent asks the operator, once, about this part alone, then relays a decision or mandate that covers it: in _meta, or, without _meta, through Fabric\'s agent.call once the hub forwards it, the service\'s elicitation, or the action\'s page as a Fabric Dashboards open_link (DEC-0125 MD-4).';
515
+
516
+ export function refusalError(refusal) {
517
+ // the gate's answer keeps no-decision at the top and its outside-mandate part in data.detail; only a string names a part
518
+ const part = typeof refusal.data?.detail === 'string' ? refusal.data.detail : refusal.detail;
519
+ const outside = refusal.code === 'operator-decision-required' && typeof part === 'string' && part.startsWith('outside-mandate:');
520
+ const message = outside ? OUTSIDE_MANDATE_MESSAGE : REFUSAL_MESSAGES[refusal.code] ?? 'Refused.';
521
+ return { error: { code: refusal.code, message, data: { detail: refusal.detail, ...(refusal.data ?? {}) } } };
522
+ }
515
523
 
516
524
  /** An MCP tools/call result: the refusal as structuredContent with isError true (DL-19). */
517
525
  export function refusalToolResult(refusal, traceparent) {
@@ -8,6 +8,7 @@ import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
8
8
  import { protocolsFindings } from './fabric-protocols.mjs';
9
9
  import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
10
10
  import { ACTIONS_EXTENSION_KEY, actionsExtension, actionsExtensionRules } from './fabric-actions.mjs';
11
+ import { a2aSurfaceProblems } from './fabric-a2a.mjs';
11
12
  import { ROUTES } from './fabric-usage.mjs';
12
13
 
13
14
  // The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
@@ -382,9 +383,21 @@ export function configurationProblems(doc) {
382
383
  // that performs it and its gate, written under extensions/actions/0.1 by actionsExtension. A declaration FAC-SEM-142
383
384
  // finds, passed either way, is refused: not its schema's, an id named twice, a humanStep that disagrees with the gate,
384
385
  // or a tool surfaces.mcp.capabilities does not list.
385
- export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols, actions }) {
386
+ // `a2a` (DEC-0122) is surfaces.a2a, where the service serves its public Agent Card (a2aSurface builds one); passed here
387
+ // or in `surfaces`, never both, and refused when it is not the schema's. A reader pinned before DEC-0122 refuses the
388
+ // member, so a service carries it only once its readers are pinned at or after it.
389
+ export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols, actions, a2a }) {
386
390
  if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
387
391
  if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
392
+ if (a2a !== undefined && a2a !== null) {
393
+ if (Object.hasOwn(surfaces, 'a2a')) throw new ServiceError('surfaces.a2a: pass it in surfaces or as a2a, not both.');
394
+ surfaces = { ...surfaces, a2a };
395
+ }
396
+ if (Object.hasOwn(surfaces, 'a2a')) {
397
+ // DEC-0122: a host reads the member by the schema; a service never publishes one it would refuse.
398
+ const problems = a2aSurfaceProblems(surfaces.a2a);
399
+ if (problems.length) throw new ServiceError(`surfaces.a2a: ${problems.join('; ')}.`);
400
+ }
388
401
  if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
389
402
  const doc = {
390
403
  protocol: PROTOCOL,