@passioncode-ai/passioncode 0.2.39 → 0.2.41

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 (66) hide show
  1. package/CHANGELOG.md +15 -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/hooks/hooks.json +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +5 -2
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  11. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +8 -6
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -3
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +5 -2
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +60 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +7 -2
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -2
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +35 -0
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +22 -0
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +47 -1
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +31 -2
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +14 -1
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +29 -6
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +29 -1
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +30 -2
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +40 -2
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -12
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +16 -2
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
  46. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +99 -14
  47. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +32 -7
  48. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
  49. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
  50. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
  51. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  52. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +35 -0
  53. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
  54. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +388 -22
  55. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  56. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +5 -2
  57. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
  58. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +56 -10
  59. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +14 -3
  60. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +12 -7
  61. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +8 -0
  62. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -9
  63. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +60 -1
  64. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +10 -3
  65. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +39 -4
  66. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
8
8
  import { DRAIN_SECONDS, SUPERVISOR_ENV, ServiceError, TASK, UNIT } from './fabric-base.mjs';
9
- import { atomicWrite, serviceDirs } from './fabric-paths.mjs';
9
+ import { atomicWrite, instanceDirs } from './fabric-paths.mjs';
10
10
  import { SUPERVISOR_ARG, markerArguments } from './fabric-lock.mjs';
11
11
  import { URI } from './fabric-backups.mjs';
12
12
 
@@ -370,14 +370,15 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
370
370
  /**
371
371
  * Symmetric with launchdInstall (LC-14): bootout and WAIT until the job is gone, remove the plist, and reset the
372
372
  * override to enabled — launchctl has no verb that deletes an override, and a leftover `disabled` would keep a later
373
- * fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go). A job still
373
+ * fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go, and `instance` which
374
+ * instance's, so one context's purge never reaches another's, FAA-125). A job still
374
375
  * loaded at `timeout` seconds throws, leaving the plist and the data in place.
375
376
  */
376
- export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
377
+ export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, instance = 'default', run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
377
378
  const domain = launchdDomain(platform, uid);
378
379
  launchdLabel(label);
379
380
  if (purge && !serviceId) throw new ServiceError('purge needs the service id whose data, logs and cache to remove.');
380
- const dirs = purge ? serviceDirs(serviceId, platform) : {};
381
+ const dirs = purge ? instanceDirs(serviceId, instance, platform) : {}; // one instance's folders only (FAA-125)
381
382
  const opts = { run, platform, uid };
382
383
  const target = `${domain}/${label}`;
383
384
  run(['launchctl', 'bootout', target]);
@@ -0,0 +1,209 @@
1
+ """The reading side of an A2A Agent Card (DEC-0122): the well-known document's `surfaces.a2a` member, read as the
2
+ contract's schema reads it (`a2a_surface_findings`, `a2a_view`; `a2a_surface` builds one), and what a public card must
3
+ not carry (`agent_card_findings`): no member about the running process, no secret, no `securitySchemes` entry naming
4
+ the descriptor's token header, every Fabric extension `required: false` (or absent, which A2A reads as false), and interface URLs where the placement puts
5
+ them. Serving a card, the A2A binding and a client are not here (FAA-10). Part of the service kit (layer L3).
6
+ Standard library only."""
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import sys as _sys # noqa: E402
13
+ from pathlib import Path as _Path # noqa: E402
14
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
15
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
16
+
17
+ from fabric_base import ServiceError # noqa: E402
18
+ from fabric_jsvalue import js_keys # noqa: E402
19
+ from fabric_secrets import secret_value # noqa: E402
20
+
21
+ #: A2A 1.0 section 8.2: where a peer looks for the public card.
22
+ A2A_SURFACE_PATH = "/.well-known/agent-card.json"
23
+ #: The A2A version the contract pins (the A2A peer profile); `surfaces.a2a.protocolVersion` is this constant.
24
+ A2A_PROTOCOL_VERSION = "1.0"
25
+ #: The contract's extension namespace (decision D6): an extension whose uri starts here is a Fabric fact.
26
+ FABRIC_EXTENSION_PREFIX = "https://fabric.passioncode.ai/"
27
+ #: The well-known document's members about the running process, which a public card never carries (DEC-0122 item 2).
28
+ PROCESS_MEMBERS = ("build", "process", "pid", "status", "summary", "degraded")
29
+ MAX_PATH = 2048 # service-common.schema.json originPath maxLength, counted in code points as ajv counts it
30
+
31
+ # JavaScript's \s (the schema's pattern runs under ajv), not Python's: U+FEFF is one, U+001C and U+0085 are not.
32
+ _JS_SPACE = ("\t\n\x0b\x0c\r              "
33
+ "

   ")
34
+ _ORIGIN_PATH = re.compile("^/(?!/)[^%s]*\\Z" % _JS_SPACE)
35
+ _HTTPS = re.compile(r"^https://", re.IGNORECASE)
36
+
37
+
38
+ def _pointer(*parts: Any) -> str:
39
+ return "".join("/" + str(p).replace("~", "~0").replace("/", "~1") for p in parts)
40
+
41
+
42
+ def _ascii_lower(text: str) -> str:
43
+ return re.sub("[A-Z]", lambda m: m.group(0).lower(), text)
44
+
45
+
46
+ def _finding(path: str, keyword: str, message: str) -> Dict[str, str]:
47
+ return {"instancePath": path, "keyword": keyword, "message": message}
48
+
49
+
50
+ def a2a_surface_findings(value: Any, base: str = "/surfaces/a2a") -> List[Dict[str, str]]:
51
+ """`surfaces.a2a` against service-well-known.schema.json at the pin, in ajv's order: each finding's instancePath
52
+ and keyword are the schema error's (test/fixtures/contract/a2a-vectors.json), with a sentence."""
53
+ if not isinstance(value, dict):
54
+ return [_finding(base, "type", "surfaces.a2a is an object {path, protocolVersion}")]
55
+ found = [_finding(base, "required", "surfaces.a2a has no %s" % k) for k in ("path", "protocolVersion") if k not in value]
56
+ found += [_finding(base, "additionalProperties", "surfaces.a2a has no member %s: the card names its interfaces, "
57
+ "bindings and security schemes (DEC-0122)" % k)
58
+ for k in value if k not in ("path", "protocolVersion")]
59
+ if "path" in value:
60
+ path = value["path"]
61
+ if not isinstance(path, str):
62
+ found.append(_finding(base + "/path", "type", "surfaces.a2a.path is a string"))
63
+ else:
64
+ if len(path) > MAX_PATH:
65
+ found.append(_finding(base + "/path", "maxLength", "surfaces.a2a.path is longer than %d characters" % MAX_PATH))
66
+ if not _ORIGIN_PATH.match(path):
67
+ found.append(_finding(base + "/path", "pattern", "surfaces.a2a.path is not a path on the service origin "
68
+ "(one leading /, no whitespace)"))
69
+ if "protocolVersion" in value and not (isinstance(value["protocolVersion"], str) and value["protocolVersion"] == A2A_PROTOCOL_VERSION):
70
+ found.append(_finding(base + "/protocolVersion", "const", "surfaces.a2a.protocolVersion is %s, the A2A version the "
71
+ "contract pins" % A2A_PROTOCOL_VERSION))
72
+ return found
73
+
74
+
75
+ def a2a_surface_problems(value: Any) -> List[str]:
76
+ """The findings of a2a_surface_findings as sentences; empty when the member is valid."""
77
+ return [f["message"] for f in a2a_surface_findings(value)]
78
+
79
+
80
+ def a2a_surface(path: str = A2A_SURFACE_PATH) -> Dict[str, str]:
81
+ """The `surfaces.a2a` member for a service that serves its public card at `path` (DEC-0122 item 1)."""
82
+ surface = {"path": path, "protocolVersion": A2A_PROTOCOL_VERSION}
83
+ problems = a2a_surface_problems(surface)
84
+ if problems:
85
+ raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
86
+ return surface
87
+
88
+
89
+ def a2a_view(wk: Any) -> Dict[str, Any]:
90
+ """What a host reads of a well-known document's A2A member; never raises. `declared` is whether `surfaces.a2a`
91
+ is present, `valid` whether it is the schema's, `path` the card's path when it is, `findings` the schema's."""
92
+ surfaces = wk.get("surfaces") if isinstance(wk, dict) else None
93
+ if not isinstance(surfaces, dict) or "a2a" not in surfaces:
94
+ return {"declared": False, "valid": False, "path": None, "protocolVersion": None, "findings": []}
95
+ findings = a2a_surface_findings(surfaces["a2a"])
96
+ valid = not findings
97
+ return {"declared": True, "valid": valid, "path": surfaces["a2a"]["path"] if valid else None,
98
+ "protocolVersion": A2A_PROTOCOL_VERSION if valid else None, "findings": findings}
99
+
100
+
101
+ def _card(code: str, path: str, message: str) -> Dict[str, str]:
102
+ return {"code": code, "instancePath": path, "message": message}
103
+
104
+
105
+ def _secrets(card: Dict[str, Any]) -> List[Dict[str, str]]:
106
+ """Every string the card holds that is shaped like a credential, by its place; walked without recursion, in the
107
+ order JavaScript lists keys, so both twins name the same places in the same order."""
108
+ found: List[Dict[str, str]] = []
109
+ stack: List[Any] = [(card, "")]
110
+ while stack:
111
+ node, path = stack.pop()
112
+ if isinstance(node, str):
113
+ if secret_value(node):
114
+ found.append(_card("secret", path, "%s holds a value shaped like a credential: a public card carries no "
115
+ "secret (DEC-0122 item 2)" % (path or "the card")))
116
+ elif isinstance(node, dict):
117
+ stack.extend((node[k], path + _pointer(k)) for k in reversed(js_keys(node)))
118
+ elif isinstance(node, list):
119
+ stack.extend((node[i], path + _pointer(i)) for i in reversed(range(len(node))))
120
+ return found
121
+
122
+
123
+ def _descriptor_schemes(card: Dict[str, Any], header: str) -> List[Dict[str, str]]:
124
+ """D5: a securitySchemes entry that is an API key in the descriptor's own header. With Authorization (the
125
+ default) the card cannot show the token, so nothing is reported."""
126
+ schemes = card.get("securitySchemes")
127
+ if not isinstance(schemes, dict) or not header or _ascii_lower(header) == "authorization":
128
+ return []
129
+ found = []
130
+ for name in js_keys(schemes):
131
+ scheme = schemes[name]
132
+ if not isinstance(scheme, dict):
133
+ continue
134
+ key = scheme.get("apiKeySecurityScheme") # A2A 1.0: one wrapper member per kind
135
+ if isinstance(key, dict):
136
+ location, named = key.get("location"), key.get("name")
137
+ elif scheme.get("type") == "apiKey": # the OpenAPI shape of A2A before 1.0
138
+ location, named = scheme.get("in"), scheme.get("name")
139
+ else:
140
+ continue
141
+ if location == "header" and isinstance(named, str) and _ascii_lower(named) == _ascii_lower(header):
142
+ found.append(_card("descriptor-token-scheme", _pointer("securitySchemes", name),
143
+ "securitySchemes.%s is an API key in %s, the descriptor's token header: an A2A interface "
144
+ "never takes the descriptor's token (DEC-0122 item 3)" % (name, header)))
145
+ return found
146
+
147
+
148
+ def _fabric_extensions(card: Dict[str, Any]) -> List[Dict[str, str]]:
149
+ """D6: an extension under the contract's namespace is not required (A2A 1.0 section 4.4.4): `required` false or
150
+ absent, which A2A reads as false (a protobuf-written card drops a false default); true or a non-boolean is a finding."""
151
+ capabilities = card.get("capabilities")
152
+ extensions = capabilities.get("extensions") if isinstance(capabilities, dict) else None
153
+ if not isinstance(extensions, list):
154
+ return []
155
+ found = []
156
+ for i, extension in enumerate(extensions):
157
+ uri = extension.get("uri") if isinstance(extension, dict) else None
158
+ if not isinstance(uri, str) or _ascii_lower(uri[:len(FABRIC_EXTENSION_PREFIX)]) != FABRIC_EXTENSION_PREFIX:
159
+ continue
160
+ if "required" in extension and extension["required"] is not False:
161
+ found.append(_card("fabric-extension-required", _pointer("capabilities", "extensions", i),
162
+ "the Fabric extension at capabilities.extensions[%d] is required, so a peer that does not "
163
+ "know it cannot call; it carries required: false (DEC-0122 item 4)" % i))
164
+ return found
165
+
166
+
167
+ def _interfaces(card: Dict[str, Any], origin: Optional[str], remote: bool) -> List[Dict[str, str]]:
168
+ """Where the card's interfaces are (DEC-0122 item 2): on a local placement on the service's loopback origin as
169
+ written, on a remote one https; every one A2A 1.0."""
170
+ interfaces = card.get("supportedInterfaces")
171
+ if not isinstance(interfaces, list) or not interfaces:
172
+ return [_card("interfaces-missing", "/supportedInterfaces", "the card names no supportedInterfaces: A2A 1.0 "
173
+ "lists a card's interfaces there")]
174
+ found = []
175
+ for i, interface in enumerate(interfaces):
176
+ place = _pointer("supportedInterfaces", i)
177
+ url = interface.get("url") if isinstance(interface, dict) else None
178
+ if not isinstance(url, str):
179
+ found.append(_card("interface-shape", place, "supportedInterfaces[%d] is not an interface with a url" % i))
180
+ continue
181
+ if remote:
182
+ if not _HTTPS.match(url):
183
+ found.append(_card("interface-scheme", place + "/url", "supportedInterfaces[%d].url is not https: a "
184
+ "loopback or plain-http interface is admitted on a local placement only" % i))
185
+ elif origin and not (url == origin or url.startswith(origin + "/")):
186
+ found.append(_card("interface-origin", place + "/url", "supportedInterfaces[%d].url is not on the service "
187
+ "origin %s, where a peer on this computer reaches it" % (i, origin)))
188
+ if not (isinstance(interface.get("protocolVersion"), str) and interface["protocolVersion"] == A2A_PROTOCOL_VERSION):
189
+ found.append(_card("interface-version", place + "/protocolVersion", "supportedInterfaces[%d] does not expose "
190
+ "A2A %s, the version surfaces.a2a names" % (i, A2A_PROTOCOL_VERSION)))
191
+ return found
192
+
193
+
194
+ def agent_card_findings(card: Any, *, origin: Optional[str] = None, remote: bool = False,
195
+ descriptor_header: Optional[str] = "Authorization") -> List[Dict[str, str]]:
196
+ """What a public Agent Card must not carry or say (DEC-0122 items 2 to 4; decisions D5, D6), each finding
197
+ {code, instancePath, message} naming the place and never the value. `origin` is the service origin the card was
198
+ read on (its interfaces' place on a local placement; omitted, the place is not judged), `remote` the placement,
199
+ `descriptor_header` the descriptor's `auth.header` (default Authorization). It reads the card only: whether an
200
+ interface accepts the token cannot be seen without sending it, which DEC-0122 forbids."""
201
+ if not isinstance(card, dict):
202
+ return [_card("card-shape", "", "the card is not a JSON object")]
203
+ found = [_card("process-member", _pointer(k), "the card carries %s, a member about the running process: a public "
204
+ "card carries none (DEC-0122 item 2)" % k) for k in PROCESS_MEMBERS if k in card]
205
+ found += _secrets(card)
206
+ found += _descriptor_schemes(card, descriptor_header if isinstance(descriptor_header, str) else "")
207
+ found += _fabric_extensions(card)
208
+ found += _interfaces(card, origin, remote)
209
+ return found
@@ -14,6 +14,8 @@ extensions/actions/0.1 (DEC-0121, FAC-SEM-142). Part of the service kit (layer L
14
14
  refused when ``decision.schema.json`` would refuse it.
15
15
  - ``action_caller(tier, authenticated)``: the caller a service records (AP-2): the identity it authenticated, else
16
16
  ``local:T0`` / ``local:T1`` for a caller its local floor authenticated, never a name the call carries.
17
+ - DEC-0125: ``action_decision`` reads a call's mandate (``_meta.mandate``) through fabric_mandate.mandate_covers when the
18
+ call carries no decision.
17
19
 
18
20
  The kit has no JSON Schema validator: the two shapes are written here from the schemas, lengths in code points and
19
21
  patterns over the whole string, as the contract's validator reads them. Never raises on what it reads. Standard library
@@ -32,7 +34,8 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
32
34
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
33
35
 
34
36
  from fabric_base import ServiceError # noqa: E402
35
- from fabric_jsvalue import MISSING, contains, get, json_equal, json_stringify # noqa: E402
37
+ from fabric_jsvalue import MISSING, contains, get, is_number, json_equal, json_stringify # noqa: E402
38
+ from fabric_mandate import mandate_covers # noqa: E402 - DEC-0125
36
39
  from fabric_time import format_rfc3339, parse_rfc3339_ms # noqa: E402
37
40
 
38
41
  ACTIONS_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/actions/0.1"
@@ -152,7 +155,13 @@ def action_decision(inputs: Any) -> Dict[str, Any]:
152
155
  digest; an accepted elicitation is the operator's; no decision is `operator-decision-required`; a decision that
153
156
  is not the schema's, is for another action or object, or whose `at` is more than 60 s ahead of `now` or more
154
157
  than 24 h behind it is `decision-invalid`; otherwise the operator's act, recorded with `caller`.
155
- - `{"fault": ...}` (action-invalid, caller-missing, now-unreadable, digest-missing, url-missing) is the service's
158
+ - DEC-0125: a call with no decision that carries `mandate` (its `_meta.mandate`) is answered as
159
+ fabric_mandate.mandate_covers answers, given `self` (the service's own agent id), `callerAgent` (only an agent id
160
+ the service authenticated), `effect` (the action's effect class, the service's own knowledge), `spendUsd` (its
161
+ own estimate) and `project`: covered, `{by: operator, via: mandate, task, chain, caller}`; otherwise
162
+ `operator-decision-required` or `decision-invalid` with `data.detail`.
163
+ - `{"fault": ...}` (action-invalid, caller-missing, now-unreadable, digest-missing, url-missing, effect-missing,
164
+ self-missing) is the service's
156
165
  own: an input it owes is missing or unreadable. It is never sent to a caller.
157
166
  """
158
167
  action = get(inputs, "action")
@@ -182,6 +191,8 @@ def action_decision(inputs: Any) -> Dict[str, Any]:
182
191
  if math.isnan(clock):
183
192
  return {"fault": "now-unreadable"}
184
193
  decision = get(inputs, "decision")
194
+ if decision is MISSING and get(inputs, "mandate") is not MISSING:
195
+ return _by_mandate(inputs, action, caller)
185
196
  if decision is MISSING:
186
197
  return _refuse(OPERATOR_DECISION_REQUIRED)
187
198
  if not decision_shape(decision):
@@ -195,6 +206,33 @@ def action_decision(inputs: Any) -> Dict[str, Any]:
195
206
  return {"ok": True, "recordAs": {"by": "operator", "via": "relayed-instruction", "caller": caller}}
196
207
 
197
208
 
209
+ def _by_mandate(inputs: Any, action: Dict[str, Any], caller: str) -> Dict[str, Any]:
210
+ """A relayed action's call that carries a mandate and no decision (DEC-0125 MD-3): the mandate decides, as the
211
+ contract's byMandate reads it: the service's own `effect` and agent id `self` are required (else a fault), its
212
+ estimate `spendUsd` is read when a number and `project` when a string."""
213
+ effect, me = get(inputs, "effect"), get(inputs, "self")
214
+ if not isinstance(effect, str):
215
+ return {"fault": "effect-missing"}
216
+ if not isinstance(me, str):
217
+ return {"fault": "self-missing"}
218
+ call: Dict[str, Any] = {"capability": action["tool"], "action": action["id"], "effect": effect}
219
+ spend, project = get(inputs, "spendUsd"), get(inputs, "project")
220
+ if is_number(spend):
221
+ call["spendUsd"] = spend
222
+ if isinstance(project, str):
223
+ call["project"] = project
224
+ covers: Dict[str, Any] = {"mandate": inputs["mandate"], "tier": get(inputs, "tier"), "now": get(inputs, "now"),
225
+ "self": me, "call": call}
226
+ if get(inputs, "callerAgent") is not MISSING:
227
+ covers["callerAgent"] = inputs["callerAgent"]
228
+ answer = mandate_covers(covers)
229
+ if "recordAs" in answer:
230
+ record = dict(answer["recordAs"])
231
+ record["caller"] = caller
232
+ return {"ok": True, "recordAs": record}
233
+ return answer # never `ignored`: the tier was read first, so a T2 call answered approval-required before (MD-5)
234
+
235
+
198
236
  def actions_view(well_known: Any) -> Dict[str, Any]:
199
237
  """The host reader of extensions/actions/0.1 (DEC-0121 AP-1), as the contract's actionsView: `{declared: False}`,
200
238
  `{declared: True, valid: False, errors}` or `{declared: True, valid: True, actions}`. Never raises."""
@@ -0,0 +1,170 @@
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. Part of
3
+ the service kit (layer L3, after fabric_descriptor); the twin of fabric-context.mjs. Standard library only.
4
+
5
+ A service started for a context runs as that context's one instance on this machine: its own instance name, descriptor,
6
+ data, log and cache folders and instance lock; a second copy for the same context exits 75 as ``hold_single_instance``
7
+ does; an organization's instance runs only under a placement of this machine's estate record that names an unrevoked
8
+ share grant for that context (FAC-SEM-111), inside the grant's window; a data folder never changes context (its
9
+ ``fabric-context.json`` marker). A refusal exits 78 (EX_CONFIG) with nothing created. The facade ``fabric_service`` does
10
+ not re-export this module, so a service that does not run per context keeps the estate reader out of its closure:
11
+ ``fabric_kit.py install <dir> --module fabric_context``. Docs: references/estate-record.md (Contexts)."""
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import re
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any, Callable, Dict, Optional, Sequence, Tuple
21
+
22
+ import sys as _sys # noqa: E402
23
+ from pathlib import Path as _Path # noqa: E402
24
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
25
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
26
+ from fabric_base import ServiceError, _INSTANCE # noqa: E402
27
+ from fabric_descriptor import descriptor_path # noqa: E402
28
+ from fabric_estate import parse_context # noqa: E402
29
+ from fabric_lock import InstanceLock, hold_single_instance # noqa: E402
30
+ from fabric_paths import atomic_write, instance_dirs # noqa: E402
31
+ from fabric_share import instance_share_grant # noqa: E402
32
+ from fabric_time import clock_ms # noqa: E402
33
+
34
+ #: EX_CONFIG: the context is refused; 75 stays "already running" (``fabric_lock.EXIT_ALREADY_RUNNING``).
35
+ EXIT_CONTEXT_REFUSED = 78
36
+ CONTEXT_MARKER = "fabric-context.json"
37
+ #: An organization id kept readable in the instance name: lower-case words joined by single hyphens (brief D15).
38
+ _READABLE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*\Z")
39
+ #: Only a hashed name ends in a hyphen and 10 hex digits, so a readable and a hashed name never meet.
40
+ _HASHED_TAIL = re.compile(r"-[0-9a-f]{10}\Z")
41
+ _READABLE_MAX = 28
42
+ _SLUG_MAX = 17
43
+ #: What each refusal tells the person; the sentence never carries a record or grant value.
44
+ _REASONS = {
45
+ "context-invalid": "the context is neither personal nor org:<orgId> as written",
46
+ "record-required": "an organization's instance needs this machine's estate record, its machine id and its agentRef",
47
+ "placement-missing": "the estate record places no such agent on this machine in that context",
48
+ "grant-refused": "its placement names no unrevoked share grant for that context (FAC-SEM-111)",
49
+ "grant-window": "now is outside its share grant's window",
50
+ "context-mismatch": "its data folder belongs to another context or instance (%s)" % CONTEXT_MARKER,
51
+ }
52
+
53
+
54
+ class ContextRefused(ServiceError):
55
+ """A context this machine does not run. ``code``: context-invalid, record-required, placement-missing,
56
+ grant-refused, grant-window or context-mismatch."""
57
+
58
+ def __init__(self, code: str, detail: str = ""):
59
+ self.code = code
60
+ self.detail = detail
61
+ super().__init__("%s: %s." % (code, detail or _REASONS[code]))
62
+
63
+
64
+ def _shown(context: Any) -> str:
65
+ return context if parse_context(context) else "a context that is neither personal nor org:<orgId>"
66
+
67
+
68
+ def context_instance_name(context: Any, *, personal_instance: str = "default") -> str:
69
+ """The instance name of ``context`` (brief D15): ``personal`` is ``personal_instance`` (``default``, so an existing
70
+ install keeps its descriptor, folders and lock); ``org:<orgId>`` is ``org-<orgId>`` when the id is lower-case words
71
+ joined by single hyphens, at most 28 characters, and does not end in a hyphen and 10 hex digits; otherwise
72
+ ``org-<slug>-<first 10 hex digits of sha256(orgId)>``. Every name is a descriptor's instance. Raises ContextRefused."""
73
+ parsed = parse_context(context)
74
+ if parsed is None:
75
+ raise ContextRefused("context-invalid")
76
+ if parsed["kind"] == "personal":
77
+ if not isinstance(personal_instance, str) or not _INSTANCE.match(personal_instance) or personal_instance.startswith("org-"):
78
+ raise ContextRefused("context-invalid", "the personal instance %r is no instance name outside org-" % (personal_instance,))
79
+ return personal_instance
80
+ org = parsed["org"]
81
+ if _READABLE.match(org) and len(org) <= _READABLE_MAX and not _HASHED_TAIL.search(org):
82
+ return "org-" + org
83
+ slug = re.sub(r"[^a-z0-9]+", "-", org.lower()).strip("-")[:_SLUG_MAX].strip("-") or "x"
84
+ return "org-%s-%s" % (slug, hashlib.sha256(org.encode("utf-8")).hexdigest()[:10])
85
+
86
+
87
+ def context_instance(service_id: str, context: Any, *, record: Optional[Dict[str, Any]] = None,
88
+ machine_id: Optional[str] = None, agent_ref: Optional[str] = None, at: Any = None,
89
+ personal_instance: str = "default", services_directory: Optional[Path] = None) -> Dict[str, Any]:
90
+ """What ``context`` runs as on this machine; pure (no folder, file or lock). Answers ``{"context", "kind":
91
+ "personal"|"org", "org"?, "instance", "key": "<id>.<instance>", "dirs": {"data", "logs", "cache"},
92
+ "descriptorPath", "grant"?: {"id", "startsAt"?, "expiresAt"?}}``, never the record. Raises ContextRefused.
93
+
94
+ ``record`` is the estate record revision this machine applied, already verified (``verify_estate_record``); the
95
+ helper reads it and verifies no signature. ``machine_id`` and ``agent_ref`` come from whoever installed the
96
+ instance, never guessed. ``at`` is the clock (an aware datetime or epoch seconds; now when None)."""
97
+ instance = context_instance_name(context, personal_instance=personal_instance)
98
+ parsed = parse_context(context)
99
+ dirs = instance_dirs(service_id, instance)
100
+ answer: Dict[str, Any] = {"context": context, "kind": parsed["kind"], "instance": instance,
101
+ "key": "%s.%s" % (service_id, instance), "dirs": dirs,
102
+ "descriptorPath": descriptor_path(service_id, instance, services_directory)}
103
+ have_place = isinstance(record, dict) and isinstance(machine_id, str) and isinstance(agent_ref, str)
104
+ at_ms = time.time() * 1000.0 if at is None else clock_ms(at)
105
+ if parsed["kind"] == "personal":
106
+ if have_place: # brief D20: a personal placement that names a grant is refused; no placement is no refusal
107
+ checked = instance_share_grant(record, machine_id, agent_ref, context, at_ms)
108
+ if not checked["ok"] and checked["code"] == "grant-refused":
109
+ raise ContextRefused("grant-refused", checked["detail"])
110
+ return answer
111
+ answer["org"] = parsed["org"]
112
+ if not have_place:
113
+ raise ContextRefused("record-required")
114
+ checked = instance_share_grant(record, machine_id, agent_ref, context, at_ms)
115
+ if not checked["ok"]:
116
+ raise ContextRefused(checked["code"], checked["detail"])
117
+ answer["grant"] = checked["grant"]
118
+ return answer
119
+
120
+
121
+ def _refuse(service_id: Any, context: Any, code: str) -> None:
122
+ print("%s will not run for %s (%s): %s." % (service_id, _shown(context), code, _REASONS[code]), file=sys.stderr, flush=True)
123
+ raise SystemExit(EXIT_CONTEXT_REFUSED)
124
+
125
+
126
+ def _marker_matches(path: Path, expected: Dict[str, str]) -> Optional[bool]:
127
+ """True when the marker names this instance, False when it names another or cannot be read, None when absent."""
128
+ try:
129
+ text = path.read_text(encoding="utf-8")
130
+ except FileNotFoundError:
131
+ return None
132
+ except OSError:
133
+ return False
134
+ try:
135
+ found = json.loads(text)
136
+ except ValueError:
137
+ return False
138
+ return isinstance(found, dict) and all(found.get(key) == value for key, value in expected.items())
139
+
140
+
141
+ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dict[str, Any]] = None,
142
+ machine_id: Optional[str] = None, agent_ref: Optional[str] = None, at: Any = None,
143
+ personal_instance: str = "default", wait_seconds: Optional[float] = None,
144
+ sleep: Callable[[float], None] = time.sleep, clock: Callable[[], float] = time.monotonic,
145
+ env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None,
146
+ services_directory: Optional[Path] = None) -> Tuple[Dict[str, Any], InstanceLock]:
147
+ """Run as ``context``'s one instance, before any side effect (brief D17): ``context_instance`` (a refusal prints one
148
+ sentence naming the code and exits 78, nothing created); then ``hold_single_instance`` on the instance's data
149
+ folder (its markers, its supervised wait, its exit 75); then the marker ``<data>/fabric-context.json``: absent, it
150
+ is written (0600, atomic); equal, it is kept; another context or instance, or unreadable, releases the lock and
151
+ exits 78 (``context-mismatch``). Returns the instance and the held lock."""
152
+ try:
153
+ info = context_instance(service_id, context, record=record, machine_id=machine_id, agent_ref=agent_ref, at=at,
154
+ personal_instance=personal_instance, services_directory=services_directory)
155
+ except ContextRefused as refused:
156
+ _refuse(service_id, context, refused.code)
157
+ lock = hold_single_instance(info["dirs"]["data"], wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
158
+ expected = {"service": service_id, "instance": info["instance"], "context": context}
159
+ marker = Path(info["dirs"]["data"]) / CONTEXT_MARKER
160
+ try:
161
+ matches = _marker_matches(marker, expected)
162
+ if matches is None:
163
+ atomic_write(marker, (json.dumps(expected, indent=1) + "\n").encode("utf-8"), mode=0o600)
164
+ except BaseException:
165
+ lock.release()
166
+ raise
167
+ if matches is False:
168
+ lock.release()
169
+ _refuse(service_id, context, "context-mismatch")
170
+ return info, lock
@@ -45,6 +45,7 @@ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
45
45
  jcs,
46
46
  )
47
47
  from fabric_paths import atomic_write as _atomic_write # noqa: E402 - the kit's private writer (FAA-85)
48
+ from fabric_mandate import effect_closure # noqa: E402,F401 - defined once beside the mandate that reads it too (DEC-0125)
48
49
 
49
50
  REFUSAL_CODES = (
50
51
  "unauthenticated", "token-expired", "wrong-audience", "scope-not-granted", "effect-above-ceiling", "no-grant",
@@ -93,7 +94,11 @@ REFUSAL_MESSAGES = {
93
94
  "issuer-unavailable": "The estate's published keys cannot be read.",
94
95
  "unknown-state": "The state cannot be established, so the call is refused.",
95
96
  # DEC-0121 (AP-2, AP-4): the refusals of an action call; each answers 403.
96
- "operator-decision-required": "This action is the operator's decision; ask the operator and call again with it in _meta.decision.",
97
+ "operator-decision-required": ("This action is the operator's decision; ask the operator and call again with it in "
98
+ "_meta.decision. A client that cannot set _meta: Fabric's agent.call with "
99
+ "call.decision, once the hub forwards it; else this service's own elicitation, "
100
+ "where the client shows one; else the action's page handed to the operator as "
101
+ "Fabric Dashboards' open_link."),
97
102
  "decision-invalid": "The call's decision is not valid for this action, object or time.",
98
103
  "human-step-required": "This step is the person's own; hand them the link in error.data.url.",
99
104
  }
@@ -299,16 +304,6 @@ def _chain_of(act: Dict[str, Any]) -> List[str]:
299
304
  return chain
300
305
 
301
306
 
302
- def effect_closure(ceiling: List[str]) -> set:
303
- """`none` is below every class and `draft` below every external one; external classes are not ordered."""
304
- admitted = set(ceiling)
305
- if ceiling:
306
- admitted.add("none")
307
- if any(effect != "none" for effect in ceiling):
308
- admitted.add("draft")
309
- return admitted
310
-
311
-
312
307
  def effect_within(effect: str, ceiling: List[str]) -> bool:
313
308
  return effect in effect_closure(ceiling)
314
309
 
@@ -822,11 +817,19 @@ def refusal_from_action(answer: Any) -> Optional[Dict[str, Any]]:
822
817
  return _refuse(code, _ACTION_DETAILS[code], dict(data) if isinstance(data, dict) and data else None)
823
818
 
824
819
 
820
+ #: DEC-0125 MD-4: a refusal outside a mandate goes back up the chain; only the leading agent asks the operator.
821
+ 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).")
822
+
823
+
825
824
  def refusal_error(refusal: Dict[str, Any]) -> Dict[str, Any]:
826
825
  """The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key."""
827
826
  data = {"detail": refusal["detail"]}
828
827
  data.update(refusal.get("data") or {})
829
- return {"error": {"code": refusal["code"], "message": REFUSAL_MESSAGES.get(refusal["code"], "Refused."), "data": data}}
828
+ # the gate's answer keeps no-decision at the top and its outside-mandate part in data.detail; only a string names a part
829
+ part = data.get("detail") if isinstance(data.get("detail"), str) else refusal["detail"]
830
+ outside = refusal["code"] == "operator-decision-required" and isinstance(part, str) and part.startswith("outside-mandate:")
831
+ message = OUTSIDE_MANDATE_MESSAGE if outside else REFUSAL_MESSAGES.get(refusal["code"], "Refused.")
832
+ return {"error": {"code": refusal["code"], "message": message, "data": data}}
830
833
 
831
834
 
832
835
  def refusal_tool_result(refusal: Dict[str, Any], traceparent: Optional[str] = None) -> Dict[str, Any]:
@@ -37,6 +37,7 @@ from fabric_protocols import (
37
37
  )
38
38
  from fabric_connections import CONNECTIONS_EXTENSION_KEY, connections_view # noqa: E402 - the kit (FAA-62)
39
39
  from fabric_actions import ACTIONS_EXTENSION_KEY, actions_extension, actions_extension_rules # noqa: E402 - DEC-0121
40
+ from fabric_a2a import a2a_surface_problems # noqa: E402 - DEC-0122
40
41
  from fabric_usage import (
41
42
  ROUTES,
42
43
  )
@@ -579,7 +580,8 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
579
580
  extensions: Optional[Dict[str, Any]] = None,
580
581
  configuration: Optional[Dict[str, Any]] = None,
581
582
  protocols: Optional[Sequence[str]] = None,
582
- actions: Optional[Sequence[Dict[str, Any]]] = None) -> Dict[str, Any]:
583
+ actions: Optional[Sequence[Dict[str, Any]]] = None,
584
+ a2a: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
583
585
  """The unauthenticated identity answer. `degraded` is always present; ready means empty.
584
586
  `configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
585
587
  `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
@@ -587,7 +589,10 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
587
589
  `actions` (DEC-0121 AP-1) is the service's action table, every button its dashboard, app or page offers with the
588
590
  tool that performs it and its gate; it is written under extensions/actions/0.1 by fabric_actions.actions_extension.
589
591
  A declaration FAC-SEM-142 finds, passed either way, is refused: one that is not its schema's, an id named twice, a
590
- humanStep that disagrees with the gate, or a tool `surfaces.mcp.capabilities` does not list."""
592
+ humanStep that disagrees with the gate, or a tool `surfaces.mcp.capabilities` does not list.
593
+ `a2a` (DEC-0122) is `surfaces.a2a`, where the service serves its public Agent Card (fabric_a2a.a2a_surface builds
594
+ one); passed here or in `surfaces`, never both, and refused when it is not the schema's. A reader pinned before
595
+ DEC-0122 refuses the member, so a service carries it only once its readers are pinned at or after it."""
591
596
  if status not in STATUSES:
592
597
  raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
593
598
  degraded = list(degraded)
@@ -595,6 +600,15 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
595
600
  status = "degraded"
596
601
  if "events" not in surfaces:
597
602
  raise ServiceError("surfaces.events is required.")
603
+ if a2a is not None:
604
+ if "a2a" in surfaces:
605
+ raise ServiceError("surfaces.a2a: pass it in surfaces or as a2a, not both.")
606
+ surfaces = dict(surfaces, a2a=a2a)
607
+ if "a2a" in surfaces:
608
+ # DEC-0122: a host reads the member by the schema; a service never publishes one it would refuse.
609
+ problems = a2a_surface_problems(surfaces["a2a"])
610
+ if problems:
611
+ raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
598
612
  doc: Dict[str, Any] = {
599
613
  "protocol": PROTOCOL,
600
614
  "service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
@@ -776,6 +776,13 @@ def _placement_grant_problems(record: Dict[str, Any], placement: Dict[str, Any])
776
776
  return problems
777
777
 
778
778
 
779
+ def placement_grant_findings(record: Any, placement: Any) -> List[Dict[str, str]]:
780
+ """FAC-SEM-111 for one placement of ``record``, as findings (``instancePath`` relative to the placement). Answers,
781
+ never raises: a record or placement that is no object reads as an empty one (FAA-125)."""
782
+ record, placement = (v if isinstance(v, dict) else {} for v in (record, placement))
783
+ return [{"code": "FAC-SEM-111", "instancePath": at, "message": m} for at, m in _placement_grant_problems(record, placement)]
784
+
785
+
779
786
  def _placement_grants(value: Dict[str, Any], flag) -> None:
780
787
  for machine, m in _indexed(_get(value, "machines")):
781
788
  for placement, p in _indexed(_get(machine, "packages")):