@passioncode-ai/passioncode 0.2.38 → 0.2.39

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 (50) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +29 -11
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +53 -3
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
  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/building-fabric-services/SKILL.md +47 -12
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +5 -4
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +3 -2
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +53 -3
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -1
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +99 -1
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -2
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +5 -3
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +45 -2
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +325 -2
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +246 -0
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +22 -0
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -2
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +45 -3
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +302 -0
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -1
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +22 -3
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +154 -8
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +45 -2
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +124 -16
  44. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
  45. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +53 -3
  46. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
  47. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +41 -9
  48. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -0
  49. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  50. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -1,6 +1,7 @@
1
1
  """The probe's rules that read one thing and need no running service (FAA-65): a plist's marker and priority, the
2
- network guard's verdict, the board responder's fixture conversation, a session cookie's lifetime, a usage report, and
3
- whether two repository URLs name one repository. ``check_service.py`` runs them against a live service.
2
+ network guard's verdict, the board responder's fixture conversation, a session cookie's lifetime, a usage report,
3
+ whether two repository URLs name one repository, a service's actions declaration (DEC-0121) and whether its checkout
4
+ opens its pages in a browser (FAA-118). ``check_service.py`` runs them against a live service.
4
5
  Standard library only, Python 3.9+."""
5
6
  from __future__ import annotations
6
7
 
@@ -17,6 +18,7 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
17
18
 
18
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
19
20
  import fabric_service as fs # noqa: E402 - beside this file
21
+ import fabric_actions as fa # noqa: E402 - beside this file (DEC-0121)
20
22
 
21
23
 
22
24
  def plist_problems(plist: dict, label: object, token: object = None) -> list:
@@ -291,3 +293,324 @@ def same_repository(a: str, b: str) -> bool:
291
293
  u = re.sub(r"^[^@/]+@", "", u)
292
294
  return re.sub(r"\.git$", "", u).rstrip("/")
293
295
  return bool(a and b) and norm(a) == norm(b)
296
+
297
+
298
+ # actions (fabric-actions/0.1, DEC-0121, FAC-SEM-142) ----------------------------------------
299
+ _ACTIONS_KEY_PATH = "/extensions/" + fa.ACTIONS_EXTENSION_KEY.replace("~", "~0").replace("/", "~1")
300
+
301
+
302
+ def _actions_place(instance_path: str) -> str:
303
+ """A finding's place inside the declaration (`/actions/1/id`), or the declaration itself."""
304
+ inner = instance_path[len(_ACTIONS_KEY_PATH):] if instance_path.startswith(_ACTIONS_KEY_PATH) else instance_path
305
+ return inner or "extensions/actions/0.1"
306
+
307
+
308
+ def well_known_actions_verdict(wk: Any) -> Tuple[str, str]:
309
+ """`well-known.actions`: a declared extensions/actions/0.1 is FAC-SEM-142's (the kit's actions_extension_rules, the
310
+ contract's reader): its schema's, each id once, humanStep exactly when gate is human, every tool in a present
311
+ surfaces.mcp.capabilities. No declaration is NOT_RUN: dashboard.actions-declared says what that means."""
312
+ view = fa.actions_view(wk)
313
+ if not view["declared"]:
314
+ return "NOT_RUN", "no extensions/actions/0.1 declaration (DEC-0121 AP-1)"
315
+ findings = fa.actions_extension_rules(wk)
316
+ if findings:
317
+ return "FAIL", "; ".join("FAC-SEM-142 %s: %s" % (_actions_place(f["instancePath"]), f["message"]) for f in findings[:5])
318
+ gates: Dict[str, int] = {}
319
+ for action in view["actions"]:
320
+ gates[action["gate"]] = gates.get(action["gate"], 0) + 1
321
+ return "PASS", "%d action(s) (%s), FAC-SEM-142 holds" % (
322
+ len(view["actions"]), ", ".join("%d %s" % (n, g) for g, n in sorted(gates.items())))
323
+
324
+
325
+ def action_tools_verdict(wk: Any, tools: Optional[Dict[str, Any]], why_not: str = "tools/list was not read",
326
+ no_surface: bool = False, remote: bool = False) -> Tuple[str, str]:
327
+ """`interop.action-tools`: every declared action's tool is served by tools/list, the live surface (FAC-SEM-142
328
+ checks only the list the document names). `tools` None means no listing was read; `why_not` says why. A local
329
+ service that declares actions and has no MCP surface fails: every action has a tool on its agent surface
330
+ (DEC-0121 AP-1), and a local service's is MCP. A remote agent's surface may be A2A or MCP behind a gateway (the
331
+ contract's surfaces table), so there it is NOT_RUN. This goes beyond FAC-SEM-142, which reads only a present list."""
332
+ view = fa.actions_view(wk)
333
+ if not view["declared"]:
334
+ return "NOT_RUN", "no extensions/actions/0.1 declaration"
335
+ if not view["valid"]:
336
+ return "NOT_RUN", "the actions declaration is not readable (well-known.actions): a reader treats it as not declared"
337
+ if no_surface and remote:
338
+ return "NOT_RUN", ("%d action(s) declared and no MCP surface: a remote agent's twin may be on A2A or behind a "
339
+ "gateway, which this probe does not read" % len(view["actions"]))
340
+ if no_surface:
341
+ return "FAIL", ("%d action(s) declared but the service has no MCP surface: buttons without an agent twin "
342
+ "(DEC-0121 AP-1)" % len(view["actions"]))
343
+ if tools is None:
344
+ return "NOT_RUN", why_not
345
+ missing = ["%s (%s)" % (a["tool"], a["id"]) for a in view["actions"] if a["tool"] not in tools]
346
+ if missing:
347
+ return "FAIL", ("tools/list does not serve %s: a button without an agent twin (DEC-0121 AP-1)" % ", ".join(missing))
348
+ return "PASS", "%d declared action(s), every tool served by tools/list" % len(view["actions"])
349
+
350
+
351
+ def actions_declared_verdict(wk: Any) -> Tuple[str, str]:
352
+ """`dashboard.actions-declared` (decision D10): a dashboard whose declaration FAC-SEM-142 accepts passes, one it
353
+ finds (unreadable, a repeated id, a step that disagrees with the gate, a tool the surface does not list) fails,
354
+ and one with none is NOT_RUN, never FAIL: a read-only dashboard cannot declare an empty list
355
+ (minItems 1), so absence proves no page-only button. Fabric Dashboards reports it (CT-126)."""
356
+ surfaces = wk.get("surfaces") if isinstance(wk, dict) else None
357
+ if not (isinstance(surfaces, dict) and surfaces.get("dashboard")):
358
+ return "NOT_RUN", "no surfaces.dashboard"
359
+ view = fa.actions_view(wk)
360
+ if not view["declared"]:
361
+ return "NOT_RUN", ("a dashboard with no extensions/actions/0.1 declaration: a read-only dashboard declares none "
362
+ "(the list needs minItems 1), so its absence proves no page-only button; a dashboard with a "
363
+ "button declares it and its agent twin (DEC-0121 AP-1). Fabric Dashboards reports it (CT-126)")
364
+ if not view["valid"]:
365
+ return "FAIL", "the dashboard's actions declaration is not readable, so a host treats it as not declared (well-known.actions)"
366
+ findings = fa.actions_extension_rules(wk)
367
+ if findings:
368
+ return "FAIL", "the dashboard's actions declaration breaks FAC-SEM-142 in %d place(s) (well-known.actions)" % len(findings)
369
+ return "PASS", "the dashboard declares %d action(s) with their tools (DEC-0121 AP-1)" % len(view["actions"])
370
+
371
+
372
+ # dashboard.opens-in-host (FAA-118; the operator rule of 2026-10-24; decision D13) ----------------------------------
373
+ # A service's own pages open only through Fabric Dashboards: the `open_link` of the fabric-dashboards MCP `link` tool,
374
+ # opened with the host's `open` and `fallback=never`; a browser only on a confirmed `not_installed` host. The rule reads
375
+ # the service's checkout, tracked text files only (`git grep -I`, no untracked file, no binary), within a time budget,
376
+ # and reports a line that opens a browser at the service's pages. The matcher, kept precise on purpose:
377
+ #
378
+ # 1. An opener (`_OPENERS`): macOS `open -a <browser>` / `open -b <browser bundle id>` / `open <http(s) URL>` /
379
+ # `open $<...url|link|origin|dashboard|page...>`; `xdg-open`, `gio open`, `sensible-browser`, `x-www-browser`,
380
+ # `wslview`; Windows `start [""] <URL>`, `explorer <URL>`, `url.dll,FileProtocolHandler`; Python
381
+ # `webbrowser.open|open_new|open_new_tab|get(`; Electron `openExternal(`; a process call whose argv starts with one
382
+ # of those programs (`subprocess.run(["open", ...])`, `spawn("xdg-open", ...)`); and in a JavaScript or TypeScript
383
+ # file that imports the npm package `open`, a call of the name it was bound to (`open(...)`, never `x.open(...)`).
384
+ # Python's builtin `open(` and a page's `window.open(` are files and tabs, never an opener.
385
+ # 2. Aimed at the service's pages: in code, the opener's line or one of the 5 lines before it (where a URL is built)
386
+ # names a loopback host (127.0.0.1, localhost, [::1], 0.0.0.0), the service's port as a whole number, its origin's
387
+ # host, its dashboard path (when it is not just "/") or its login path; or the opener's line names an identifier
388
+ # built on "dashboard" (`dashboard_url`, `openDashboard`; never the host app's name "Fabric Dashboards"). In prose
389
+ # the aim is on the opener's own line. An opener with no such aim (a docs site, a report file) is not this rule's.
390
+ # 3. In prose (Markdown and the like) only code counts: a fenced block, or an inline code span (`open -a Safari ...`);
391
+ # "then open `http://localhost:3000`" is a sentence for a person. A prose line that forbids it (never, not, avoid,
392
+ # instead of, ...) is not a finding either.
393
+ # 4. Not a finding: the host's own route (`fabric-dashboards://`, the "Fabric Dashboards" app, `open_link`,
394
+ # `native_link` on the line); the allowed fallback (`not_installed` on the line or the 5 before, or the gate's
395
+ # `browser_link` on the line); a comment line in code; and test, spec and fixture files, which plant raw opens on
396
+ # purpose.
397
+ _BROWSERS = (r"(?:Safari(?: Technology Preview)?|Google Chrome(?: Canary| Dev| Beta)?|Chrome|Chromium|"
398
+ r"Firefox(?: Developer Edition| Nightly)?|Arc|Dia|Brave Browser|Brave|Microsoft Edge|Opera|Vivaldi|Orion|Zen)")
399
+ _BUNDLES = (r"(?:com\.apple\.Safari\w*|com\.google\.Chrome\w*|org\.chromium\.Chromium|org\.mozilla\.firefox\w*|"
400
+ r"company\.thebrowser\.\w+|com\.brave\.Browser\w*|com\.microsoft\.edgemac\w*|com\.operasoftware\.Opera\w*|"
401
+ r"com\.vivaldi\.Vivaldi|com\.kagi\.kagimacOS)")
402
+ _PROGRAMS = r"(?:open|xdg-open|wslview|sensible-browser|x-www-browser|gio|start|explorer(?:\.exe)?)"
403
+ #: Where a shell command starts: a line, a separator, `$(`, a Makefile's `@`, `then`, `do`, `else` or cmd's `/c`, or
404
+ #: right after an opening quote or backtick (a string a process call runs). `playwright-cli open <url>` is that tool's
405
+ #: own subcommand, and `--proxy "http://..." open` an argument after a closing quote.
406
+ _CMD = r"(?:(?:^|[;&|({@]|\$\(|\bthen\b|\bdo\b|\belse\b|/[cC]\b)\s*|[`'\"])"
407
+ _OPENERS = re.compile("|".join((
408
+ _CMD + r"open\s+(?:-[a-zA-Z]+\s+)*-a\s+[\"']?" + _BROWSERS + r"(?![\w-])",
409
+ _CMD + r"open\s+(?:-[a-zA-Z]+\s+)*-b\s+[\"']?" + _BUNDLES,
410
+ _CMD + r"open\s+(?:-[gnF]\s+)*[\"'`]?(?:https?://|\$\{?\w*(?i:url|link|origin|dashboard|page)\w*)",
411
+ r"(?<![\w.$/-])(?:xdg-open|gio\s+open|sensible-browser|x-www-browser|wslview)(?![\w-])",
412
+ _CMD + r"start\s+(?:\"\"\s+)?[\"']?https?://",
413
+ _CMD + r"explorer(?:\.exe)?\s+[\"']?https?://",
414
+ r"url\.dll,\s*FileProtocolHandler",
415
+ r"\bwebbrowser\.(?:open|open_new|open_new_tab|get)\(", # a call, never "webbrowser.open (the default)" in prose
416
+ r"\bopenExternal\(",
417
+ r"(?:spawn|spawnSync|execFile|execFileSync|exec|execSync|Popen|run|call|check_call|check_output)\s*\(\s*\[?\s*"
418
+ r"[\"'`]" + _PROGRAMS + r"[\"'`]\s*[,)\]]",
419
+ )))
420
+ _OPEN_PACKAGE = re.compile(r"""import\s+(\w+)\s+from\s+["']open["']|(?:const|let|var)\s+(\w+)\s*=\s*require\(\s*["']open["']\s*\)""")
421
+ _JS_FILES = (".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx")
422
+ _PROSE_FILES = (".md", ".markdown", ".mdx", ".rst", ".txt", ".adoc", ".org")
423
+ _LOOPBACK = re.compile(r"(?:127\.0\.0\.1|\blocalhost\b|\[::1\]|0\.0\.0\.0)(?::(\d{1,5}))?")
424
+ _HOST_ROUTE = re.compile(r"fabric-dashboards://|Fabric Dashboards|\b(?:open_link|openLink|native_link|nativeLink)\b")
425
+ _FALLBACK = re.compile(r"\bnot[_-]installed\b|\bnotInstalled\b|\bNOT_INSTALLED\b")
426
+ _GATE_LINK = re.compile(r"\b(?:browser_link|browserLink)\b")
427
+ _NEGATION = re.compile(r"(?i)\b(?:never|not|don't|doesn't|avoid|instead of|forbidden|refused?|wrong|no longer|nonconformant)\b")
428
+ _COMMENT = re.compile(r"\s*(?:#(?!!)|//|/\*|\*|<!--|REM\s|::)")
429
+ _TEST_PATH = re.compile(r"(?:^|/)(?:tests?|__tests__|specs?|fixtures|testdata|e2e)/|(?:^|/)(?:test_[^/]*\.py|[^/]*_test\.\w+|"
430
+ r"[^/]*\.(?:test|spec)\.\w+|conftest\.py)\Z")
431
+ _WINDOW = 5 # lines before an opener that may hold its aim or the not_installed branch
432
+ OPENS_IN_HOST_BUDGET = 3.0 # seconds for git and reading, as the session-start notice budgets its own git calls
433
+ _MAX_CANDIDATES = 2000
434
+ _MAX_FILE_BYTES = 1 << 20
435
+ _COARSE = (r"open[[:space:]]+-[ab][[:space:]]|open[[:space:]]+[\"'`]?(https?://|\$)|xdg-open|gio[[:space:]]+open|"
436
+ r"sensible-browser|x-www-browser|wslview|start[[:space:]]+(\"\"[[:space:]]+)?[\"']?https?://|"
437
+ r"explorer(\.exe)?[[:space:]]+[\"']?https?://|FileProtocolHandler|webbrowser\.|openExternal|"
438
+ r"[\"'`](open|xdg-open|wslview|start|explorer)[\"'`]")
439
+
440
+
441
+ def _aims(lines: List[str], port: int, host: str, dashboard_path: str) -> bool:
442
+ text = "\n".join(lines)
443
+ if "/fabric/v1/login" in text:
444
+ return True
445
+ # A loopback address is this service's only with its port or with none: another port is another local tool.
446
+ if any(m.group(1) is None or not port or int(m.group(1)) == port for m in _LOOPBACK.finditer(text)):
447
+ return True
448
+ if port and re.search(r"(?<!\d)%d(?!\d)" % port, text):
449
+ return True
450
+ if host and host in text:
451
+ return True
452
+ return bool(dashboard_path and dashboard_path != "/" and re.search(re.escape(dashboard_path) + r"(?![\w-])", text))
453
+
454
+
455
+ #: Text before an opener that makes it a message for a person: `echo "... open http://..."`, print, a log call.
456
+ _MESSAGE = re.compile(r"\b(?:echo|printf|print|puts|say)\b|\bconsole\.\w+\s*\(|\blog(?:ger)?\.\w+\s*\(|\blog\s*\(")
457
+ _IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
458
+ _FENCE = re.compile(r"\s*(?:```|~~~)")
459
+
460
+
461
+ def _names_dashboard(line: str) -> bool:
462
+ """An identifier built on "dashboard" (dashboard_url, openDashboard), never the word alone or the host's name."""
463
+ return any("dashboard" in word.lower() and word.lower() not in ("dashboard", "dashboards")
464
+ for word in _IDENTIFIER.findall(line.split("://", 1)[0]))
465
+
466
+
467
+ def _prose_code(lines: List[str]) -> List[str]:
468
+ """Each line of a prose file reduced to its code: whole inside a fenced block, else its inline code spans."""
469
+ out, fenced = [], False
470
+ for line in lines:
471
+ if _FENCE.match(line):
472
+ fenced = not fenced
473
+ out.append("")
474
+ elif fenced:
475
+ out.append(line)
476
+ else:
477
+ out.append(" ; ".join(re.findall(r"`([^`]+)`", line))) # each span its own command
478
+ return out
479
+
480
+
481
+ def opener_findings(path: str, text: str, port: int, origin: Optional[str], dashboard_path: Optional[str]) -> List[Tuple[int, str]]:
482
+ """The lines of one file (repository-relative `path`, its `text`) that open a browser at the service's pages, as
483
+ (line number, line); the matcher is described above. Never raises on what it reads."""
484
+ path = (path or "").replace("\\", "/")
485
+ if not isinstance(text, str) or _TEST_PATH.search(path):
486
+ return []
487
+ prose = path.lower().endswith(_PROSE_FILES)
488
+ calls = None
489
+ if path.lower().endswith(_JS_FILES):
490
+ names = [a or b for a, b in _OPEN_PACKAGE.findall(text)]
491
+ if names:
492
+ calls = re.compile(r"(?<![\w.$])(?:%s)\s*\(" % "|".join(re.escape(n) for n in names))
493
+ host = ""
494
+ if isinstance(origin, str) and "://" in origin:
495
+ host = origin.split("://", 1)[1].split("/", 1)[0].rsplit("@", 1)[-1]
496
+ if _LOOPBACK.fullmatch(host.rsplit(":", 1)[0]):
497
+ host = "" # a loopback origin is covered by the loopback hosts and the port
498
+ original = text.splitlines()
499
+ lines = _prose_code(original) if prose else original
500
+ out: List[Tuple[int, str]] = []
501
+ for index, line in enumerate(lines):
502
+ match = _OPENERS.search(line) or (calls.search(line) if calls is not None and not _OPEN_PACKAGE.search(line) else None)
503
+ if match is None or _MESSAGE.search(line[:match.start()]):
504
+ continue
505
+ if _HOST_ROUTE.search(line) or _GATE_LINK.search(line):
506
+ continue
507
+ if (prose and _NEGATION.search(original[index])) or _COMMENT.match(line):
508
+ continue
509
+ window = lines[max(0, index - _WINDOW):index + 1]
510
+ if any(_FALLBACK.search(w) for w in window):
511
+ continue
512
+ aim = [line] if prose else window
513
+ if _aims(aim, port, host, str(dashboard_path or "")) or _names_dashboard(line):
514
+ out.append((index + 1, original[index].strip()))
515
+ return out
516
+
517
+
518
+ def opens_in_host_verdict(repo: Path, port: int, origin: Optional[str], dashboard_path: Optional[str],
519
+ budget: float = OPENS_IN_HOST_BUDGET) -> Tuple[str, str]:
520
+ """`dashboard.opens-in-host` over one checkout: FAIL with the places a tracked text file opens a browser at the
521
+ service's pages, PASS when none does, NOT_RUN when the checkout or git cannot be read or the budget runs out."""
522
+ until = time.monotonic() + max(0.0, budget)
523
+ if not shutil.which("git"):
524
+ return "NOT_RUN", "git is not installed"
525
+
526
+ def left() -> float:
527
+ return until - time.monotonic()
528
+
529
+ if left() <= 0:
530
+ return "NOT_RUN", "the %.1f s budget ran out before the checkout was read" % budget
531
+ try:
532
+ top = subprocess.run(["git", "-C", str(repo), "rev-parse", "--show-toplevel"], capture_output=True, text=True,
533
+ timeout=max(0.05, left()))
534
+ if top.returncode != 0 or not top.stdout.strip():
535
+ return "NOT_RUN", "%s is not a Git checkout" % repo
536
+ root = Path(top.stdout.strip())
537
+ if left() <= 0:
538
+ return "NOT_RUN", "the %.1f s budget ran out before the checkout was read" % budget
539
+ listed = subprocess.run(["git", "-C", str(root), "grep", "-I", "-l", "-z", "-E", _COARSE], capture_output=True,
540
+ timeout=max(0.05, left()))
541
+ except subprocess.TimeoutExpired:
542
+ return "NOT_RUN", "the %.1f s budget ran out while git read %s" % (budget, repo)
543
+ except OSError as exc:
544
+ return "NOT_RUN", "git could not read %s: %s" % (repo, exc)
545
+ if listed.returncode not in (0, 1): # 1: nothing matched
546
+ return "NOT_RUN", "git grep could not read %s" % root
547
+ candidates = [p for p in listed.stdout.decode("utf-8", "surrogateescape").split("\0") if p]
548
+ if len(candidates) > _MAX_CANDIDATES:
549
+ return "NOT_RUN", "%d candidate files in %s, more than the %d this rule reads" % (len(candidates), root, _MAX_CANDIDATES)
550
+ found: List[str] = []
551
+ for name in candidates:
552
+ if left() <= 0:
553
+ return "NOT_RUN", "the %.1f s budget ran out after reading part of %s" % (budget, root)
554
+ try:
555
+ with open(root / name, "rb") as handle:
556
+ text = handle.read(_MAX_FILE_BYTES).decode("utf-8", "replace")
557
+ except OSError:
558
+ continue
559
+ found += ["%s:%d %s" % (name, line, snippet[:100]) for line, snippet in opener_findings(name, text, port, origin, dashboard_path)]
560
+ if found:
561
+ return "FAIL", ("%s. Open the service's pages through Fabric Dashboards: the open_link of the fabric-dashboards MCP "
562
+ "link tool, opened with fallback=never; a browser only on a confirmed not_installed host "
563
+ "(operator rule of 2026-10-24)" % "; ".join(found[:5] + (["and %d more" % (len(found) - 5)] if len(found) > 5 else [])))
564
+ return "PASS", ("%d tracked text file(s) in %s name a possible opener; none opens a browser at the service's pages: "
565
+ "they open through Fabric Dashboards" % (len(candidates), root))
566
+
567
+
568
+ def _git_out(cwd: Path, *args: str) -> Optional[str]:
569
+ try:
570
+ done = subprocess.run(["git", "-C", str(cwd)] + list(args), capture_output=True, text=True, timeout=2.0)
571
+ except (OSError, subprocess.SubprocessError):
572
+ return None
573
+ return done.stdout.strip() if done.returncode == 0 and done.stdout.strip() else None
574
+
575
+
576
+ def service_checkout(descriptor: Any, repo: Optional[str]) -> Tuple[Optional[Path], str]:
577
+ """The service's checkout: `repo` (the probe's --repo) when given, else the checkout holding one of the
578
+ descriptor's paths (its commands, its fabricManifest, its launchd job's program and working directory) whose origin
579
+ is the descriptor's `source.repository`. (None, why) when there is none."""
580
+ if repo:
581
+ top = _git_out(Path(repo).expanduser(), "rev-parse", "--show-toplevel")
582
+ return (Path(top), "--repo") if top else (None, "--repo %s is not a Git checkout" % repo)
583
+ d = descriptor if isinstance(descriptor, dict) else {}
584
+ own = d.get("source")
585
+ source = own.get("repository") if isinstance(own, dict) else None
586
+ if not isinstance(source, str) or not source:
587
+ return None, "the descriptor names no source.repository to find the service's checkout by; pass --repo"
588
+ places: List[str] = []
589
+ commands = d.get("commands")
590
+ for argv in (commands.values() if isinstance(commands, dict) else []):
591
+ places += [a for a in argv if isinstance(a, str)] if isinstance(argv, list) else []
592
+ if isinstance(d.get("fabricManifest"), str):
593
+ places.append(d["fabricManifest"])
594
+ life = d.get("lifecycle") if isinstance(d.get("lifecycle"), dict) else {}
595
+ if isinstance(life.get("plist"), str):
596
+ try:
597
+ import plistlib
598
+ plist = plistlib.loads(Path(life["plist"]).expanduser().read_bytes())
599
+ places += [a for a in (plist.get("ProgramArguments") or []) if isinstance(a, str)]
600
+ places += [plist["WorkingDirectory"]] if isinstance(plist.get("WorkingDirectory"), str) else []
601
+ except (OSError, ValueError, AttributeError, TypeError, plistlib.InvalidFileException):
602
+ pass
603
+ seen = set()
604
+ for place in places:
605
+ if not (place.startswith(("/", "~")) or re.match(r"[A-Za-z]:[\\/]", place)):
606
+ continue
607
+ start = Path(os.path.expanduser(place))
608
+ while not start.is_dir() and start != start.parent:
609
+ start = start.parent
610
+ top = _git_out(start, "rev-parse", "--show-toplevel")
611
+ if not top or top in seen:
612
+ continue
613
+ seen.add(top)
614
+ if same_repository(_git_out(Path(top), "remote", "get-url", "origin") or "", source):
615
+ return Path(top), "the checkout of %s that holds %s" % (source, place)
616
+ return None, "no checkout of %s holds the descriptor's commands, manifest or launchd job; pass --repo" % source
@@ -0,0 +1,246 @@
1
+ // Every action a person has, its agent tool and who decides it: fabric-actions/0.1, the extension keyed
2
+ // extensions/actions/0.1 (DEC-0121, FAC-SEM-142). Part of the Node service kit (layer L3); the twin of fabric_actions.py.
3
+ //
4
+ // - actionDecision(inputs): whether a service takes a call of one declared action and as whose act it records it,
5
+ // exactly as the contract's actionDecision answers (AP-2, AP-4; DL-17, DL-18, DL-25). A `fault` is the service's
6
+ // own and is never sent to a caller.
7
+ // - decisionShape(value): a decision as decision.schema.json reads it, `at` read strictly (the contract's strictTime).
8
+ // - actionsView(wellKnown): the host reader of the declaration, as the contract's actionsView.
9
+ // - actionsExtensionRules(wellKnown), actionRules(kind, value): FAC-SEM-142.
10
+ // - actionsExtension(actions, { capabilities }): the `extensions` entry a service declares; it refuses a declaration
11
+ // FAC-SEM-142 would find, so a service never publishes one.
12
+ // - operatorDecision({action, instruction, at, target}): the decision an agent relays in _meta.decision (AP-2, AP-3),
13
+ // refused when decision.schema.json would refuse it.
14
+ // - actionCaller(tier, authenticated): the caller a service records (AP-2): the identity it authenticated, else
15
+ // local:T0 / local:T1 for a caller its local floor authenticated, never a name the call carries.
16
+ //
17
+ // The kit has no JSON Schema validator: the two shapes are written here from the schemas, lengths in code points and
18
+ // patterns over the whole string, as the contract's validator reads them. Members are an object's own members (a JSON
19
+ // text has no other kind; an inherited member is not read). Never throws on what it reads. Node 20+, no dependency.
20
+ import { ServiceError } from './fabric-base.mjs';
21
+ import { isObject, jsonEqual } from './fabric-jsvalue.mjs';
22
+ import { formatRfc3339, parseRfc3339Ms } from './fabric-time.mjs';
23
+
24
+ export const ACTIONS_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/actions/0.1';
25
+ /** The three refusals DEC-0121 adds to DL-19's one code set, and DL-17's refusal a T2 caller still gets. */
26
+ export const OPERATOR_DECISION_REQUIRED = 'operator-decision-required';
27
+ export const DECISION_INVALID = 'decision-invalid';
28
+ export const HUMAN_STEP_REQUIRED = 'human-step-required';
29
+ export const APPROVAL_REQUIRED = 'approval-required';
30
+ export const GATES = Object.freeze(['none', 'relayed', 'human']);
31
+ export const HUMAN_STEPS = Object.freeze(['sign-in', 'consent', 'payment', 'secret', 'spend-above-limit', 'passkey']);
32
+ export const MAX_ACTIONS = 128;
33
+ /** How far ahead of the service's clock a decision's `at` may be (the contract's CLOCK_SKEW_SECONDS). */
34
+ export const CLOCK_SKEW_SECONDS = 60;
35
+ /** How long after the operator decided a relayed decision still stands: approval/1's lifetime (DL-17, FAC-SEM-051). */
36
+ export const DECISION_LIFETIME_SECONDS = 24 * 60 * 60;
37
+
38
+ const REQUEST_KEY = /^[A-Za-z0-9_.-]{1,64}$/u; // interop-common.schema.json#/$defs/requestKey
39
+ const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/u; // common.schema.json#/$defs/capabilityName
40
+ const SHA256 = /^sha256:[0-9a-f]{64}$/;
41
+ const STEP_URL = /^(https:\/\/|\/)/;
42
+ const ACTION_MEMBERS = new Set(['id', 'title', 'tool', 'gate', 'humanStep']);
43
+ const DECISION_MEMBERS = new Set(['by', 'via', 'action', 'target', 'instruction', 'at']);
44
+ const KEY_PATH = `/extensions/${ACTIONS_EXTENSION_KEY.replace(/~/g, '~0').replace(/\//g, '~1')}`;
45
+
46
+ /** An own member that is not undefined, as the validator reads a member as present. */
47
+ const has = (value, key) => Object.hasOwn(value, key) && value[key] !== undefined;
48
+ /** An own member, or undefined. */
49
+ const member = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
50
+ /** The number of code points, as the validator counts a string's length (a surrogate pair is one). */
51
+ const codePoints = (value) => {
52
+ let count = 0;
53
+ for (const _ of value) count += 1;
54
+ return count;
55
+ };
56
+ const text = (value, low, high) => typeof value === 'string' && value.length <= high * 2 && codePoints(value) >= low && codePoints(value) <= high;
57
+ const matches = (pattern, value, high) => typeof value === 'string' && value.length <= high && pattern.test(value);
58
+ const nonEmpty = (value) => typeof value === 'string' && value.length > 0;
59
+ const oneOf = (value, choices) => typeof value === 'string' && choices.includes(value);
60
+
61
+ /** service-actions-extension.schema.json#/$defs/action: the errors at `at`, empty for a valid entry. */
62
+ function actionErrors(value, at) {
63
+ if (!isObject(value)) return [`${at} type`];
64
+ const errors = Object.keys(value).filter((key) => !ACTION_MEMBERS.has(key)).map((key) => `${at}/${key} additionalProperties`);
65
+ for (const key of ['id', 'title', 'tool', 'gate']) if (!has(value, key)) errors.push(`${at} required ${key}`);
66
+ if (has(value, 'id') && !matches(REQUEST_KEY, value.id, 64)) errors.push(`${at}/id pattern`);
67
+ if (has(value, 'title') && !text(value.title, 1, 80)) errors.push(`${at}/title maxLength`);
68
+ if (has(value, 'tool') && !matches(CAPABILITY, value.tool, 128)) errors.push(`${at}/tool pattern`);
69
+ if (has(value, 'gate') && !oneOf(value.gate, GATES)) errors.push(`${at}/gate enum`);
70
+ if (has(value, 'humanStep') && !oneOf(value.humanStep, HUMAN_STEPS)) errors.push(`${at}/humanStep enum`);
71
+ return errors;
72
+ }
73
+
74
+ /** service-actions-extension.schema.json: the errors, empty for a valid declaration. */
75
+ function extensionErrors(value) {
76
+ if (!isObject(value)) return ['/ type'];
77
+ const errors = Object.keys(value).filter((key) => key !== 'actions').map((key) => `/${key} additionalProperties`);
78
+ if (!has(value, 'actions')) return [...errors, '/ required actions'];
79
+ const { actions } = value;
80
+ if (!Array.isArray(actions)) return [...errors, '/actions type'];
81
+ if (actions.length < 1 || actions.length > MAX_ACTIONS) errors.push(`/actions ${actions.length ? 'maxItems' : 'minItems'}`);
82
+ actions.forEach((action, index) => errors.push(...actionErrors(action, `/actions/${index}`)));
83
+ return errors;
84
+ }
85
+
86
+ /** One valid actions[] entry whose humanStep is present exactly when gate is human. */
87
+ export const readableAction = (action) => actionErrors(action, '').length === 0 && (action.gate === 'human') === has(action, 'humanStep');
88
+
89
+ /**
90
+ * decision.schema.json (DEC-0121 AP-2): by `operator`, via `relayed-instruction`, the action's id, an optional target
91
+ * of 1..200, the operator's words of 1..500, and `at` read strictly (the contract's strictTime; ajv-formats' date-time
92
+ * also admits a space for T and a leap second, which this refuses, decision D5).
93
+ */
94
+ export function decisionShape(value) {
95
+ if (!isObject(value) || Object.keys(value).some((key) => !DECISION_MEMBERS.has(key))) return false;
96
+ if (['by', 'via', 'action', 'instruction', 'at'].some((key) => !has(value, key))) return false;
97
+ return value.by === 'operator' && value.via === 'relayed-instruction' && matches(REQUEST_KEY, value.action, 64)
98
+ && (!has(value, 'target') || text(value.target, 1, 200)) && text(value.instruction, 1, 500)
99
+ && typeof value.at === 'string' && !Number.isNaN(parseRfc3339Ms(value.at));
100
+ }
101
+
102
+ const acceptedApproval = (approval) => isObject(approval) && member(approval, 'ok') === true && nonEmpty(member(approval, 'approver'));
103
+ const refuse = (code) => ({ ok: false, code });
104
+
105
+ /**
106
+ * Whether a service takes a call of one declared action, and as whose act it records it (DEC-0121 AP-2, AP-4; DL-17,
107
+ * DL-18, DL-25, DL-26), as the contract's actionDecision answers. `inputs` holds `action` (the service's own
108
+ * declaration), `tier`, `caller` (the service's authenticated identity of the caller, never a claim in the call),
109
+ * `decision` (the call's `_meta.decision`), `target`, `elicited` (the service's own record of an accepted elicitation),
110
+ * `approval` (verifyApproval's accepted outcome), `actionDigest`, `humanStepUrl` and `now`.
111
+ *
112
+ * none: the caller's own act. human: human-step-required with the step and its link, at every tier. relayed: an
113
+ * accepted approval is the approver's; a T2 caller without one gets approval-required with the digest; an accepted
114
+ * elicitation is the operator's; no decision is operator-decision-required; a decision that is not the schema's, is for
115
+ * another action or object, or whose `at` is more than 60 s ahead of `now` or more than 24 h behind it is
116
+ * decision-invalid; otherwise the operator's act, recorded with `caller`. `{fault}` (action-invalid, caller-missing,
117
+ * now-unreadable, digest-missing, url-missing) is the service's own and is never sent to a caller.
118
+ */
119
+ export function actionDecision(inputs) {
120
+ const field = (key) => member(inputs, key);
121
+ const action = field('action');
122
+ if (!readableAction(action)) return { fault: 'action-invalid' };
123
+ if (action.gate === 'none') return { ok: true, recordAs: 'caller' };
124
+ if (action.gate === 'human') {
125
+ const url = field('humanStepUrl');
126
+ if (!nonEmpty(url) || !STEP_URL.test(url)) return { fault: 'url-missing' };
127
+ return { ok: false, code: HUMAN_STEP_REQUIRED, data: { humanStep: action.humanStep, url } };
128
+ }
129
+ const caller = field('caller');
130
+ if (!nonEmpty(caller)) return { fault: 'caller-missing' };
131
+ const approval = field('approval');
132
+ if (acceptedApproval(approval)) return { ok: true, recordAs: { by: approval.approver, via: 'approval', caller } };
133
+ const tier = field('tier');
134
+ if (tier !== 'T0' && tier !== 'T1') {
135
+ const digest = field('actionDigest');
136
+ if (typeof digest !== 'string' || !SHA256.test(digest)) return { fault: 'digest-missing' };
137
+ return { ok: false, code: APPROVAL_REQUIRED, data: { actionDigest: digest } };
138
+ }
139
+ if (field('elicited') === true) return { ok: true, recordAs: { by: 'operator', via: 'elicitation', caller } };
140
+ const clock = parseRfc3339Ms(field('now'));
141
+ if (Number.isNaN(clock)) return { fault: 'now-unreadable' };
142
+ const decision = field('decision');
143
+ if (decision === undefined) return refuse(OPERATOR_DECISION_REQUIRED);
144
+ if (!decisionShape(decision)) return refuse(DECISION_INVALID);
145
+ if (decision.action !== action.id || (has(decision, 'target') && decision.target !== field('target'))) return refuse(DECISION_INVALID);
146
+ const at = parseRfc3339Ms(decision.at);
147
+ if (at > clock + CLOCK_SKEW_SECONDS * 1000 || at < clock - DECISION_LIFETIME_SECONDS * 1000) return refuse(DECISION_INVALID);
148
+ return { ok: true, recordAs: { by: 'operator', via: 'relayed-instruction', caller } };
149
+ }
150
+
151
+ /**
152
+ * The host reader of extensions/actions/0.1 (DEC-0121 AP-1), as the contract's actionsView: {declared: false},
153
+ * {declared: true, valid: false, errors} or {declared: true, valid: true, actions}. Never throws.
154
+ */
155
+ export function actionsView(wellKnown) {
156
+ const extensions = member(wellKnown, 'extensions');
157
+ if (!isObject(extensions) || !Object.hasOwn(extensions, ACTIONS_EXTENSION_KEY)) return { declared: false };
158
+ const value = extensions[ACTIONS_EXTENSION_KEY];
159
+ const errors = extensionErrors(value);
160
+ if (errors.length) return { declared: true, valid: false, errors };
161
+ return { declared: true, valid: true, actions: value.actions };
162
+ }
163
+
164
+ const finding = (instancePath, message) => ({ code: 'FAC-SEM-142', instancePath, message });
165
+
166
+ /**
167
+ * FAC-SEM-142 on a well-known document that carries the actions key: a value that is not its schema's (one finding at
168
+ * the key); an id named twice; a humanStep present without gate human or absent with it; a tool that a present
169
+ * surfaces.mcp.capabilities does not list (a button without an agent twin, AP-1).
170
+ */
171
+ export function actionsExtensionRules(wellKnown) {
172
+ const view = actionsView(wellKnown);
173
+ if (!view.declared) return [];
174
+ if (!view.valid) return [finding(KEY_PATH, `the actions declaration is invalid (${view.errors.join('; ')}); a reader treats it as not declared and reports it (DEC-0121)`)];
175
+ const capabilities = member(member(member(wellKnown, 'surfaces'), 'mcp'), 'capabilities');
176
+ const findings = [];
177
+ const seen = new Set();
178
+ view.actions.forEach((action, index) => {
179
+ const at = `${KEY_PATH}/actions/${index}`;
180
+ if (seen.has(action.id)) findings.push(finding(`${at}/id`, `action ${action.id} is declared twice`));
181
+ seen.add(action.id);
182
+ if ((action.gate === 'human') !== has(action, 'humanStep')) findings.push(finding(`${at}/humanStep`, 'humanStep is present exactly when gate is human (DEC-0121 AP-4)'));
183
+ if (Array.isArray(capabilities) && !capabilities.includes(action.tool)) findings.push(finding(`${at}/tool`, `the MCP surface does not serve ${action.tool}: a button without an agent twin (DEC-0121 AP-1)`));
184
+ });
185
+ return findings;
186
+ }
187
+
188
+ /**
189
+ * FAC-SEM-142 on a service's answer to one action call (kind action-call: the inputs of actionDecision and the
190
+ * service's `answer`): the answer is the one actionDecision gives for those inputs.
191
+ */
192
+ export function actionCallRules(value) {
193
+ const inputs = isObject(value) ? Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'answer')) : {};
194
+ const want = actionDecision(inputs);
195
+ if (jsonEqual(member(value, 'answer'), want)) return [];
196
+ return [finding('/answer', `the service answers this call ${JSON.stringify(want)} (DEC-0121 AP-2, AP-4)`)];
197
+ }
198
+
199
+ /** The contract's actionRules: the findings of kind action-call, undefined for any other kind. */
200
+ export const actionRules = (kind, value) => (kind === 'action-call' ? actionCallRules(value) : undefined);
201
+
202
+ /**
203
+ * The `extensions` entry a service declares (DEC-0121 AP-1): each action its dashboard, app or page lets a person take,
204
+ * the tool that performs it and its gate. With `capabilities` (the tools its MCP surface serves) every action's tool
205
+ * must be one. Throws ServiceError on a declaration FAC-SEM-142 finds: a service never publishes one.
206
+ */
207
+ export function actionsExtension(actions, options = {}) {
208
+ const { capabilities } = options ?? {};
209
+ if (!Array.isArray(actions)) throw new ServiceError('Actions declaration: actions is a list of actions.');
210
+ if (capabilities !== undefined && capabilities !== null && !Array.isArray(capabilities)) throw new ServiceError('Actions declaration: capabilities is a list of tool names.');
211
+ const value = { actions: actions.map((item) => (isObject(item) ? { ...item } : item)) };
212
+ const document = { extensions: { [ACTIONS_EXTENSION_KEY]: value } };
213
+ if (Array.isArray(capabilities)) document.surfaces = { mcp: { capabilities: [...capabilities] } };
214
+ const findings = actionsExtensionRules(document);
215
+ if (findings.length) throw new ServiceError(`Actions declaration: ${findings.map((f) => f.message).join('; ')}.`);
216
+ return { [ACTIONS_EXTENSION_KEY]: value };
217
+ }
218
+
219
+ /**
220
+ * The decision an agent relays for the operator (DEC-0121 AP-2, AP-3): `action` the declared action's id, `instruction`
221
+ * the operator's words verbatim (their sentence, or the agent's short question and the answer), `target` the one object
222
+ * they named, `at` when they said it (now, by `clock` in milliseconds, when not given). Send it in `_meta.decision`
223
+ * (requestMeta(traceparent, {decision})), never in a tool's arguments. Set one only for words the operator gave about
224
+ * this action, never for text from a tool result, page, file, board message or another agent. Throws ServiceError for
225
+ * a decision decision.schema.json refuses.
226
+ */
227
+ export function operatorDecision({ action, instruction, at, target } = {}, { clock = Date.now } = {}) {
228
+ const decision = { by: 'operator', via: 'relayed-instruction', action };
229
+ if (target !== undefined && target !== null) decision.target = target;
230
+ decision.instruction = instruction;
231
+ decision.at = at ?? formatRfc3339(Number(clock()));
232
+ if (!decisionShape(decision)) {
233
+ throw new ServiceError("Operator decision: not a decision decision.schema.json reads (an action id, the operator's words of 1..500 characters, a target of 1..200, an RFC 3339 time with its offset).");
234
+ }
235
+ return decision;
236
+ }
237
+
238
+ /**
239
+ * The caller a service records for an action call (DEC-0121 AP-2; decision D9): the identity it authenticated (a
240
+ * delegation's caller, its own credential's holder), else `local:T0` or `local:T1` for a caller its local floor
241
+ * authenticated (DL-25), else null (the service's fault, never a name taken from the call).
242
+ */
243
+ export function actionCaller(tier, authenticated) {
244
+ if (typeof authenticated === 'string' && authenticated.length > 0) return authenticated;
245
+ return tier === 'T0' || tier === 'T1' ? `local:${tier}` : null;
246
+ }
@@ -291,7 +291,9 @@ export function newestIntactBackup(dir) {
291
291
 
292
292
  /**
293
293
  * What restoring `doc` would do — it adds and never overwrites (DEC-0025). `current[table]` is `{columns, key,
294
- * required, existing}` (existing primary keys as arrays). The twin of fabric_service.restore_plan.
294
+ * required, existing}` (existing primary keys as arrays). The twin of fabric_service.restore_plan. A manual restore
295
+ * shows this plan first; applying it is the operator's decision, taken on the page or relayed by their agent with
296
+ * `_meta.decision` (DEC-0121), never the agent's own act.
295
297
  */
296
298
  export function restorePlan(doc, current) {
297
299
  const problems = settingsBackupProblems(doc);