@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.83.1

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 (114) hide show
  1. package/CHANGELOG.md +122 -3
  2. package/README.md +6 -3
  3. package/SECURITY.md +4 -3
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  105. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  106. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -3,7 +3,6 @@ accept the exact digest, effect, progress, complete; stop when fenced). The sess
3
3
  in fabric_board_link and are re-exported here. Part of the board connector (FAA-65, layer L4)."""
4
4
  from __future__ import annotations
5
5
 
6
- import inspect
7
6
  import json
8
7
  import random
9
8
  import re
@@ -28,6 +27,8 @@ from fabric_board_link import ( # noqa: E402,F401 - re-exported: callers import
28
27
  BoardSession,
29
28
  Checkpoint,
30
29
  PERSONAL_BOARD,
30
+ notice_subject as _notice_subject,
31
+ takes_subject as _takes_subject,
31
32
  )
32
33
  from fabric_board_slots import ( # noqa: E402,F401 - the slot records (FAA-155, FAA-163); the constants re-exported
33
34
  HOLD_RETRY_MAX_SECONDS,
@@ -51,6 +52,9 @@ IDLE_LEAVE_SECONDS = 3.0
51
52
 
52
53
  EVENT_SCHEMA = "urn:fabric:schema:board-responder-event:1"
53
54
 
55
+ #: `board.down` is said once per outage window: said again only after the board stayed up this long (FAA-187 (22)).
56
+ DOWN_QUIET_SECONDS = 300.0
57
+
54
58
  #: On these the attempt is no longer ours: stop acting at once (C4).
55
59
  STOP_CODES = frozenset(["fenced", "lease_expired", "authority_changed"])
56
60
 
@@ -60,8 +64,6 @@ TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
60
64
  NOT_OURS = frozenset(["queued", "claimed", "answered"])
61
65
 
62
66
 
63
-
64
-
65
67
  class WorkContext:
66
68
  """What a handler may do while it works: `progress(note)` posts a note on the request's thread (and renews the
67
69
  lease); `begin_effect(summary)` must be called (and must return) before the handler spends or acts outside
@@ -70,12 +72,12 @@ class WorkContext:
70
72
  `mandate_covers(call)` whether it covers a call (`mandate_covers`' answer), None when none travelled."""
71
73
 
72
74
  def __init__(self, responder: "Responder", entry: Dict[str, Any], mandate: Optional[Dict[str, Any]] = None,
73
- caller: Optional[str] = None):
74
- self._responder, self._entry, self.mandate, self._caller = responder, entry, mandate, caller
75
+ caller: Optional[str] = None, caller_agent: Optional[str] = None):
76
+ self._responder, self._entry, self.mandate, self._caller, self._agent = responder, entry, mandate, caller, caller_agent
75
77
  self.stopped = threading.Event()
76
78
 
77
79
  def mandate_covers(self, call: Dict[str, Any]) -> Optional[Dict[str, Any]]:
78
- return _link.covers(self.mandate, call, self._responder.agent_id, self._responder.wall, self._caller)
80
+ return _link.covers(self.mandate, call, self._responder.agent_id, self._responder.wall, self._caller, self._agent)
79
81
 
80
82
  def progress(self, note: Optional[str] = None) -> None:
81
83
  if self.stopped.is_set():
@@ -100,33 +102,6 @@ class _Work:
100
102
  Handler = Callable[[BoardRequest, WorkContext], Dict[str, Any]]
101
103
 
102
104
 
103
-
104
- def _notice_subject(value: Dict[str, Any], project: str, episode: Optional[str] = None) -> Dict[str, str]:
105
- """A notice's subject (`{type, id}`): its request, else its capability, else its episode (a cause such as a block's
106
- code, so two causes are two episodes for a host, seam r31), else the Project."""
107
- for kind, given in (("request", value.get("request")), ("capability", value.get("capability")), ("episode", episode)):
108
- if isinstance(given, str) and 0 < len(given) <= 128:
109
- return {"type": kind, "id": given}
110
- return {"type": "project", "id": project}
111
-
112
-
113
- def _takes_subject(emit: Callable[..., Any]) -> bool:
114
- """Whether an emit's signature names a `subject` parameter, as JsonlEventLog.append does. Any other emit (one with
115
- only **kwargs, which may forward them to a sink without it or set its own, or one whose signature cannot be read)
116
- is called as 0.55.0 called it, without one (0.56.0 unit r33). A subject already bound into the emit, as with
117
- functools.partial(log.append, subject=...), stands.
118
-
119
- The signature is the emit's own, not a wrapped function's: a functools.wraps forwarder that sets its own subject
120
- would otherwise be passed a second one (0.56.0 unit r34). Nothing here raises, so a notice never stops the
121
- responder."""
122
- try:
123
- params = inspect.signature(emit, follow_wrapped=False).parameters.values()
124
- return any(p.name == "subject" and p.kind is not inspect.Parameter.VAR_KEYWORD
125
- and (p.default is inspect.Parameter.empty or p.default is None) for p in params)
126
- except Exception: # noqa: BLE001 - an unreadable signature (or a default that raises) is called without one
127
- return False
128
-
129
-
130
105
  class Responder(SlotKeeper):
131
106
  """One service's responder for one Project and one or more capabilities, one request at a time.
132
107
 
@@ -142,9 +117,11 @@ class Responder(SlotKeeper):
142
117
  emit: Optional[Callable[..., Any]] = None, service: str = "service",
143
118
  now: Callable[[], float] = time.monotonic, rand: Callable[[], float] = random.random,
144
119
  spawn: Optional[Callable[[Callable[[], None]], Any]] = None, session_factory: Optional[Callable[..., BoardSession]] = None,
145
- agent_id: Optional[str] = None, mandate_from: Any = None, wall: Callable[[], float] = time.time):
146
- if admit is None or handler is None:
147
- raise BoardError("invalid_arguments", "A responder needs the service's own input gate (admit) and a handler.")
120
+ agent_id: Optional[str] = None, mandate_from: Any = None, wall: Callable[[], float] = time.time,
121
+ identify_agent: Optional[Callable[[Dict[str, Any]], Optional[str]]] = None, links: Any = None):
122
+ if admit is None or handler is None or not (identify_agent is None or callable(identify_agent)):
123
+ raise BoardError("invalid_arguments", "A responder needs the service's own input gate (admit) and a handler; "
124
+ "identify_agent, when given, is a function of the sender (FAA-135).")
148
125
  caps = list(capabilities)
149
126
  if not caps or any(not isinstance(c, str) or not _CAPABILITY.fullmatch(c) for c in caps) or len(set(caps)) != len(caps):
150
127
  raise BoardError("invalid_arguments", "Name each capability once, [a-z][a-z0-9._-]{1,127}.")
@@ -152,8 +129,10 @@ class Responder(SlotKeeper):
152
129
  raise BoardError("invalid_arguments", "%s is a Project's primary responder's, a session's: a service answers "
153
130
  "the capabilities it declares (DEC-0140 PR-1)." % _fs.PRIMARY_CAPABILITY)
154
131
  self.hub, self.project = hub, _hub._project(project_id)
155
- #: FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; None reads none).
132
+ #: FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; None reads none), and
133
+ #: bind its chain with the agent id identify_agent names for the sender (asked only when a mandate travelled).
156
134
  self.mandate_from, self.agent_id, self.wall = _link.mandate_options(agent_id, mandate_from), agent_id, wall
135
+ self.identify_agent, self.links = identify_agent, _link.notice_links(links) # FAA-187 (21): a link per notify kind
157
136
  self.capabilities = caps
158
137
  self.credential_file, self.handler, self.admit, self.observe = Path(credential_file), handler, admit, observe
159
138
  self.checkpoint = Checkpoint(Path(checkpoint_file))
@@ -172,6 +151,7 @@ class Responder(SlotKeeper):
172
151
  self.claim_keys: Dict[str, str] = {}
173
152
  self._probing: set = set() # moved slots whose move-back claim lost its answer: asked again under the same key
174
153
  self._said: Dict[str, str] = {}
154
+ self._up_since: Optional[float] = None # when the board was last seen up after a fault (FAA-187 (22))
175
155
  self._rr = 0
176
156
  #: The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS.
177
157
  self.hold_delay = POLL_SECONDS
@@ -203,6 +183,8 @@ class Responder(SlotKeeper):
203
183
  # goes once, without a subject there (unit r31, seam r31).
204
184
  if _takes_subject(self._emit_to):
205
185
  extra["subject"] = _notice_subject(value, self.project, episode)
186
+ if notify and kind in getattr(self, "links", {}): # the page that resolves it, which the service named (FAA-187 (21))
187
+ extra["link"] = self.links[kind]
206
188
  try:
207
189
  self._emit_to(kind, level, text, notify=notify, **extra)
208
190
  except Exception: # noqa: BLE001 - an event that cannot be written never stops the responder
@@ -267,8 +249,8 @@ class Responder(SlotKeeper):
267
249
  self.slots = {}
268
250
  self._recover(status, [c for c in self.capabilities if c in grant.capabilities])
269
251
  self.state = "ready"
270
- for kind in ("board.down", "board.blocked") + (() if missing else ("board.access.needed",)):
271
- self._said.pop(kind, None)
252
+ for kind in ("board.blocked",) + (() if missing else ("board.access.needed",)):
253
+ self._said.pop(kind, None) # board.down waits for its quiet window (step)
272
254
 
273
255
  def _enroll(self, capability: str) -> None:
274
256
  args = {"idempotency": self._idem(self._key("enroll")), "capability": capability}
@@ -525,6 +507,8 @@ class Responder(SlotKeeper):
525
507
  self.backoff.reset()
526
508
  if self.state == "down": # a lost call was sent again and answered: the session held throughout
527
509
  self.state = "ready"
510
+ self._up_since = now if self._up_since is None else self._up_since
511
+ if now - self._up_since >= DOWN_QUIET_SECONDS: # up a whole window: a later outage is said again
528
512
  self._said.pop("board.down", None)
529
513
  return self.interval()
530
514
  except (BoardError, _hub.HubError) as exc:
@@ -542,7 +526,7 @@ class Responder(SlotKeeper):
542
526
  return min(renewed) + LEASE_SECONDS - self.now() if renewed and self.session is not None else 0.0
543
527
 
544
528
  def _fault(self, exc: Exception) -> float:
545
- code, slots = getattr(exc, "code", "unreachable"), self.slots.values()
529
+ code, slots, self._up_since = getattr(exc, "code", "unreachable"), self.slots.values(), None
546
530
  connecting = self.state == "connecting" and not any(s.get("state") == "held" for s in slots)
547
531
  wait = self.backoff.next()
548
532
  left = self._lease_left() - LEASE_MARGIN_SECONDS
@@ -613,10 +597,11 @@ class Responder(SlotKeeper):
613
597
  for capability in list(self._suspect): # kept until com.status answered, so it is asked before any enroll
614
598
  self._gone(capability)
615
599
  self._suspect.discard(capability)
600
+ shared: Dict[str, Any] = {} # one com.status read a tick for every moved slot (FAA-194 (5))
616
601
  for capability, slot in list(self.slots.items()):
617
602
  if slot.get("state") == "moved": # never enrolled again on its own, even when the new holder lapses (FAA-155)
618
603
  if self.now() - slot.get("renewed_at", 0) >= RENEW_SECONDS - 0.5:
619
- self._moved_back(capability) # unless com.status shows a later generation it may hold (FAA-165)
604
+ self._moved_back(capability, shared) # unless com.status shows a later generation it may hold (FAA-165)
620
605
  continue
621
606
  if slot.get("state") == "paused":
622
607
  if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0) and not self._gone(capability):
@@ -699,15 +684,14 @@ class Responder(SlotKeeper):
699
684
  "revision": receipt["request"]["revision"], "step": "claimed", "keys": {}, "effect": False}
700
685
  self.checkpoint.put(entry)
701
686
  message = self._call("com.get", {"message": entry["message"]})
702
- req = message.get("request") if isinstance(message, dict) else None
703
- if message.get("digest") != entry["digest"]:
687
+ problem = _link.read_back_problem(message, entry, self.project)
688
+ if problem == "digest-mismatch":
704
689
  self.checkpoint.drop(entry["request"])
705
690
  self._emit("board.request.skipped", "warning", "A claimed request was not accepted: the message read back carries "
706
691
  "another digest than the claim. Nothing was done; the claim lapses.", request=entry["request"],
707
692
  capability=capability, reason="digest-mismatch")
708
693
  return
709
- if not isinstance(req, dict) or req.get("id") != entry["request"] or req.get("capability") != capability \
710
- or req.get("target") != self.project:
694
+ if problem:
711
695
  self.checkpoint.drop(entry["request"])
712
696
  self._emit("board.request.skipped", "warning", "A claimed request was not accepted: it is not addressed to this "
713
697
  "Project and capability. Nothing was done; the claim lapses.", request=entry["request"],
@@ -754,12 +738,15 @@ class Responder(SlotKeeper):
754
738
  self.checkpoint.put(entry)
755
739
  return True
756
740
 
757
- def _run(self, entry: Dict[str, Any], request: BoardRequest) -> None:
741
+ def _run(self, entry: Dict[str, Any], request: BoardRequest, problem: Optional[str] = None) -> None:
758
742
  mandate, unread = _link.read_mandate(request, self.mandate_from, self.agent_id)
743
+ if problem and (mandate is not None or unread): # FAA-196 (1): a resume read back another message than accepted
744
+ mandate, unread = None, problem
759
745
  if unread: # by its code alone: the block holds the operator's words
760
746
  self._emit("board.request.mandate_unread", "warning", "Request %s carries a mandate the kit does not read (%s); "
761
747
  "it is worked without one." % (entry["request"], unread), request=entry["request"], reason=unread)
762
- ctx = WorkContext(self, entry, mandate, request.sender.get("project") if isinstance(request.sender, dict) else None)
748
+ sender = request.sender if isinstance(request.sender, dict) else {}
749
+ ctx = WorkContext(self, entry, mandate, sender.get("project"), _link.sender_agent(self.identify_agent, sender, mandate))
763
750
  work = _Work(entry, request, ctx)
764
751
  work.renewed_at = self.now()
765
752
  self.current = work
@@ -781,7 +768,7 @@ class Responder(SlotKeeper):
781
768
  self._complete(entry)
782
769
  return
783
770
  message = self._call("com.get", {"message": entry["message"]})
784
- self._run(entry, BoardRequest(message, entry["capability"]))
771
+ self._run(entry, BoardRequest(message, entry["capability"]), _link.read_back_problem(message, entry, self.project))
785
772
 
786
773
  def _fenced(self, op: str, entry: Dict[str, Any], key: str, extra: Optional[Dict[str, Any]] = None,
787
774
  apply: Optional[Callable[[Dict[str, Any]], None]] = None) -> Optional[Dict[str, Any]]:
@@ -125,19 +125,20 @@ class SlotKeeper:
125
125
  self._note(capability, dict(held, moved=now)) # said again at the next start if it cannot be written
126
126
  if "moved" not in held: # once, even across restarts
127
127
  self._emit("board.slot.moved", "warning", "The %s slot on this Project moved to another session. This service stopped answering it; accepted "
128
- "work stays `accepted` (work begun is the new holder's). The operator brings it back by take-back (once the "
129
- "other session lets it go, or at a restart with accepted work waiting; a restart alone does not) or, where "
130
- "Fabric offers it, a move back here. Else ask its maintainer." % capability,
128
+ "work stays accepted (work begun is the new holder's). The operator brings it back by take-back (once the "
129
+ "other session lets it go, or at a restart or reconnect with accepted work waiting; neither alone does) or, "
130
+ "where Fabric can, a move back here. Else ask its maintainer." % capability,
131
131
  notify=True, episode=capability, capability=capability, generation=now, held=held["generation"])
132
132
  return True
133
133
 
134
- def _moved_back(self, capability: str) -> None:
134
+ def _moved_back(self, capability: str, shared: Optional[Dict[str, Any]] = None) -> None:
135
135
  """A moved slot asked about once per later generation (FAA-165): the operator may move it back to this session
136
136
  (DEC-0140 PR-2), which this client cannot hear, so when com.status shows a generation past the recorded move, one
137
137
  `com.claim` asks whether the slot is its own. A claim that lands makes it held again and starts what it handed
138
138
  out; a `lease_expired` one (the move reached this session but its lease lapsed) enrolls again; a fenced one is
139
139
  another session's, recorded so the same generation is not asked about twice. Never an enroll or a replace on a
140
- slot another session holds: a slot moved off stays off unless it is handed back."""
140
+ slot another session holds: a slot moved off stays off unless it is handed back. `shared` holds the tick's one
141
+ com.status read, shared by every moved slot (FAA-194 (5))."""
141
142
  if self.current is not None or self.stopping:
142
143
  return # a claim now could hand out a second request, or start work while the responder stops
143
144
  held = self.checkpoint.slots.get(capability) or {}
@@ -145,7 +146,12 @@ class SlotKeeper:
145
146
  if held.get("project") != self.project or not _is_int(seen, 1):
146
147
  return
147
148
  (self.slots.get(capability) or {})["renewed_at"] = self.now()
148
- status = self._status()
149
+ if shared is not None and "status" in shared:
150
+ status = shared["status"]
151
+ else:
152
+ status = self._status()
153
+ if shared is not None:
154
+ shared["status"] = status
149
155
  now = _whole(next((r.get("generation") for r in status.get("responders") or [] if isinstance(r, dict) and r.get("capability") == capability), None))
150
156
  if not _is_int(now, 1) or now <= seen:
151
157
  return
@@ -202,7 +208,9 @@ class SlotKeeper:
202
208
  self.slots[capability] = {"state": "held", "generation": generation, "renewed_at": float("-inf")} # no lease yet
203
209
  self._held_at(capability, generation)
204
210
  self._said.pop("board.slot.moved", None) # a later move is said again
205
- return True
211
+ self._emit("board.slot.returned", "info", "The %s slot on this Project is this service's again: the operator moved it "
212
+ "back here, and it answers it from now on." % capability, capability=capability, generation=generation)
213
+ return True # FAA-194 (8): the feed no longer ends on the move's warning
206
214
 
207
215
  def take_back(self, capability: str) -> None:
208
216
  """The operator's act, never the kit's: forget the move; the next tick enrolls once the slot is free (FAA-155)."""
@@ -6,7 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import re
8
8
  import urllib.parse
9
- from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
9
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
10
10
 
11
11
  import sys as _sys # noqa: E402
12
12
  from pathlib import Path as _Path # noqa: E402
@@ -52,7 +52,8 @@ _KEY_SHAPE = re.compile(
52
52
  r"(?:^|[^A-Za-z0-9])(?:sk-(?=[A-Za-z0-9_-]*[0-9])(?:(?=[A-Za-z0-9_-]*[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})"
53
53
  r"|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}"
54
54
  r"|(?:bot)?[0-9]{6,12}:(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])[A-Za-z0-9_-]{34,35}(?![A-Za-z0-9_-]))")
55
- _URL_SCHEME = re.compile(r"[a-z][a-z0-9+.-]*://", re.IGNORECASE | re.ASCII)
55
+ _SCHEME_LETTERS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
56
+ _SCHEME_CHARS = _SCHEME_LETTERS | frozenset("0123456789+.-")
56
57
  _CLOSING = ")]}>\"'.,;:!?"
57
58
  _TRIED_BACK = 1
58
59
 
@@ -139,13 +140,33 @@ def _shape_problems(value: Any) -> List[str]:
139
140
  return problems
140
141
 
141
142
 
143
+ def _scheme_spans(piece: str) -> List[Tuple[int, int]]:
144
+ """The (start, end) of each URL scheme and its `://` in a piece, where `[a-z][a-z0-9+.-]*://` (case-insensitive,
145
+ ASCII) finds them left to right, read in linear time (FAA-322: the pattern tried every letter of a long run with no
146
+ `://`). From each `://` the run of scheme characters before it is read back, and the match starts at its first
147
+ letter at or after where the last match ended."""
148
+ spans: List[Tuple[int, int]] = []
149
+ after, at = 0, piece.find("://")
150
+ while at >= 0:
151
+ start = at
152
+ while start > after and piece[start - 1] in _SCHEME_CHARS:
153
+ start -= 1
154
+ while start < at and piece[start] not in _SCHEME_LETTERS:
155
+ start += 1
156
+ if start < at:
157
+ spans.append((start, at + 3))
158
+ after = at + 3
159
+ at = piece.find("://", at + 1)
160
+ return spans
161
+
162
+
142
163
  def _urls_in(value: str) -> List[str]:
143
164
  """The URLs a member may hold, from every scheme in each whitespace-delimited piece, without the closing brackets,
144
165
  quotes and punctuation that end a sentence, and with _TRIED_BACK of them put back (`]` closes an IPv6 host)."""
145
166
  urls: List[str] = []
146
167
  for piece in _SPACES.split(value):
147
- for match in _URL_SCHEME.finditer(piece):
148
- url = piece[match.start():]
168
+ for start, _end in _scheme_spans(piece):
169
+ url = piece[start:]
149
170
  end = len(url)
150
171
  while end > 0 and url[end - 1] in _CLOSING:
151
172
  end -= 1
@@ -160,8 +181,8 @@ def _long_run(value: str) -> bool:
160
181
  """A long secret-shaped run in a piece read whole; in a piece that holds a URL, the text before its first scheme's
161
182
  `://` (the URL itself is read by secret_url)."""
162
183
  for piece in _SPACES.split(value):
163
- found = _URL_SCHEME.search(piece)
164
- cut = piece.find("://", found.start()) if found else -1
184
+ found = _scheme_spans(piece)
185
+ cut = found[0][1] - 3 if found else -1
165
186
  if _long_secret(piece if cut < 0 else piece[:cut]):
166
187
  return True
167
188
  return False
@@ -46,6 +46,8 @@ from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
46
46
  )
47
47
  from fabric_paths import atomic_write as _atomic_write # noqa: E402 - the kit's private writer (FAA-85)
48
48
  import fabric_peer_owner as _po # noqa: E402 - a loopback key set is read from this account's listener only (FAA-255)
49
+ import fabric_secrets # noqa: E402 - the WHATWG host reading (FAA-98, FAA-309)
50
+ import fabric_socket_proof # noqa: E402 - the one loopback host rule (FAA-187 (15))
49
51
  from fabric_mandate import effect_closure # noqa: E402,F401 - defined once beside the mandate that reads it too (DEC-0125)
50
52
 
51
53
  REFUSAL_CODES = (
@@ -621,16 +623,10 @@ def action_digest(action: Any) -> str:
621
623
 
622
624
  # --- the estate's keys ----------------------------------------------------------------------------------------
623
625
 
624
- def _loopback(host: str) -> bool:
625
- if host == "localhost":
626
- return True
627
- try:
628
- address = ipaddress.ip_address(host)
629
- except ValueError:
630
- return False
631
- # A zone (`::1%eth0`) is no loopback: urllib decodes `%3A80` in it into another address (0.56.0 seam r13).
632
- # Nor an IPv4-mapped address, which Python 3.13+ counts as loopback and 3.9 and Node do not (0.56.0 seam r14).
633
- return address.is_loopback and not getattr(address, "scope_id", None) and not getattr(address, "ipv4_mapped", None)
626
+ #: A key set source's loopback host, the kit's one rule (fabric_socket_proof.loopback_host), shared with fabric_http's
627
+ #: check_url, so a source starts a service on both kits alike (FAA-187 (15); `127.1`, `2130706433`, a name under
628
+ #: `.localhost`). A zone and an IPv4-mapped address stay refused, as before (0.56.0 seam r13, r14).
629
+ _loopback = fabric_socket_proof.loopback_host
634
630
 
635
631
 
636
632
  # fabric_http._URL_ENDS (this module may not import fabric_http; test_fabric_delegation pins them equal).
@@ -663,7 +659,16 @@ def _source_authority_ok(parts: urllib.parse.SplitResult) -> bool:
663
659
  parts.port # noqa: B018 - raises on a port above 65535
664
660
  except ValueError:
665
661
  return False
666
- return bool(parts.hostname)
662
+ # A host `new URL` refuses for its structure (`a127.0.0.1`, `09`), or one Python's IDNA codec cannot write for
663
+ # http.client (U+180E), is refused here as check_url refuses it, never read and failed on every call (FAA-187 (14),
664
+ # (16)).
665
+ host = parts.hostname or ""
666
+ try:
667
+ if not host.isascii():
668
+ host.encode("idna")
669
+ except UnicodeError:
670
+ return False
671
+ return bool(host) and not fabric_secrets.url_refused(parts.geturl())
667
672
 
668
673
 
669
674
  class _NoRedirect(urllib.request.HTTPRedirectHandler):
@@ -734,7 +739,7 @@ def _fetch(source: str) -> Any:
734
739
  body += chunk
735
740
  if deadline["fired"] or time.monotonic() > deadline["end"]:
736
741
  raise TimeoutError("the key set did not arrive within %s s" % JWKS_FETCH_SECONDS)
737
- return json.loads(body[:65536])
742
+ return json.loads(body[:65536], parse_constant=_no_constant) # NaN is no JSON, as JSON.parse reads it (FAA-194)
738
743
  except urllib.error.HTTPError as exc:
739
744
  exc.close()
740
745
  raise
@@ -745,7 +750,7 @@ def _fetch(source: str) -> Any:
745
750
  finally:
746
751
  if deadline["timer"] is not None:
747
752
  deadline["timer"].cancel()
748
- return json.loads(Path(source).expanduser().read_text())
753
+ return json.loads(Path(source).expanduser().read_text(), parse_constant=_no_constant)
749
754
 
750
755
 
751
756
  class JwksCache:
@@ -55,7 +55,7 @@ from fabric_usage import (
55
55
  DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
56
56
 
57
57
 
58
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.7"
58
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.1.8"
59
59
 
60
60
 
61
61
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
@@ -197,9 +197,11 @@ def read_descriptors(directory: Optional[Path] = None) -> List[Tuple[Path, Dict[
197
197
  return found
198
198
  for path in sorted(root.glob("*.json")):
199
199
  try:
200
- found.append((path, json.loads(path.read_text(encoding="utf-8"))))
201
- except (OSError, ValueError):
200
+ value = json.loads(path.read_text(encoding="utf-8"))
201
+ except (OSError, ValueError, RecursionError):
202
202
  continue
203
+ if isinstance(value, dict): # a file that holds no object is no descriptor (FAA-200 (50))
204
+ found.append((path, value))
203
205
  return found
204
206
 
205
207
 
@@ -222,10 +224,11 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
222
224
  return problems
223
225
  if descriptor["protocol"] != PROTOCOL:
224
226
  problems.append("protocol must be %s" % PROTOCOL)
225
- if not _ID.match(str(descriptor["id"])):
226
- problems.append("id must match %s" % _ID.pattern)
227
- if not _INSTANCE.match(str(descriptor["instance"])):
228
- problems.append("instance must match %s" % _INSTANCE.pattern)
227
+ for key, pattern in (("id", _ID), ("instance", _INSTANCE)): # a string before its pattern (FAA-200 (9), (36))
228
+ if not isinstance(descriptor[key], str):
229
+ problems.append("%s must be a string" % key)
230
+ elif not pattern.match(descriptor[key]):
231
+ problems.append("%s must match %s" % (key, pattern.pattern))
229
232
  if remote:
230
233
  problems.extend(remote_origin_problems(descriptor["origin"]))
231
234
  elif "origin" in descriptor and not _ORIGIN.match(str(descriptor["origin"])):
@@ -234,11 +237,11 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
234
237
  if "tokenFile" not in auth:
235
238
  problems.append("auth.tokenFile is required")
236
239
  elif not _LOCAL_PATH.match(str(auth["tokenFile"])):
237
- problems.append("auth.tokenFile must be a local path, never a network share")
240
+ problems.append("auth.tokenFile must be an absolute local path (/, ~/ or a drive letter), never a relative path, never a network share")
238
241
  paths = descriptor.get("paths") or {}
239
242
  for value in ([paths["data"]] if "data" in paths else []) + list(paths.get("logs") or []):
240
243
  if not _LOCAL_PATH.match(str(value)):
241
- problems.append("paths must be local paths, never a network share: %r" % value)
244
+ problems.append("paths must be absolute local paths (/, ~/ or a drive letter), never a relative path, never a network share: %r" % value)
242
245
  header = auth.get("header", "Authorization")
243
246
  scheme = auth.get("scheme", "Bearer")
244
247
  if not (isinstance(header, str) and _AUTH_HEADER.fullmatch(header)):
@@ -405,7 +408,7 @@ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
405
408
  elif "@" in repository and not _HIERARCHICAL.match(repository): # 0.62.0 readings 7: never an scp form's user
406
409
  problems.append("source.repository with a user is a URL with an authority (https://user@host/path), never an scp form")
407
410
  elif fabric_secrets.repository_credential(repository): # its name is no key (0.62.0 seam reading 6)
408
- problems.append("FAC-SEM-086: /source/repository holds a value shaped like a credential; it names a repository")
411
+ problems.append("FAC-SEM-086: /source/repository holds a value shaped like a credential; it names a repository: write the repository's name in lower case if it is one")
409
412
  if "peer" in descriptor:
410
413
  peer = descriptor.get("peer")
411
414
  if not isinstance(peer, dict):
@@ -589,8 +592,15 @@ def port_listener_refusal(port: int, reader: Optional[Any] = None) -> Optional[s
589
592
  "FAA-225)." % (port, uid))
590
593
 
591
594
 
595
+ def warn_no_source(descriptor: Dict[str, Any], stacklevel: int = 2) -> None:
596
+ """The rewrite warning of a descriptor that names no source.repository (FAA-198)."""
597
+ me = "%s.%s" % (descriptor["id"], descriptor["instance"])
598
+ warnings.warn("Descriptor %s %s; a new install is refused without it." % (me, NO_SOURCE_REPOSITORY),
599
+ NoSourceRepositoryWarning, stacklevel=stacklevel + 1)
600
+
601
+
592
602
  def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None, *, _stacklevel: int = 2,
593
- port_owner: Optional[Any] = None) -> Path:
603
+ port_owner: Optional[Any] = None, _warned: bool = False) -> Path:
594
604
  """Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim), and a port another
595
605
  OS account listens on (FAA-225; ``port_owner(port) -> (kind, uid) | None`` reads it, the kit's own by default).
596
606
 
@@ -604,9 +614,8 @@ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = Non
604
614
  refusal = source_refusal(descriptor, root)
605
615
  if refusal:
606
616
  raise ServiceError(refusal)
607
- if not names_repository(descriptor):
608
- warnings.warn("Descriptor %s %s; a new install is refused without it." % (me, NO_SOURCE_REPOSITORY),
609
- NoSourceRepositoryWarning, stacklevel=_stacklevel)
617
+ if not names_repository(descriptor) and not _warned: # register_remote warns itself, before its token (FAA-200 (1))
618
+ warn_no_source(descriptor, _stacklevel)
610
619
  # DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
611
620
  port = None if placement_of(descriptor) == "remote" or "origin" not in descriptor else port_of(descriptor["origin"])
612
621
  for path, other in read_descriptors(root):
@@ -51,7 +51,9 @@ def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subj
51
51
  link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
52
52
  span_id: Optional[str] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
53
53
  """One activity event: one sentence a person reads, never a machine id. `level` is one of `LEVELS`; `warn`, the
54
- dashboard chip's name, is stored as `warning`. `data` (DEC-0070) adds machine-readable
54
+ dashboard kit's chip for "a person is needed" (`.fd-s-warn`), is stored as `warning`, the feed's name for the same
55
+ level, so a reader that draws chips shows a stored `warning` as the `warn` chip (FAA-276, FAA-277 (6)). `data`
56
+ (DEC-0070) adds machine-readable
55
57
  fields beside the sentence, `{schema, value}`, never a secret: it is refused here, so no feed carries one.
56
58
 
57
59
  An event about traced work carries its trace as a pair, `trace_id` and `span_id`
@@ -21,7 +21,8 @@ says so three ways:
21
21
 
22
22
  `HoldBook` keeps one JSON file (0600, written atomically); a ThreadingHTTPServer's handlers share it under the store
23
23
  lock. It holds caller names, work labels, reasons and the operator's words, never a credential: `hold` refuses a text
24
- the contract's detector reads as one.
24
+ the contract's detector reads as one. A record written since 0.83.0 carries `squash: "ecmascript"`; one without it
25
+ (0.81.0, 0.82.0) is matched under both squashes, since 0.81.0's Python twin squashed names with str.split() (FAA-320).
25
26
  """
26
27
  from __future__ import annotations
27
28
 
@@ -65,6 +66,27 @@ def _squash(text: str) -> str:
65
66
  return _SPACES.sub(" ", text).strip(" ")
66
67
 
67
68
 
69
+ #: What a record written since 0.83.0 carries: its names were squashed with ECMAScript's whitespace (FAA-320).
70
+ SQUASH = "ecmascript"
71
+ #: Python's str.isspace() class, the one 0.81.0's Python twin squashed a name with (`" ".join(text.split())`).
72
+ _PY_SPACES = re.compile("[\t\n\x0b\x0c\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+")
73
+
74
+
75
+ def _legacy_squash(text: str) -> str:
76
+ """A name as 0.81.0's Python twin wrote it (str.split()), written out so both twins read it alike."""
77
+ return _PY_SPACES.sub(" ", text).strip(" ")
78
+
79
+
80
+ def _names(caller: Any, record: Dict[str, Any]) -> tuple:
81
+ """The stored forms `caller` matches in `record`: the ECMAScript squash, and for a record with no `squash` mark
82
+ (written before 0.83.0) also the squash 0.81.0's Python twin wrote (FAA-320). None matches only None."""
83
+ if not isinstance(caller, str):
84
+ return (caller,)
85
+ if record.get("squash") == SQUASH:
86
+ return (_squash(caller),)
87
+ return (_squash(caller), _legacy_squash(caller))
88
+
89
+
68
90
  def _instant(value: Any, name: str) -> str:
69
91
  if utc_time(value) is None:
70
92
  raise ServiceError("%s is a UTC instant (Z, at most 3 fraction digits)." % name)
@@ -197,6 +219,7 @@ class HoldBook:
197
219
  job_id: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
198
220
  """Hold `work` for `caller` by `reason`. Returns {hold, statusMessage, push}: `push` is the notify event when this
199
221
  call opened the hold, None when the work joined an open one (one push per hold)."""
222
+ raw_caller = caller
200
223
  caller, work = _text(caller, "caller"), _text(work, "work")
201
224
  if reason not in HOLD_REASONS:
202
225
  raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
@@ -207,11 +230,11 @@ class HoldBook:
207
230
  data = self._load()
208
231
  self._expire(data, utc_time(at))
209
232
  hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
210
- and h["caller"] == caller), None)
233
+ and h["caller"] in _names(raw_caller, h)), None)
211
234
  opened = hold is None
212
235
  if opened:
213
236
  hold = {"id": uuid.uuid4().hex, "reason": reason, "caller": caller, "work": work, "since": at,
214
- "until": until, "jobs": [], "closed": None}
237
+ "until": until, "jobs": [], "closed": None, "squash": SQUASH}
215
238
  data["holds"].append(hold)
216
239
  if job_id is not None and job_id not in hold["jobs"]:
217
240
  hold["jobs"].append(str(job_id))
@@ -229,7 +252,7 @@ class HoldBook:
229
252
  with self._lock:
230
253
  data = self._load()
231
254
  hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
232
- and h["caller"] == caller), None)
255
+ and h["caller"] in _names(caller, h)), None)
233
256
  if hold is None:
234
257
  return None
235
258
  hold["closed"], hold["closedAt"] = "released", at
@@ -269,14 +292,15 @@ class HoldBook:
269
292
  raise ServiceError("A raised limit needs its window's end (until): it falls back when the window ends.")
270
293
  record: Dict[str, Any] = {"id": uuid.uuid4().hex, "reason": reason, "caller": None if caller is None else _text(caller, "caller"),
271
294
  "job": None if job_id is None else str(job_id), "from": at, "until": until,
272
- given[0]: size, "decision": dict(decision), "at": at}
295
+ given[0]: size, "decision": dict(decision), "at": at, "squash": SQUASH}
273
296
  if given[0] != "limit":
274
297
  record["left"] = size
275
298
  with self._lock:
276
299
  data = self._load()
277
300
  self._expire(data, utc_time(at))
278
301
  for hold in data["holds"]:
279
- if hold.get("closed") is None and hold["reason"] == reason and record["caller"] in (None, hold["caller"]) and (
302
+ if hold.get("closed") is None and hold["reason"] == reason and (
303
+ caller is None or hold["caller"] in _names(caller, hold)) and (
280
304
  job_id is None or str(job_id) in hold["jobs"]):
281
305
  hold["closed"], hold["closedAt"], hold["lift"] = "lifted", at, record["id"]
282
306
  data["lifts"].append(record)
@@ -291,7 +315,7 @@ class HoldBook:
291
315
  def _matching(self, data: Dict[str, Any], reason: str, caller: Optional[str], job_id: Optional[str], at: int,
292
316
  kind: str) -> List[Dict[str, Any]]:
293
317
  return [l for l in data["lifts"] if l["reason"] == reason and kind in l and self._live(l, at)
294
- and l.get("caller") in (None, caller) and l.get("job") in (None, job_id)]
318
+ and (l.get("caller") is None or l["caller"] in _names(caller, l)) and l.get("job") in (None, job_id)]
295
319
 
296
320
  def admit(self, *, reason: str, caller: str, job_id: Optional[str] = None, cost_usd: Optional[float] = None,
297
321
  now: Optional[str] = None) -> Dict[str, Any]: