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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +13 -2
  3. package/SECURITY.md +23 -8
  4. package/bin/fabric-agent-adapter.js +24 -1
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +8 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +14 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +18 -3
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +55 -75
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +5 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +8 -3
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +25 -9
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +22 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +14 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +17 -28
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +2 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +135 -3
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +78 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -3
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +85 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +31 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +13 -8
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +18 -3
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +141 -17
  51. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
  52. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  53. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  54. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +8 -3
  55. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  56. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  57. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  58. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  59. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  60. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  61. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  62. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  63. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +14 -2
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  69. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  70. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  71. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  72. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  73. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  75. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +1 -1
  76. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env python3
2
+ """Create a Fabric agent project with the contract built in, in one command (FAA-270, North Star G21).
3
+
4
+ python3 new_agent.py <folder> --id <agent-id> [--profile mcp|a2a|local-runner] [--service]
5
+ [--capability NAME] [--effect CLASS] [--force] [--json]
6
+
7
+ It writes, into an empty (or new) folder:
8
+
9
+ - the provider bundle `adapt_project.py scaffold` writes, pinned to the contract (`fabric-agent.json`,
10
+ `fabric-contract.lock.json`, `fabric/`), every value it invents a sentinel `adapt_project.py check` names;
11
+ - `fabric.source.json`, the agent's source manifest (DEC-0149, `fabric-source/1`): what is source, default deny, and the
12
+ slots an installation fills (the publisher's URI behind `provider.createdBy`, the placement record and, for a
13
+ service, its supervisor label and port), never a value;
14
+ - the placement guard (`src/placement.py`, with its tests in `tests/`): at start the agent reads Project Observatory's
15
+ placement record (`observatory-placement/1`) and refuses to start where the record places it on another machine,
16
+ starts degraded when it cannot read it, and starts when there is none; `config/placement.example.json` shows the
17
+ record's shape on neutral values;
18
+ - `README.md`, `LICENSE` (all rights reserved until the owner names the terms), `.gitignore`;
19
+ - with `--service`: `service/`, the service kit's sample (`service.py`, a fabric-service/0.1 service with one capability
20
+ and a job) and the kit modules it imports, copied by `fabric_kit.py install`, whose digests `fabric_kit.py status`
21
+ checks later.
22
+
23
+ A repeat with the same arguments in a folder this command made changes nothing and answers the same line. A folder that
24
+ holds anything else is refused unless `--force`, which writes the planned files over it and leaves every other file as
25
+ it is. The last stdout line is `{"folder": "<path>", "id": "<id>", "profile": "<profile>", "service": <bool>,
26
+ "created": [<paths>], "repeated": <bool>}` (`--json` prints only it). Exit 0 made or already made, 1 refused (the
27
+ reason on stderr), 2 usage. Writes nothing outside the folder; reads no key and no instance data. Standard library only.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ from pathlib import Path
35
+ import re
36
+ import shutil
37
+ import sys
38
+ from typing import Any, Dict, List, Optional, Sequence
39
+
40
+ HERE = Path(__file__).resolve().parent
41
+ sys.path.insert(0, str(HERE))
42
+ import adapt_project as ap # noqa: E402 - the scaffolder and the source manifest, beside this file
43
+
44
+ VERSION = "0.1.0"
45
+ RECORD = "fabric/new-agent.json"
46
+ #: The agent's id: the package's `name` grammar (`fabric-package.schema.json#/$defs/name`), kebab case.
47
+ AGENT_ID = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z")
48
+ CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}\Z")
49
+ KIT = HERE.parent.parent / "building-fabric-services" / "scripts"
50
+ #: What the sample service imports; `fabric_kit.py install` copies each one's closure.
51
+ SERVICE_MODULES = ("fabric_service", "fabric_interop", "fabric_board", "fabric_hub")
52
+
53
+
54
+ class Refused(Exception):
55
+ """The folder cannot be made as asked; the reason is printed, never a value."""
56
+
57
+
58
+ PLACEMENT_PY = '''"""The placement guard: at start, before the agent polls, schedules or listens, it reads Project Observatory's
59
+ placement record and runs only on the machine that record names (creating-fabric-agents, where-agents-run.md).
60
+
61
+ verdict(service_id, label) -> {"state": "ok" | "placed-elsewhere" | "placement-unreadable", "machine"?}
62
+
63
+ `placed-elsewhere`: the record's `elsewhere` list names this agent (its `serviceId` or its supervisor `label`) on a
64
+ machine that is not this one: refuse to start, log one line, report it as degraded. `placement-unreadable`: the file is
65
+ there and cannot be read, or is of another schema: start, and report it as degraded. No file: start.
66
+ """
67
+ import json
68
+ import os
69
+ from pathlib import Path
70
+ from typing import Any, Dict, Optional
71
+
72
+ SCHEMA = "observatory-placement/1"
73
+
74
+
75
+ def placement_path(env: Optional[Dict[str, str]] = None) -> Path:
76
+ env = os.environ if env is None else env
77
+ base = env.get("XDG_DATA_HOME") or ""
78
+ root = Path(base) if os.path.isabs(base) else Path.home() / ".local" / "share"
79
+ return root / "project-observatory" / "placement.json"
80
+
81
+
82
+ def verdict(service_id: str, label: str, path: Optional[Path] = None) -> Dict[str, Any]:
83
+ path = placement_path() if path is None else path
84
+ try:
85
+ data = path.read_bytes()
86
+ except FileNotFoundError:
87
+ return {"state": "ok"}
88
+ except OSError:
89
+ return {"state": "placement-unreadable"}
90
+ try:
91
+ doc = json.loads(data.decode("utf-8"))
92
+ except ValueError:
93
+ return {"state": "placement-unreadable"}
94
+ if not isinstance(doc, dict) or doc.get("schema") != SCHEMA or not isinstance(doc.get("elsewhere"), list):
95
+ return {"state": "placement-unreadable"}
96
+ for entry in doc["elsewhere"]:
97
+ if not isinstance(entry, dict):
98
+ continue
99
+ ours = (service_id and entry.get("serviceId") == service_id) or (label and entry.get("label") == label)
100
+ if ours and entry.get("placedOn") and entry.get("placedOn") != doc.get("machine"):
101
+ return {"state": "placed-elsewhere", "machine": entry.get("placedOn")}
102
+ return {"state": "ok"}
103
+ '''
104
+
105
+ PLACEMENT_TEST = '''"""The placement guard: one copy placed elsewhere refuses, one with no file and one with a malformed file start."""
106
+ import json
107
+ from pathlib import Path
108
+ import sys
109
+ import tempfile
110
+ import unittest
111
+
112
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
113
+ import placement # noqa: E402
114
+
115
+ EXAMPLE = Path(__file__).resolve().parents[1] / "config" / "placement.example.json"
116
+
117
+
118
+ class PlacementTests(unittest.TestCase):
119
+ def setUp(self):
120
+ self.folder = tempfile.TemporaryDirectory()
121
+ self.addCleanup(self.folder.cleanup)
122
+ self.path = Path(self.folder.name) / "placement.json"
123
+
124
+ def test_placed_elsewhere_refuses(self):
125
+ self.path.write_text(EXAMPLE.read_text(encoding="utf-8"), encoding="utf-8")
126
+ found = placement.verdict("{service_id}", "{label}", self.path)
127
+ self.assertEqual(found["state"], "placed-elsewhere")
128
+
129
+ def test_no_file_starts(self):
130
+ self.assertEqual(placement.verdict("{service_id}", "{label}", self.path), {{"state": "ok"}})
131
+
132
+ def test_a_malformed_file_starts_degraded(self):
133
+ self.path.write_text("{{not json", encoding="utf-8")
134
+ self.assertEqual(placement.verdict("{service_id}", "{label}", self.path), {{"state": "placement-unreadable"}})
135
+ self.path.write_text(json.dumps({{"schema": "another/1"}}), encoding="utf-8")
136
+ self.assertEqual(placement.verdict("{service_id}", "{label}", self.path)["state"], "placement-unreadable")
137
+
138
+ def test_placed_on_this_machine_starts(self):
139
+ doc = json.loads(EXAMPLE.read_text(encoding="utf-8"))
140
+ doc["machine"] = doc["elsewhere"][0]["placedOn"]
141
+ self.path.write_text(json.dumps(doc), encoding="utf-8")
142
+ self.assertEqual(placement.verdict("{service_id}", "{label}", self.path), {{"state": "ok"}})
143
+
144
+
145
+ if __name__ == "__main__":
146
+ unittest.main()
147
+ '''
148
+
149
+ README = '''# {agent_id}
150
+
151
+ A Fabric agent made with the Fabric Agent Adapter's `new` command, contract `{contract}` at `{commit}`.
152
+
153
+ ## Layout
154
+
155
+ - `fabric-agent.json`, `fabric-contract.lock.json`, `fabric/`: the provider bundle, pinned to the contract. Every value
156
+ the scaffold invented is a sentinel: `adapt_project.py check` names each one until it is replaced.
157
+ - `fabric.source.json`: the agent's source manifest (DEC-0149): what is source, and the slots an installation fills,
158
+ never a value. `fabric_source_check.py check .` reads the source under it (exit 0 before every release).
159
+ - `src/placement.py`: the placement guard the agent runs at start, before it polls, schedules or listens.
160
+ {service_line}
161
+ ## Configure
162
+
163
+ Keys are read by name from the environment or the secret store, never from a file of this repository. Slots:
164
+ {slots}
165
+
166
+ ## Data
167
+
168
+ The agent's data lives apart from this source, in its data home (`<agent>-data`, or the service kit's folders), from
169
+ the first commit.
170
+ '''
171
+
172
+ GITIGNORE = """# the instance: real configuration, keys and what running the agent leaves
173
+ config/*.json
174
+ !config/*.example.json
175
+ .env
176
+ .env.*
177
+ !.env.example
178
+ *.token
179
+ *.credential
180
+ __pycache__/
181
+ *.pyc
182
+ """
183
+
184
+
185
+ def _placement_example(service_id: str, label: str) -> str:
186
+ return ap._json_text({"schema": "observatory-placement/1", "machine": "example-machine-a", "updatedAt": "2026-01-01T00:00:00Z",
187
+ "elsewhere": [{"label": label, "serviceId": service_id, "placedOn": "example-machine-b",
188
+ "since": "2026-01-01T00:00:00Z", "by": "full machine-move leave"}]})
189
+
190
+
191
+ def plan(args: argparse.Namespace) -> Dict[str, str]:
192
+ """Every file the command writes, by its path, with its text (the scaffold's own included)."""
193
+ agent_id = args.id
194
+ capability = args.capability or "%s.run" % agent_id.replace("-", "_")
195
+ if not CAPABILITY.match(capability):
196
+ raise Refused("--capability %r is not a capability name (lower case, `domain.action`)" % capability)
197
+ scaffold = argparse.Namespace(
198
+ profile=args.profile, provider_id="urn:fabric:provider:%s" % agent_id, provider_name=agent_id,
199
+ capability_id="urn:fabric:capability:%s" % capability, capability_name=capability,
200
+ schema_base="https://replace.invalid/%s/schemas" % agent_id, mcp_mode="streamable-http",
201
+ mcp_url="https://replace.invalid/mcp", agent_card_url="https://replace.invalid/.well-known/agent-card.json",
202
+ runner_kind="replace-me", executable_ref="urn:executable:replace-me", runtime_arg=[], effect=args.effect,
203
+ idempotency="none", cost_preset=[], default_cost_preset=None, job=False, created_by=None, identity_method=None,
204
+ data_class=[], probe_assertion=[])
205
+ files = dict(ap._generated_files(scaffold))
206
+ service_id = "%s.default" % agent_id
207
+ label = "com.example.%s" % agent_id
208
+ include = ["README.md", "LICENSE", ".gitignore", "src/", "tests/", "config/placement.example.json",
209
+ "fabric-agent.json", "fabric-contract.lock.json", "fabric/"] + (["service/"] if args.service else [])
210
+ manifest = ap.source_manifest(include, "%s, a Fabric agent" % agent_id, service=args.service)
211
+ slots = "\n".join("- `%s` (%s%s): %s" % (s["name"], s["kind"], ", optional" if s.get("optional") else "", s["purpose"])
212
+ for s in manifest["slots"])
213
+ files.update({
214
+ ap.SOURCE_FILE: ap._json_text(manifest),
215
+ "README.md": README.format(agent_id=agent_id, contract=ap.CONTRACT_VERSION, commit=ap.CONTRACT_COMMIT[:12], slots=slots,
216
+ service_line="- `service/`: the service kit's sample, run as `python3 service/service.py "
217
+ "serve --id %s --port <port> --data-dir <data>`, and the kit modules it imports.\n" % agent_id
218
+ if args.service else ""),
219
+ "LICENSE": "Copyright (c) the owner of %s. All rights reserved, until the owner names other terms.\n" % agent_id,
220
+ ".gitignore": GITIGNORE,
221
+ "src/placement.py": PLACEMENT_PY,
222
+ "tests/test_placement.py": PLACEMENT_TEST.format(service_id=service_id, label=label),
223
+ "config/placement.example.json": _placement_example(service_id, label),
224
+ })
225
+ record = {"tool": "fabric-agent-adapter new %s" % VERSION, "id": agent_id, "profile": args.profile, "service": args.service,
226
+ "capability": capability, "effect": args.effect, "contract": ap.CONTRACT_COMMIT}
227
+ files[RECORD] = ap._json_text(record)
228
+ return files
229
+
230
+
231
+ def _service_files(folder: Path) -> List[str]:
232
+ """Copy the kit's sample and the closure of what it imports into `service/` (fabric_kit.py install)."""
233
+ import importlib.util
234
+ found = importlib.util.spec_from_file_location("fabric_kit", KIT / "fabric_kit.py") # the kit's own installer
235
+ fabric_kit = importlib.util.module_from_spec(found)
236
+ found.loader.exec_module(fabric_kit) # type: ignore[union-attr]
237
+ target = folder / "service"
238
+ result = fabric_kit.install(target, "python", list(SERVICE_MODULES))
239
+ if result["refused"]:
240
+ raise Refused("service/ holds kit modules this kit does not recognise: %s" % ", ".join(result["refused"]))
241
+ shutil.copyfile(KIT / "sample_service.py", target / "service.py")
242
+ return ["service/" + name for name in result["copied"] + result["kept"]] + ["service/service.py"]
243
+
244
+
245
+ def make(folder: Path, args: argparse.Namespace) -> Dict[str, Any]:
246
+ if not AGENT_ID.match(args.id) or len(args.id) > 64:
247
+ raise Refused("--id %r is not an agent id (lower case words joined by -, at most 64 characters)" % args.id)
248
+ files = plan(args)
249
+ answer = {"folder": str(folder), "id": args.id, "profile": args.profile, "service": args.service}
250
+ if folder.exists() and not folder.is_dir():
251
+ raise Refused("%s is not a folder" % folder.name)
252
+ present = sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*")) if folder.is_dir() else []
253
+ if present:
254
+ record = folder / RECORD
255
+ try:
256
+ same = record.is_file() and json.loads(record.read_text(encoding="utf-8")) == json.loads(files[RECORD])
257
+ except ValueError:
258
+ same = False
259
+ if same and all((folder / path).is_file() for path in files) and not args.force:
260
+ return dict(answer, created=[], repeated=True)
261
+ if not args.force:
262
+ raise Refused("the folder is not empty and was not made by this command with these arguments: "
263
+ "name another folder, or pass --force to write the planned files over it")
264
+ for path, text in files.items():
265
+ target = folder / path
266
+ target.parent.mkdir(parents=True, exist_ok=True)
267
+ target.write_text(text, encoding="utf-8")
268
+ created = sorted(files)
269
+ if args.service:
270
+ created = sorted(created + _service_files(folder))
271
+ return dict(answer, created=created, repeated=False)
272
+
273
+
274
+ def build_parser() -> argparse.ArgumentParser:
275
+ parser = argparse.ArgumentParser(prog="new_agent.py", description="Create a Fabric agent project with the contract built in.")
276
+ parser.add_argument("folder", type=Path)
277
+ parser.add_argument("--id", required=True, help="the agent's id, kebab case (example-agent)")
278
+ parser.add_argument("--profile", choices=("mcp", "a2a", "local-runner"), default="mcp")
279
+ parser.add_argument("--service", action="store_true", help="also wire the service kit's sample (fabric-service/0.1)")
280
+ parser.add_argument("--capability", help="its capability, `domain.action` (default <id>.run)")
281
+ parser.add_argument("--effect", choices=ap.EFFECT_CLASSES, default="none",
282
+ help="the capability's effect class (default none: declare money, deletion or publication now)")
283
+ parser.add_argument("--force", action="store_true", help="write the planned files into a folder that holds others")
284
+ parser.add_argument("--json", action="store_true", help="print only the last line")
285
+ return parser
286
+
287
+
288
+ def main(argv: Optional[Sequence[str]] = None) -> int:
289
+ try:
290
+ args = build_parser().parse_args(argv)
291
+ except SystemExit as exc:
292
+ return exc.code if isinstance(exc.code, int) else 2
293
+ try:
294
+ answer = make(args.folder.expanduser().resolve(), args)
295
+ except (Refused, ap.AdaptationError, OSError) as exc:
296
+ print("new_agent.py: %s" % (exc if not isinstance(exc, OSError) else "the folder cannot be written (%s)"
297
+ % type(exc).__name__), file=sys.stderr)
298
+ return 1
299
+ if not args.json:
300
+ print("%s %s: %d file(s)%s" % ("already made" if answer["repeated"] else "made", args.id, len(answer["created"]),
301
+ "" if answer["repeated"] else "; next: replace the sentinels `adapt_project.py check` "
302
+ "names, then run fabric_source_check.py check on the folder"))
303
+ print(json.dumps(answer, ensure_ascii=False))
304
+ return 0
305
+
306
+
307
+ if __name__ == "__main__":
308
+ raise SystemExit(main())
@@ -9,10 +9,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
9
9
  compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
10
10
  metadata:
11
11
  author: PassionCode.ai
12
- version: "0.80.2"
12
+ version: "0.81.0"
13
13
  contract-version: "0.1.0"
14
14
  extension: "fabric-service/0.1"
15
- extension-commit: "18f8d48762edc5bfb1433657cbfe1c16196df1a6"
15
+ extension-commit: "93e7db164c7829e2783b4b7705c4113bb5201131"
16
16
  ---
17
17
 
18
18
  # Building Fabric services
@@ -82,7 +82,9 @@ MCP registration into a client config, the login flow or the CSRF header.
82
82
  When other agents or Fabric call the service over MCP, it follows `fabric-interop/0.1`:
83
83
  each manifest capability is the tool of its name, long work is a job with a stable id,
84
84
  a question for a person is an elicitation, and every call carries one trace. A job a limit stops or pauses, and an
85
- effect nobody saw, have their own codes and a key per caller (DEC-0087). Read
85
+ effect nobody saw, have their own codes and a key per caller (DEC-0087); work held by the service's own limits is
86
+ said on the job, pushed to the operator once per hold and lifted by their scoped answer (`scripts/fabric_holds.py`,
87
+ FAA-298). Read
86
88
  [the interop reference](references/interop.md) before writing an MCP tool; the helpers
87
89
  are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
88
90
  [`scripts/fabric-interop.mjs`](scripts/fabric-interop.mjs). When the callers are agents of the estate, the
@@ -2,7 +2,7 @@
2
2
 
3
3
  A service answers one inbound question from its own knowledge file, or hands it to a person. It is a step of an
4
4
  agent that answers people, not that agent: see the paragraph after the table. Under the creating-fabric-agents
5
- template (`communication-agent-template.md`) the plan-and-draft call writes the draft, not this recipe's `answer()`:
5
+ template (`templates/communication-agent/`, read through `references/communication-agent-template.md`) the plan-and-draft call writes the draft, not this recipe's `answer()`:
6
6
  the draft carries a `[KB:<id>]` marker for each knowledge id the plan used, and the agent runs `check_reply(draft,
7
7
  question, prompt_ids, max_chars=..., sources=...)` on it for the code checks. `prompt_ids` are the ids of the items
8
8
  placed in the plan's prompt, never the plan's own list (an id the plan names outside them is held as a suspected
@@ -65,8 +65,8 @@ host's `open` with `fallback=never`. Never `open <url>`, `open -a Safari`,
65
65
  agent's pages. A browser only when `host_status` confirms `not_installed`, and
66
66
  a raw `http://127.0.0.1:<port>` address only after the listener on that port is
67
67
  this OS account's (DEC-0154 PL-3 and HA-2; the adapter's `building-fabric-services/scripts/fabric_peer_owner.py`: `server_end` of a connection it opens and sends nothing on; `check_service.py <id>.<instance>` prints it as `listener.owner`): a
68
- `foreign-user` listener is reported, never opened (the link gate below does not
69
- read the owner yet, FAA-251). An installed host that failed
68
+ `foreign-user` listener is reported, never opened (the link gate below takes
69
+ that owner as `listener_owner`). An installed host that failed
70
70
  to open is reported, not replaced by HTTP. Links
71
71
  carry no token or login code. Every completion and notification action passes
72
72
  the Fabric Agent Adapter's link gate before delivery: `check_dashboard_link.py`
@@ -119,7 +119,12 @@ Its trusted context is supplied by the host, not by untrusted model output:
119
119
 
120
120
  `native_link` must equal the resolved open_link, with an available host on the
121
121
  viewer's device. `browser_link` must equal http_url, with confirmed not_installed,
122
- if_absent policy and the same device. Contract DEC-0123 PL-3 also allows a browser
122
+ if_absent policy, the same device and `"listener_owner": "this-account"`: the kind
123
+ the host read for the listener on http_url's port (`fabric_peer_owner`'s
124
+ `this-account`, `another-account`, `system`, `unknown`, `none`, or
125
+ `check_service.py`'s `foreign-user`); any other, or none given, refuses the
126
+ browser (DEC-0154 PL-3, FAA-251). The native link does not take it: Fabric
127
+ Dashboards checks its own listener. Contract DEC-0123 PL-3 also allows a browser
123
128
  on `unsupported` (a system the host does not ship for); the gate treats
124
129
  `unsupported` as no fallback, by the operator's rule that a browser opens only on a
125
130
  confirmed `not_installed`, and macOS never reports it (FAA-137). `remote_open` instead contains `device_id`,
@@ -53,8 +53,11 @@ the same owner; **T2** everyone else, and any call relayed onto loopback from of
53
53
  hub forwarding a T2 caller). DL-26: a T0 or T1 caller is never required to present a token; pass the tier,
54
54
  `gate(tool, headers, tier="T0")` or `server.handle(message, headers, tier="T0")`, and the call passes with no
55
55
  delegation. On a computer with more than one counted OS account, a loopback caller with no token is T0 only after the
56
- door checks the peer's account (the pinned DEC-0154 HA-3 and DL-25; the kit's own peer check is FAA-227), so pass
57
- `tier="T0"` there only for a caller the descriptor token authenticated. A token such a caller presents is still verified and refused when invalid. A T2 caller takes the whole
56
+ door checks the peer's account (the pinned DEC-0154 HA-3 and DL-25): the kit's `LoopbackHTTPServer` with `peer_check="strict"` (Node
57
+ `loopbackServer(app, {peerCheck: 'strict'})`, [protocol.md](protocol.md#the-listeners-owner-dec-0154-ha-2)) is that check,
58
+ for loopback peers: pass `tier="T0"` only when the handler's `self.server.peer_owner()` (`connectionOwner(req.socket)`)
59
+ names an owner, since a peer off loopback is served without one and is T2;
60
+ otherwise pass `tier="T0"` there only for a caller the descriptor token authenticated. A token such a caller presents is still verified and refused when invalid. A T2 caller takes the whole
58
61
  profile. Money limits and human gates bind at every tier.
59
62
 
60
63
  **The family and the agent network** (DEC-0130, wording settled by DEC-0137). The operator's family is every agent
@@ -159,6 +159,33 @@ node keys every per-caller rule with `job_caller_key`, counts every started job
159
159
  and paused ones too) as running, and answers another caller's job id `unknown-job`. The text-only `queue-full` result
160
160
  and `fabric.job.list` are still the node's own (FAA-103).
161
161
 
162
+ ### Work held by the service's own limits
163
+
164
+ A service that holds work by a limit of its own (a daily quota, a per-caller share, a concurrency cap, a money cap)
165
+ says so, so a hold is never seen only by a caller who waits (FAA-298). `fabric_holds` (Node `fabric-holds.mjs`) does
166
+ the three parts:
167
+
168
+ - **On the job.** `hold_job(jobs, job_id, reason, since, until, message)` (Node `holdJob`) pauses the job with the
169
+ statusMessage `waiting_message` writes: `quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`,
170
+ `reason` one of `daily-quota`, `caller-share`, `concurrency`, `money`. The pinned `interop-job` schema is closed
171
+ (`additionalProperties: false`), so `waiting {reason, since, until, message}` is carried in `statusMessage`, the
172
+ contract's pause grammar, never as a field of the job: every caller already reads it as wait (until `until` when
173
+ given), and `read_waiting(answer)` (Node `readWaiting`) gives the four parts back. A job waiting its normal turn in
174
+ the call queue keeps `queued <position> of <length>`; a hold is the service's own limit.
175
+ - **One push per hold.** `HoldBook(path, events=log)` (Node `new HoldBook(file, {events})`) opens a hold per reason and
176
+ caller: `hold(caller=, work=, reason=, until=, job_id=)` returns `{hold, statusMessage, push}`, and only the call that
177
+ opens the hold pushes: one `limit.held` event, level `notice`, `notify: true`, naming the caller, the work, the reason
178
+ and when it frees by itself, and asking whether to raise the limit. More work held by the same limit for the same
179
+ caller joins the hold silently. A hold closes by `release`, when its `until` passes, or by a lift that covers it;
180
+ the next hold pushes again. Names that read as a credential are refused.
181
+ - **The operator's answer as a scoped lift.** `lift(reason=, decision=, caller=, job_id=, jobs= | usd= | limit=,
182
+ until=)` records the operator's words (`fabric_actions.operator_decision("limit.lift", words)`) with its scope (this
183
+ order, one caller, or every caller) and its size: a number of jobs, an amount of money, or a raised limit for a window
184
+ (100 a day for today: `limit=100, until=<midnight>`), and writes `limit.lifted`. `admit(reason=, caller=, job_id=,
185
+ cost_usd=)` spends a jobs or money lift (the narrowest scope first) and answers whether the work may pass the limit;
186
+ pass its answer to `jobs.create(..., limit_holds=not admitted)` for a repeated key. `limit(reason=, base=, caller=)`
187
+ is the limit in force. A lift falls back by itself when its window ends or its amount is spent.
188
+
162
189
  <a id="actions"></a>
163
190
 
164
191
  ## Actions
@@ -412,5 +439,6 @@ your own record, keep it (OA-6).
412
439
  | `interop.events-trace` | every event with a trace carries both `traceId` and `spanId`, well formed | no events page was read |
413
440
  | `interop.action-tools` | every action the well-known `extensions/actions/0.1` declares names a tool `tools/list` serves (DEC-0121 AP-1); one it does not serve is a `FAIL`, and so is a local service that declares actions with no MCP surface (beyond FAC-SEM-142, which reads only a present list) | no declaration, an unreadable one (`well-known.actions` reports it), no listing was read, or a remote service with no MCP surface (its twin may be on A2A or behind a gateway) |
414
441
  | `channel-agent.declaration` | a channel agent's declaration (DEC-0150) passes the contract's FAC-SEM-162 to 165 at the pin, read from `provider.extensions` of the manifest the descriptor names, else from `extensions` of the well-known answer (CA-1); a `FAIL` on the obligations of a declaration the reader still reads (FAC-SEM-164) or a string shaped like a credential (FAC-SEM-165, not a declaration too large to read for credentials) in the read declaration or in any copy beside it, the first five findings shown as code and path, never a declared value | nothing declared, the named manifest unreadable (`interop.manifest-link` fails it), or a key read as not declared (a manifest's own `extensions`, a capability's, another version's key, a well-known copy beside a manifest, a form the schema or FAC-SEM-163 refuses), each named in the evidence, never a failure while producers move to this revision unless a copy holds a string shaped like a credential (that fails in any copy the probe reads: a manifest's own `extensions`, a capability's, the well-known copy, CA-5); no line when the well-known answer is not read; credentials are read as the contract reads them, within its reading budget and under the `0.1` key only (a key of another version is named, not read); a well-known answer carrying the declaration at 75 % of the 2 MiB cap or over is named, never a failure (until the contract's CT-223) |
442
+ | `limits.holds` | a service that declares limits (a load declaration's `limits`, the usage report's `budget` or `budgets`) holds work through `fabric_holds` (`HoldBook`, `waiting_message`, `hold_job`) in a tracked code file (`.py`, `.mjs`, `.js`, `.ts`) of its checkout outside the kit's copy, a vendored `check_service_rules.py` and the tests (FAA-298) | no limits declared, or no checkout found (`--repo` names it), git missing or out of time; and, since 0.81.0, a service that declares limits and holds none through the kit: a migration note naming `fabric_holds`, never a `FAIL`, since services built before the module could not have adopted it |
415
443
 
416
444
  `python3 scripts/check_service.py <id>` prints them with the rest; `--json` for a machine.
@@ -59,7 +59,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
59
59
  | L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
60
60
  | L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
61
61
  | L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
62
- | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0 vendored from commit `f86f0622` with the readings its header names (`1.1.0+kit.4`), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
62
+ | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py`, vendored from commit `f86f0622` (1.1.0) and read against `2180235c` (1.2.0, whose Linux rule it carries), with the readings its header names (`1.2.0+kit.5`; Observatory's 1.3.x libproc proof is FAA-292), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
63
63
  | L0 | `fabric_tailnet` | who calls an agent behind `tailscale serve` and what that person may open (FAA-297): `Tailscale-User-Login` only on the agent's own serve listener from a loopback peer, groups from a generated `tailnet-perimeter/1` file, a personal SHA-256-stored key for MCP no wider than the groups; `authorize`, `load_perimeter`, `load_keys`, `hash_key`, refusals 401/403 with a code ([surfaces-and-auth.md](surfaces-and-auth.md#behind-tailscale-serve)) |
64
64
  | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
65
65
  | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
@@ -82,7 +82,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
82
82
  | L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
83
83
  | L3 | `fabric_usage` | usage receipts, the ledger, the usage report, attribution, spending limits ([usage](usage.md)) |
84
84
  | L3 | `fabric_backups` | settings backups (DEC-0025) |
85
- | L3 | `fabric_descriptor` | the descriptor, peers, app descriptors, the well-known document |
85
+ | L3 | `fabric_descriptor` | the descriptor, peers, app descriptors, the well-known document, the loopback HTTP server that refuses another OS account's peer before reading it (`LoopbackHTTPServer`, Node `loopbackServer`; FAA-227) |
86
86
  | L3 | `fabric_context` | one instance per context (FAA-125, N-11): a context's instance, folders, descriptor, lock and marker; not in the facade, installed with `--module fabric_context` ([contexts](estate-record.md#one-instance-per-context-the-helper)) |
87
87
  | L3 | `fabric_auth` | the token, the network guard, login codes |
88
88
  | L3 | `fabric_events` | events, paging, the JSONL event log (DEC-0029) |
@@ -92,6 +92,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
92
92
  | L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
93
93
  | L3 | `fabric_interop_rules` | `fabric-interop/0.1` rules without a service: constants, `InteropError`, trace context, job limits (DEC-0087; FAA-92), a job's approval step under the mandate (`job_step`, C3.3) |
94
94
  | L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools; re-exports the rule layer ([interop](interop.md)) |
95
+ | L3 | `fabric_holds` | work a service holds by its own limits (FAA-298): the held job's `statusMessage` in the pause grammar (`waiting_message`, `read_waiting`, `hold_job`), one push per hold and the operator's scoped lift (`HoldBook`: `hold`, `release`, `lift`, `admit`, `limit`) ([interop](interop.md#work-held-by-the-services-own-limits)) |
95
96
  | L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
96
97
  | L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
97
98
  | L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data, and the operator's mandate a request carries (FAA-135, imports `fabric_mandate` and `fabric_interop_rules` for the FAA-139 reading) |
@@ -145,27 +145,34 @@ it at boot, before any login, as the account its `UserName` names.
145
145
  install requires (without it the job would run as root, which every client refuses); the file goes in
146
146
  `/Library/LaunchDaemons/<label>.plist`. A plist there is in the system domain by its path (the folder's case is
147
147
  ignored); `domain="system"` (Node: `domain: 'system'`) says so for any path, and an explicit domain wins over the
148
- path. Everything else is a LaunchAgent's.
148
+ path, except that an install refuses a pair launchd would contradict: `domain="system"` with a plist in a
149
+ `LaunchAgents` folder (launchd also loads that into `gui/<uid>` at login) and `domain="gui"` with a plist in
150
+ `/Library/LaunchDaemons` (launchd loads it at boot as root). A domain name given as the plist path (`"system"`,
151
+ `"gui"`) is refused, never read as a relative path (FAA-286). Everything else is a LaunchAgent's.
149
152
  `UserName` is the account whose Fabric and agents call the service: clients of another account refuse it (HA-2).
150
153
  - **Who runs which step.** The release, the descriptor and the service token are written as the `UserName` (steps 1 and
151
154
  2 above, for example with `sudo -u <user> -H`), so they live in that user's home with its modes, and the plist's
152
155
  working directory and log paths name that home; only the plist, `launchd_install` and `launchd_uninstall` run as root,
153
156
  and at uninstall `remove_descriptor` runs as the `UserName` again. A service moved from a LaunchAgent is uninstalled
154
- as one first, so launchd never holds both. The descriptor's `lifecycle.plist` names the path exactly,
157
+ as one first, so launchd never holds both: the install refuses a plist of the same label left in the `UserName`'s
158
+ `~/Library/LaunchAgents`, and the probe's `lifecycle.one-copy` fails a label loaded in both domains (FAA-286). The descriptor's `lifecycle.plist` names the path exactly,
155
159
  `/Library/LaunchDaemons/<label>.plist`: a host that reads the folder by its spelling then knows the domain too.
156
160
  - **The label is the machine's.** `/Library/LaunchDaemons` and the system domain are shared by every account of the
157
161
  Mac, so a LaunchDaemon's label is unique on the machine, not per account (put the account in it on a Mac with a
158
162
  personal and an organization account); an install refuses, before writing anything, a label another plist holds
159
163
  (the one launchd loaded the job from, or any plist of that label in the same folder) and the plist at its path when it
160
164
  names another `UserName` or another label; an uninstall refuses a label another plist holds before its `bootout`,
161
- which would stop that plist's job, so pass it the label the plist at its path names.
165
+ which would stop that plist's job, so pass it the label the plist at its path names; in either domain an uninstall
166
+ refuses a plist at its path that names another label, before its `bootout` (FAA-286).
162
167
  - **Root installs and removes it.** `launchd_install` and `launchd_uninstall` in the system domain refuse, before writing
163
168
  anything, in a process that is not root, and name the command to run as root (`launchctl bootstrap system <plist>`,
164
169
  `launchctl bootout system/<label>`, removing the plist and `launchctl enable system/<label>`, as `launchd_uninstall`
165
170
  does). `purge` is refused: the data belongs to the `UserName`, so remove it as that user. Upgrade is the same install
166
171
  again as root, so a descriptor's `commands.update` for a LaunchDaemon runs as root, writes the new release (and any
167
172
  descriptor it rewrites) as the `UserName` as an install does, and runs only the plist and `launchd_install` as root; a
168
- host without root reports it as the operator's. A host's verbs need root too and keep LC-14 (off stays off, across a boot as well as a login): stop is
173
+ host without root reports it as the operator's. Its commands name absolute paths: `~` would be root's home, and the
174
+ probe's `lifecycle.plist` fails a LaunchDaemon command that starts with `~`, as it fails a LaunchDaemon plist without
175
+ `UserName` (FAA-286). A host's verbs need root too and keep LC-14 (off stays off, across a boot as well as a login): stop is
169
176
  `bootout` + `disable system/<label>`, start is `enable` + `bootstrap system <plist>`, restart is
170
177
  `kickstart -k system/<label>`; a host or broker without root reports the job read-only.
171
178
  - **Status and pid read without root**: `launchd_loaded(label, "system")` and `launchd_pid(label, "system")` ask
@@ -177,23 +184,32 @@ it at boot, before any login, as the account its `UserName` names.
177
184
  that pid accepted, while the pid runs as the `UserName`'s uid (`ps`) and listens on exactly `127.0.0.1` (`lsof -p`);
178
185
  an install refuses a `UserName` that is not a short name, no account of the Mac or root, and an origin other than
179
186
  `http://127.0.0.1:<port>`, where nothing could prove it. Where the socket table is restricted it accepts a port that
180
- pid listens on at exactly `127.0.0.1`, the weaker vouch SECURITY.md names (CT-261). A `ProgramArguments` wrapper that
187
+ pid listens on at exactly `127.0.0.1`, the weaker vouch SECURITY.md names (DEC-0155 RT-3). A `ProgramArguments` wrapper that
181
188
  starts the server as a child cannot pass either check, and the refusal says so: start the server with `exec`. An
182
189
  install refused after its bootstrap leaves the job loaded, and its plist starts it again at boot; the error says so,
183
- and as root `launchctl bootout system/<label>` and removing the plist (or `launchd_uninstall`) take it away. For hosts and the probe the domain comes from the plist's folder alone, so a LaunchDaemon's plist must live
190
+ and as root `launchctl bootout system/<label>` and removing the plist (or `launchd_uninstall`) take it away. A
191
+ bootstrap that fails all five tries removes the plist the call wrote, and names one that was there before, which
192
+ would load at the next boot (FAA-286). A plist that does not parse is refused as such before anything is written,
193
+ never as a missing `UserName`; the Node installer refuses a binary plist and reads only the root dict's keys. For hosts and the probe the domain comes from the plist's folder alone, so a LaunchDaemon's plist must live
184
194
  in `/Library/LaunchDaemons` even when the installer is given `domain="system"`: the installer neither moves nor checks
185
195
  it. A descriptor field for the domain is the contract's CT-234, open.
186
196
  - **A contract exception, accepted in principle.** At the pin, HA-2 reads a launch daemon's listener as another
187
- account's and nothing it answers as the service's; the contract's later RT-3 vouches only for the caller's own uid. The
197
+ account's and nothing it answers as the service's; the pin's RT-3 (DEC-0155) vouches only for the caller's own uid. The
188
198
  root installer reads its own job's token-free well-known answer anyway, proved by launchd's pid, that pid's uid and its
189
199
  bind on exactly `127.0.0.1` (`lsof -p`, never a wildcard), and sends no credential; under a restricted socket table the
190
- first proof is the CT-261 weaker vouch (the pid listens on the port; that it accepted the connection is not shown). The contract owner accepted this in
200
+ first proof is the RT-3 weaker vouch (the pid listens on the port; that it accepted the connection is not shown). The contract owner accepted this in
191
201
  principle as DEC-0161 (RT-3 extended to launchd's system domain, the restricted-table vouch included; proposed, its
192
202
  text after its readings), so it stays a departure from the pin until the next repin (FAA-288).
193
203
  Every other client keeps HA-2 as written.
194
204
  - **Hosts.** A host or broker that reads launchd only in `gui/<uid>` sees a LaunchDaemon as not loaded and must not load
195
205
  its plist into `gui/<uid>`. Fabric Dashboards from 0.8.39 takes the domain from the plist's folder and offers no verbs
196
- for a system job; the lifecycle broker is still asked (FAA-287).
206
+ for a system job; the lifecycle broker is still asked (FAA-287). A product with its own launchd reader (a status
207
+ page's `launchd_pid`, an installer of its own in another language) that asks only `gui/<uid>` reports a LaunchDaemon
208
+ as not loaded: move that reader to the plist's domain before the product moves to the system domain (FAA-286).
209
+ - **Off macOS the twins differ, on purpose.** Python's `launchd_domain()` answers `gui/<uid>` on Linux (it refuses only
210
+ Windows), so the kit's launchd tests run on Linux CI against a fake `launchctl`; Node's `launchdDomain()` refuses off
211
+ `darwin` unless a test passes `platform: 'darwin'`. Neither runs launchd off macOS: install with
212
+ `systemd_install` / `task_install` there (FAA-286 item 5, recorded rather than aligned).
197
213
 
198
214
  ## One copy under a supervisor
199
215
 
@@ -31,4 +31,5 @@ Order matters: each step leaves the service answering.
31
31
  | Data, logs or caches inside the repository | the OS data, log and cache directories; the old tree read-only for one release |
32
32
  | `KeepAlive {SuccessfulExit:false}`, a plist pointing at a checkout or a worktree | a plist generated by `launchd_plist`, pointing at a release |
33
33
  | A port another service already uses by default | the port claim at install; move the newcomer |
34
+ | A port held by socket activation (launchd `Sockets`, a systemd `.socket` unit) or by a root proxy in front of the service | the service binds `127.0.0.1:<port>` itself as its own account; launchd's or root's socket reads as `system` and the hosts' and the probe's `listener.owner` refuse it (DEC-0154 HA-2) |
34
35
  | An event log in a private shape | the events view over it, with one sentence per event |
@@ -42,6 +42,16 @@ python3 scripts/fabric_package.py pack <folder> # a dry run: the packa
42
42
  python3 scripts/fabric_package.py pack <folder> --apply # writes fabric.package.json when nothing is wrong
43
43
  ```
44
44
 
45
+ When the folder carries the agent's source manifest, `fabric.source.json` (contract DEC-0149, `fabric-source/1`), `pack`
46
+ writes `secrets[]` from its `secret` slots (name, purpose and `optional`) and `state[]` from its `store` slots' paths,
47
+ says so in its notes, and lists the manifest in `files[]` like any other file; `pack`, `sign` and `verify` refuse a
48
+ package whose `secrets[]` or `state[]` differ from the slots (FAC-SEM-160). A hand-written `secrets[]` naming a secret
49
+ the manifest has no slot for is never dropped: `pack` refuses (rule `source`, `/secrets`) and names each one, so add
50
+ it as a `secret` slot (or take it out) and pack again. An author's `state: []` is a copy packed
51
+ without state and is kept. A manifest that is no `fabric-source/1` object is refused. The manifest's own rules and the
52
+ tree check under it are the source check's (`references/source-and-instance.md` of adapting-projects-to-fabric, *The
53
+ source manifest*).
54
+
45
55
  Run `adapting-projects-to-fabric/scripts/fabric_source_check.py check <folder> --every-file` on the folder you pack first (a floor under your own
46
56
  review, not a secret scanner): a package carries
47
57
  the source, and the instance (the owner's paths, keys, strings) stays out of it (`references/source-and-instance.md` of