@passioncode-ai/passioncode 0.2.41 → 0.2.43

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 (53) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +4 -1
  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 +8 -4
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +85 -13
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +22 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +44 -6
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +48 -9
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -4
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +7 -2
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +30 -15
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +33 -3
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +146 -7
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +5 -0
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +606 -0
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +48 -525
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +43 -6
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -4
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +48 -15
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +4 -3
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +39 -16
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +34 -3
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +149 -45
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +8 -1
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -11
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +86 -642
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +766 -0
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +70 -12
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +59 -8
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +56 -16
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +4 -2
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +314 -25
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +13 -1
  43. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  44. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +4 -1
  45. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -0
  46. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +75 -36
  47. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +28 -14
  48. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +42 -12
  49. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
  50. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +12 -3
  51. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +12 -2
  52. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +360 -16
  53. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -10,12 +10,7 @@ import secrets
10
10
  import threading
11
11
  import time
12
12
  from pathlib import Path
13
- from typing import Any
14
- from typing import Callable
15
- from typing import Dict
16
- from typing import List
17
- from typing import Optional
18
- from typing import Sequence
13
+ from typing import Any, Callable, Dict, List, Optional, Sequence
19
14
 
20
15
  import sys as _sys # noqa: E402
21
16
  from pathlib import Path as _Path # noqa: E402
@@ -24,15 +19,7 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
24
19
  import fabric_hub as _hub
25
20
  import fabric_service as _fs
26
21
  from fabric_board_shapes import ( # noqa: E402 - the kit (FAA-65)
27
- BoardError,
28
- LEASE_SECONDS,
29
- REQUEST_STATES,
30
- SUMMARY_MAX,
31
- Stopped,
32
- _CAPABILITY,
33
- _is_int,
34
- outcome_problems,
35
- receipt_problems,
22
+ BoardError, LEASE_SECONDS, REQUEST_STATES, SUMMARY_MAX, Stopped, _CAPABILITY, _is_int, outcome_problems, receipt_problems,
36
23
  )
37
24
  from fabric_board_link import ( # noqa: E402,F401 - re-exported: callers import them from here since 0.40.0
38
25
  BoardRequest,
@@ -43,33 +30,25 @@ from fabric_board_link import ( # noqa: E402,F401 - re-exported: callers import
43
30
 
44
31
  RENEW_SECONDS = 20.0
45
32
 
46
-
47
33
  POLL_SECONDS = 15.0
48
34
 
49
-
50
35
  POLL_JITTER_SECONDS = 5.0
51
36
 
52
-
53
37
  DRAIN_SECONDS = 10.0
54
38
 
55
-
56
39
  EVENT_SCHEMA = "urn:fabric:schema:board-responder-event:1"
57
40
 
58
-
59
41
  #: On these the attempt is no longer ours: stop acting at once (C4).
60
42
  STOP_CODES = frozenset(["fenced", "lease_expired", "authority_changed"])
61
43
 
62
-
63
44
  TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
64
45
  #: No longer this responder's to work on: back in the queue, claimed again, or answered by the target Project's
65
46
  #: participant reply (DEC-0098, DEC-0102; not terminal, the requester may still cancel it, and nobody claims it).
66
47
  NOT_OURS = frozenset(["queued", "claimed", "answered"])
67
48
 
68
-
69
49
  #: A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38).
70
50
  LEASE_MARGIN_SECONDS = 10.0
71
51
 
72
-
73
52
  #: How long a held slot waits before it asks again: doubling from the poll interval, never past this.
74
53
  HOLD_RETRY_MAX_SECONDS = 900.0
75
54
 
@@ -105,6 +84,8 @@ class _Work:
105
84
 
106
85
  Handler = Callable[[BoardRequest, WorkContext], Dict[str, Any]]
107
86
 
87
+ _whole = lambda v: int(v) if isinstance(v, float) and v.is_integer() else v # noqa: E731 - 2.0 is 2, as Node reads it
88
+
108
89
 
109
90
  class Responder:
110
91
  """One service's responder for one Project and one or more capabilities, one request at a time.
@@ -150,6 +131,7 @@ class Responder:
150
131
  #: One fenced call at a time, and the entry edits beside them (FAA-77, finding 36).
151
132
  self._lock = threading.RLock()
152
133
  self._to_resume: List[str] = [] # transferred requests waiting until nothing runs (finding 39)
134
+ self._suspect: set = set() # capabilities a fenced call was refused on: com.status says whether they moved (FAA-155)
153
135
 
154
136
  # -- events -------------------------------------------------------------------------------------------------
155
137
  def _emit(self, kind: str, level: str, text: str, notify: bool = False, episode: Optional[str] = None, **value: Any) -> None:
@@ -230,12 +212,14 @@ class Responder:
230
212
  def _enroll(self, capability: str) -> None:
231
213
  args = {"idempotency": self._idem(self._key("enroll")), "capability": capability}
232
214
  try:
233
- receipt = self._receipt("com.enroll", self._call("com.enroll", args))
215
+ receipt = self._receipt("com.enroll", self._ask(capability, "com.enroll", args))
234
216
  except BoardError as exc:
235
217
  if exc.code == "held":
236
218
  self._pause(capability)
237
219
  return
238
220
  if exc.code == "fenced":
221
+ if self._gone(capability):
222
+ return
239
223
  self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
240
224
  self._emit("board.slot.contested", "warn", "Another responder holds the %s slot on this Project; this service "
241
225
  "waits for it to lapse and never takes it over by itself." % capability, notify=True,
@@ -243,12 +227,12 @@ class Responder:
243
227
  return
244
228
  raise
245
229
  self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
230
+ self._held_at(capability, receipt["slot"]["generation"])
246
231
  self._said.pop("board.slot.contested", None)
247
232
  self._lifted(capability)
248
233
 
249
234
  def _pause(self, capability: str) -> None:
250
- """The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and
251
- never act around the hold. Accepted work in progress continues; only new work waits."""
235
+ """The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling; work goes on."""
252
236
  slot = self.slots.get(capability) or {}
253
237
  pauses = slot.get("pauses", 0) + 1 if slot.get("state") == "paused" else 1
254
238
  wait = min(HOLD_RETRY_MAX_SECONDS, self.hold_delay * 2 ** (pauses - 1))
@@ -264,6 +248,108 @@ class Responder:
264
248
  self._emit("board.slot.released", "info", "The operator's hold on %s was lifted; the service takes new work "
265
249
  "again." % capability, capability=capability)
266
250
 
251
+ # -- a slot moved to another session (FAA-155, DEC-0140 PR-2) ------------------------------------------------
252
+ def _held_at(self, capability: str, generation: int) -> None:
253
+ """The generation this responder holds the slot at, kept beside its checkpoint so a restart knows it."""
254
+ self._note(capability, {"project": self.project, "generation": generation})
255
+
256
+ def _note(self, capability: str, record: Optional[Dict[str, Any]]) -> None:
257
+ """The slot record, best effort: one that cannot be written now is written at the next receipt."""
258
+ try:
259
+ self.checkpoint.slot(capability, record)
260
+ except BoardError:
261
+ pass
262
+
263
+ def _ask(self, capability: str, op: str, args: Dict[str, Any]) -> Dict[str, Any]:
264
+ """An enroll or replace that can move the generation is recorded with its key first (a lost answer is no move)."""
265
+ held = self.checkpoint.slots.get(capability) or {}
266
+ if held.get("project") != self.project:
267
+ return self._call(op, args) # no record: nothing a lost answer could be mistaken for
268
+ slot = self.slots.get(capability) or {}
269
+ if op == "com.enroll" and slot.get("state") == "held" and slot.get("generation") == held.get("generation") \
270
+ and self.now() - slot.get("renewed_at", 0.0) < LEASE_SECONDS - LEASE_MARGIN_SECONDS and "asking" not in held:
271
+ return self._call(op, args) # a live holder's renew cannot change its generation: nothing to record
272
+ if isinstance(held.get("asking"), dict):
273
+ if self._gone(capability): # an earlier lost ask is settled first; it may show the slot moved
274
+ raise BoardError("fenced", "The %s slot moved to another session." % capability, op=op)
275
+ held = self.checkpoint.slots.get(capability) or {}
276
+ try:
277
+ self.checkpoint.slot(capability, dict(held, asking={"op": op, "args": args}))
278
+ return self._call(op, args)
279
+ except BoardError as exc:
280
+ if exc.code == "checkpoint":
281
+ return self._call(op, args)
282
+ if exc.code not in ("unreachable", "session-ended"):
283
+ self._note(capability, held)
284
+ raise
285
+
286
+ def _replay(self, capability: str, held: Dict[str, Any], before: Any) -> Dict[str, Any]:
287
+ """Asks a lost enroll or replace again under its key. `repeated: true`: the generation is ours. Else it ran only now: past
288
+ a move (`before` beyond the record) the move is read. A refusal drops the record; a lost answer keeps it."""
289
+ asking = held.get("asking")
290
+ rest = {k: v for k, v in held.items() if k != "asking"}
291
+ if isinstance(asking, dict) and asking.get("op") in ("com.enroll", "com.responder_replace"):
292
+ try:
293
+ answer = self._receipt(asking["op"], self._call(asking["op"], asking.get("args")))
294
+ generation, moved = _whole(answer["slot"]["generation"]), _is_int(before, 1) and before > rest.get("generation", before)
295
+ if _is_int(generation, 1) and (answer.get("repeated") is True or not moved):
296
+ rest = {"project": self.project, "generation": generation}
297
+ except BoardError as exc:
298
+ if exc.code in ("unreachable", "session-ended"):
299
+ raise
300
+ if exc.code == "idempotency_window_expired":
301
+ rest = {} # a retired epoch refuses even a repeat: unknown, so no record (a first start, never a move)
302
+ except (KeyError, TypeError):
303
+ pass
304
+ self._note(capability, rest or None)
305
+ return rest
306
+
307
+ def _gone(self, capability: str, status: Optional[Dict[str, Any]] = None) -> bool:
308
+ """True when `com.status` shows the slot's generation past the record (the slot is left `moved`); else False."""
309
+ held = self.checkpoint.slots.get(capability) or {}
310
+ if held.get("project") != self.project or (not _is_int(held.get("generation"), 1) and "asking" not in held):
311
+ return False
312
+ status = self._status() if status is None else status
313
+ now = _whole(next((r.get("generation") for r in status.get("responders") or [] if isinstance(r, dict) and r.get("capability") == capability), None))
314
+ held = self._replay(capability, held, now) if "asking" in held else held # its own lost ask first
315
+ if "moved" in held and _is_int(held.get("moved"), 1) and not (_is_int(now, 1) and now > held.get("generation", now)):
316
+ now = held["moved"] # a recorded move stays until take_back, whatever com.status says of the slot now
317
+ if not _is_int(now, 1) or not _is_int(held.get("generation"), 1) or now <= held["generation"]:
318
+ return False
319
+ with self._lock:
320
+ work = self.current
321
+ if work is not None and work.entry["capability"] == capability:
322
+ work.ctx.stopped.set()
323
+ self.current = None
324
+ for entry in [dict(e) for e in self.checkpoint.entries.values() if e.get("capability") == capability]:
325
+ if entry.get("step") == "claimed":
326
+ self.checkpoint.drop(entry["request"]) # the move returned the claim to the queue
327
+ elif entry.get("step") not in ("unknown", "lapsed"):
328
+ # Begun: outcome_unknown, which the slot's holder reconciles. Accepted: held on the board, continued only
329
+ # by a transfer if the slot is this service's again; never a com.responder_replace on its own.
330
+ entry["step"] = "unknown" if entry.get("effect") else "lapsed"
331
+ self.checkpoint.put(entry)
332
+ self._to_resume = [r for r in self._to_resume if (self.checkpoint.entries.get(r) or {}).get("capability") != capability]
333
+ self.slots[capability] = {"state": "moved", "generation": now, "renewed_at": self.now()}
334
+ if "moved" not in held: # once, even across restarts
335
+ self._emit("board.slot.moved", "warn", "The %s slot on this Project was moved to another session. This service stopped "
336
+ "answering it there, left its accepted work held (work already begun is the new holder's to settle) and does not "
337
+ "take the slot back by itself. Its take-back action brings it back once the other session lets it go; without one, ask its "
338
+ "maintainer." % capability,
339
+ notify=True, episode=capability, capability=capability, generation=now, held=held["generation"])
340
+ self._note(capability, dict(held, moved=now)) # said again at the next start if it cannot be written
341
+ return True
342
+
343
+ def take_back(self, capability: str) -> None:
344
+ """The operator's act, never the kit's: forget the move; the next tick enrolls once the slot is free (FAA-155)."""
345
+ if capability not in self.capabilities:
346
+ raise BoardError("invalid_arguments", "This responder does not answer %s." % capability)
347
+ with self._lock:
348
+ self.checkpoint.slot(capability, None)
349
+ if (self.slots.get(capability) or {}).get("state") == "moved":
350
+ self.slots[capability] = {"state": "contested", "renewed_at": 0.0}
351
+ self._said.pop("board.slot.moved", None)
352
+
267
353
  # -- restart: reconcile and transfer -------------------------------------------------------------------------
268
354
  def _read_state(self, entry: Dict[str, Any]) -> Optional[str]:
269
355
  message = self._call("com.get", {"message": entry["message"]})
@@ -293,17 +379,20 @@ class Responder:
293
379
  else: # in_progress (lost result once a new generation holds the slot) or outcome_unknown
294
380
  reconcile.append(entry)
295
381
  responders = [r for r in status.get("responders") or [] if isinstance(r, dict)]
296
- generations = {r.get("capability"): r.get("generation", 0) for r in responders}
382
+ generations = {r.get("capability"): _whole(r.get("generation", 0)) for r in responders}
297
383
  on_hold = {r.get("capability") for r in responders if r.get("held") is True} # DEC-0079
298
384
  for capability in capabilities:
299
- held = transfer.get(capability)
385
+ held, was = transfer.get(capability), (self.checkpoint.slots.get(capability) or {}).get("generation")
386
+ if self._gone(capability, status):
387
+ continue # moved away: never replaced at recovery (FAA-155)
300
388
  if capability in on_hold:
301
389
  for entry in held or []:
302
390
  entry["step"] = "lapsed" # taken back by a transfer once the hold is lifted
303
391
  self.checkpoint.put(entry)
304
392
  self._pause(capability)
305
- elif held:
306
- self._replace(capability, held, generations.get(capability, 0))
393
+ elif held: # at the generation a replay of its own lost ask just recorded, else com.status's (seam 6)
394
+ mine = (self.checkpoint.slots.get(capability) or {}).get("generation")
395
+ self._replace(capability, held, mine if _is_int(mine, 1) and mine != was else generations.get(capability, 0))
307
396
  else:
308
397
  self._enroll(capability)
309
398
  for entry in reconcile:
@@ -321,8 +410,10 @@ class Responder:
321
410
  args["transfer"] = {"requests": [e["request"] for e in entries][:64],
322
411
  "checkpoint": {"summary": "Resumed by the same service after a restart; no effect had begun."}}
323
412
  try:
324
- receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
413
+ receipt = self._receipt("com.responder_replace", self._ask(capability, "com.responder_replace", args))
325
414
  except BoardError as exc:
415
+ if exc.code == "fenced" and (self.slots.get(capability) or {}).get("state") == "moved":
416
+ return # an earlier lost ask showed the slot moved: said once as board.slot.moved, no outage
326
417
  if exc.code != "held":
327
418
  raise
328
419
  for entry in entries:
@@ -331,6 +422,7 @@ class Responder:
331
422
  self._pause(capability)
332
423
  return
333
424
  self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
425
+ self._held_at(capability, receipt["slot"]["generation"])
334
426
  self._lifted(capability)
335
427
  moved = {item["request"]: item for item in receipt.get("transferred") or []}
336
428
  for entry in entries:
@@ -471,7 +563,8 @@ class Responder:
471
563
  return min(renewed) + LEASE_SECONDS - self.now() if renewed and self.session is not None else 0.0
472
564
 
473
565
  def _fault(self, exc: Exception) -> float:
474
- code = getattr(exc, "code", "unreachable")
566
+ code, slots = getattr(exc, "code", "unreachable"), self.slots.values()
567
+ connecting = self.state == "connecting" and not any(s.get("state") == "held" for s in slots)
475
568
  wait = self.backoff.next()
476
569
  left = self._lease_left() - LEASE_MARGIN_SECONDS
477
570
  keep = code == "unreachable" and left > 0 # finding 38: the lease holds, so the session and the work stay
@@ -496,15 +589,15 @@ class Responder:
496
589
  notify=True, episode=code, reason=code)
497
590
  else:
498
591
  self.state = "down"
499
- if code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol") and not keep:
592
+ # In recovery with no slot held a refusal reconnects (seam 6); with some held they are served (seam 7).
593
+ if (connecting or code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol")) and not keep:
500
594
  self._drop_session()
501
595
  self._emit("board.down", "warn", "Fabric's board cannot be reached; the responder tries again with a growing pause.",
502
596
  episode="down", reason=code)
503
597
  return wait
504
598
 
505
599
  def _drop_session(self) -> None:
506
- work, self.current = self.current, None
507
- self._to_resume = [] # the next session transfers them again
600
+ work, self.current, self._to_resume = self.current, None, [] # the next session transfers them again
508
601
  if work is not None:
509
602
  entry = work.entry
510
603
  if work.done.is_set() and work.failure is None and not outcome_problems(work.outcome, bool(entry.get("effect"))):
@@ -528,7 +621,12 @@ class Responder:
528
621
  else:
529
622
  self._renew(work)
530
623
  held_by_work = work.entry["capability"] if work is not None and self.current is not None else None
624
+ for capability in list(self._suspect): # kept until com.status answered, so it is asked before any enroll
625
+ self._gone(capability)
626
+ self._suspect.discard(capability)
531
627
  for capability, slot in list(self.slots.items()):
628
+ if slot.get("state") == "moved":
629
+ continue # never enrolled again on its own, even when the new holder's lease lapses (FAA-155)
532
630
  if slot.get("state") == "paused":
533
631
  if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0):
534
632
  self._enroll(capability) # still held: a longer pause; lifted: the slot is ours again
@@ -557,8 +655,7 @@ class Responder:
557
655
  self._replace(capability, live, self.slots[capability].get("generation", 0))
558
656
 
559
657
  def _reconcile_pending(self) -> None:
560
- """Work whose outcome became unknown while running (its attempt was lost after the effect began) is tried
561
- again every five minutes: the slot's holder reconciles from what `observe` reads, or it stays unknown."""
658
+ """Work whose outcome became unknown while running is reconciled from `observe` every five minutes, or stays so."""
562
659
  if self.now() < getattr(self, "_reconcile_at", 0.0):
563
660
  return
564
661
  self._reconcile_at = self.now() + 300.0
@@ -575,6 +672,7 @@ class Responder:
575
672
  answer = self._receipt("com.claim", self._call("com.claim", {"idempotency": self._idem(key), "capability": capability}))
576
673
  except BoardError as exc:
577
674
  if exc.code == "lease_expired":
675
+ self.slots[capability]["renewed_at"] = float("-inf") # lapsed: the enroll below is recorded first
578
676
  self._enroll(capability) # the slot lapsed: enroll again (a new generation)
579
677
  continue
580
678
  if exc.code in ("idempotency_window_expired", "capacity_exceeded"):
@@ -582,7 +680,8 @@ class Responder:
582
680
  self._status()
583
681
  continue
584
682
  if exc.code == "fenced":
585
- self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
683
+ if not self._gone(capability):
684
+ self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
586
685
  continue
587
686
  if exc.code == "held":
588
687
  self._pause(capability)
@@ -678,8 +777,7 @@ class Responder:
678
777
  self._finish(work)
679
778
 
680
779
  def _resume(self, entry: Dict[str, Any]) -> None:
681
- """A transferred request continues: an outcome already known is sent; otherwise the handler runs again (no
682
- effect had begun, so nothing happened yet)."""
780
+ """A transferred request continues: a known outcome is sent; else the handler runs again (no effect had begun)."""
683
781
  if isinstance(entry.get("outcome"), dict):
684
782
  self._complete(entry)
685
783
  return
@@ -706,6 +804,8 @@ class Responder:
706
804
  self._read_state(entry)
707
805
  continue
708
806
  if exc.code in STOP_CODES or exc.code in ("revision_conflict", "invalid_transition", "digest_conflict"):
807
+ if exc.code == "fenced":
808
+ self._suspect.add(entry["capability"]) # the next tick asks com.status whether the slot moved
709
809
  self._lose(entry, exc.code)
710
810
  if exc.code == "authority_changed":
711
811
  raise
@@ -722,13 +822,16 @@ class Responder:
722
822
  self._emit("board.reconcile.needed", "warn", "The attempt was lost after its effect began; the outcome stays "
723
823
  "unknown until this service observes what happened.", notify=True, episode=entry["request"],
724
824
  request=entry["request"], capability=entry["capability"], reason=code)
725
- elif code == "lease_expired" and entry.get("step") not in ("claimed", "accepting"):
726
- # Our own lease lapsed on accepted work: the board holds it (C4). It continues only by a checkpoint transfer
727
- # to this responder, which the next tick asks for; nothing outside the service had begun.
825
+ elif entry.get("step") not in ("claimed", "accepting") and (code == "lease_expired" or (
826
+ code == "fenced" and entry.get("capability") in self.checkpoint.slots)):
827
+ # Accepted work whose lease lapsed, or fenced off a slot this responder held (moved, FAA-155): the board holds
828
+ # it (C4). It continues only by a checkpoint transfer to this responder while it holds the slot.
728
829
  entry["step"] = "lapsed"
729
830
  self.checkpoint.put(entry)
730
- self._emit("board.attempt.lost", "warn", "This service's lease on an accepted request lapsed; it stopped working "
731
- "on it and takes it back by a transfer.", request=entry["request"], capability=entry["capability"], reason=code)
831
+ self._emit("board.attempt.lost", "warn", ("This service's lease on an accepted request lapsed; it stopped working "
832
+ "on it and takes it back by a transfer." if code == "lease_expired" else "The board fenced an accepted "
833
+ "request away from this service; it stopped working on it, and nothing had been done outside it."),
834
+ request=entry["request"], capability=entry["capability"], reason=code)
732
835
  else:
733
836
  self.checkpoint.drop(entry["request"])
734
837
  self._emit("board.attempt.lost", "warn", "The board took a request back from this service (%s); it stopped working "
@@ -893,4 +996,5 @@ class Responder:
893
996
  if self.session is not None:
894
997
  self.session.close()
895
998
  self.session = None
999
+ self.checkpoint.leave(self.project) # the slots were released: no record of them can read as a move
896
1000
  self.state = "stopped"
@@ -8,7 +8,14 @@ does; an organization's instance runs only under a placement of this machine's e
8
8
  share grant for that context (FAC-SEM-111), inside the grant's window; a data folder never changes context (its
9
9
  ``fabric-context.json`` marker). A refusal exits 78 (EX_CONFIG) with nothing created. The facade ``fabric_service`` does
10
10
  not re-export this module, so a service that does not run per context keeps the estate reader out of its closure:
11
- ``fabric_kit.py install <dir> --module fabric_context``. Docs: references/estate-record.md (Contexts)."""
11
+ ``fabric_kit.py install <dir> --module fabric_context``. Docs: references/estate-record.md (Contexts).
12
+
13
+ An organization's instance whose context the record does not pin (``organizations``, DEC-0127) still starts: the helper
14
+ checks the placement and its share grant, not the pin (decision D5 of 0.52.0). Its tokens are refused instead:
15
+ ``fabric_estate.hub_key_certified`` reads a context's hub keys only while the record pins that organization (DL-14a,
16
+ DEC-0128), so the service passes ``estate_context=info["context"]`` to its delegation gate. The contract lets Fabric
17
+ write ``organizations`` only once every reader is pinned at or after DEC-0127, so a refusal at start would stop every
18
+ organization instance on today's records."""
12
19
  from __future__ import annotations
13
20
 
14
21
  import hashlib
@@ -387,14 +387,16 @@ def _readable_clock(now: Any) -> bool:
387
387
 
388
388
 
389
389
  def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = _MISSING,
390
- revocations: Any = _MISSING) -> Optional[Tuple[str, str]]:
390
+ revocations: Any = _MISSING, context: Any = _MISSING) -> Optional[Tuple[str, str]]:
391
391
  """DL-14 row 4a (DEC-0055, DEC-0060), right after the signature: when the callee reads the estate record, the
392
392
  token's kid and the published key must be certified by that revision. `estate` is a revision the caller verified
393
393
  (fabric_estate.verify_estate_record) and its machine applied, or a callable returning it; only an omitted `estate`
394
394
  skips the rule, and None (given, or returned by the callable) is an unreadable source, as Node's null is (D3).
395
395
  `applied` is the highest revision the machine applied (`{"revision", "digest"?}`); `revocations` the revocation
396
- delta's entries applied since (a list or a callable). An omitted one is absent; None is unreadable. A source that
397
- raises is unreadable, never a pass. Returns ("token" | "issuer", detail) or None."""
396
+ delta's entries applied since (a list or a callable). An omitted one is absent; None is unreadable. `context` is the
397
+ context this callee serves (DEC-0128): an organization's `org:<orgId>` reads that context's hub keys while the record
398
+ pins it; omitted or `personal`, the personal hub keys. A source that raises is unreadable, never a pass. Returns
399
+ ("token" | "issuer", detail) or None."""
398
400
  if estate is _MISSING:
399
401
  return None
400
402
  try:
@@ -404,8 +406,10 @@ def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = _M
404
406
  return ("issuer", "estate-unreadable")
405
407
  if applied is None: # the contract's hubKeyCertified refuses a null applied mark after its record checks: unreadable
406
408
  return ("issuer", "estate-unreadable")
407
- import fabric_estate # beside this file; imported here, since fabric_estate imports this module
409
+ import fabric_estate # beside this file; imported where it is used
408
410
  given = {} if applied is _MISSING else {"applied": applied}
411
+ if context is not _MISSING:
412
+ given["context"] = context
409
413
  verdict = fabric_estate.hub_key_certified(record, kid, x, now, extra_revocations=extra, **given)
410
414
  if verdict["certified"]:
411
415
  return None
@@ -415,14 +419,17 @@ def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = _M
415
419
 
416
420
  def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
417
421
  issuer: Optional[str], read_only: bool = False, estate: Any = _MISSING,
418
- estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING) -> Dict[str, Any]:
422
+ estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING,
423
+ estate_context: Any = _MISSING) -> Dict[str, Any]:
419
424
  """DL-13, in its fixed order: nothing about a token is believed before its signature is.
420
425
  `now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
421
426
  {ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required.
422
427
  With `estate` (a verified estate revision, or a callable returning the current one), the token's kid must be one
423
428
  the revision certifies for a machine (DL-14, DEC-0055, DEC-0060); `estate_applied` is the highest revision the
424
- machine applied and `estate_revocations` the revocation delta's entries since. A clock that is not a number of
425
- seconds is refused `unknown-state` `no-clock` before row 1."""
429
+ machine applied and `estate_revocations` the revocation delta's entries since. `estate_context` is the context this
430
+ callee serves (DL-14a, DEC-0128): an organization's `org:<orgId>` verifies the token under that context's hub key,
431
+ and `issuer` is then that organization pin's context issuer (`organizations[].iss`); omitted or `personal`, the
432
+ personal hub key. A clock that is not a number of seconds is refused `unknown-state` `no-clock` before row 1."""
426
433
  # Before row 1: every time row compares against the clock, and a comparison with NaN is always false.
427
434
  if not _readable_clock(now):
428
435
  return _refuse("unknown-state", "no-clock")
@@ -433,7 +440,7 @@ def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capab
433
440
  checked = _authentic(token, jwks, DELEGATION_TYP)
434
441
  if "problem" in checked:
435
442
  return _refuse("issuer-unavailable" if checked.get("issuer") else "unauthenticated", checked["problem"])
436
- uncertified = _estate_problem(estate, checked["kid"], checked["x"], now, estate_applied, estate_revocations)
443
+ uncertified = _estate_problem(estate, checked["kid"], checked["x"], now, estate_applied, estate_revocations, estate_context)
437
444
  if uncertified:
438
445
  return _refuse("issuer-unavailable" if uncertified[0] == "issuer" else "unauthenticated", uncertified[1])
439
446
  claims = checked["compact"]["payload"]
@@ -756,7 +763,8 @@ TIERS = ("T0", "T1", "T2")
756
763
 
757
764
  def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: str,
758
765
  read_only: bool = False, clock: Callable[[], float] = time.time, estate: Any = _MISSING,
759
- estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING) -> Callable[..., Dict[str, Any]]:
766
+ estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING,
767
+ estate_context: Any = _MISSING) -> Callable[..., Dict[str, Any]]:
760
768
  """A check for one tools/call: `gate(tool, headers, tier="T2")` answers the accepted delegation or a DL-19
761
769
  refusal. `effects` maps each tool to its declared effect class (its manifest entry); a tool with none is
762
770
  refused rather than guessed. `jwks` is a JwksCache or a key set; `issuer` (auth.delegation.issuer) is required.
@@ -767,7 +775,9 @@ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], iss
767
775
  passes with no delegation ({ok: True, tier, principal: None, ...}). A present token is verified at every tier
768
776
  and refused when invalid. Unknown tiers count as T2. `estate` (a callable returning the current verified estate
769
777
  revision) makes the gate trust only kids that revision certifies (DL-14, DEC-0055, DEC-0060); `estate_applied` and
770
- `estate_revocations` pass the applied mark and the revocation delta's entries through."""
778
+ `estate_revocations` pass the applied mark and the revocation delta's entries through, and `estate_context` the
779
+ context the service's instance runs in (DL-14a, DEC-0128: an organization's instance passes its `org:<orgId>`, and
780
+ `issuer` its pin's context issuer)."""
771
781
  if not _is_string(issuer):
772
782
  raise ValueError("the gate needs the issuer it trusts (auth.delegation.issuer)")
773
783
 
@@ -783,7 +793,8 @@ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], iss
783
793
  keys = jwks.get(_kid_of(carried["token"])) if isinstance(jwks, JwksCache) else jwks
784
794
  verdict = verify_delegation(carried["token"], audience=audience, jwks=keys, now=clock(), capability=tool,
785
795
  effect=effect, issuer=issuer, read_only=read_only, estate=estate,
786
- estate_applied=estate_applied, estate_revocations=estate_revocations)
796
+ estate_applied=estate_applied, estate_revocations=estate_revocations,
797
+ estate_context=estate_context)
787
798
  if verdict["ok"]:
788
799
  verdict["tier"] = tier if tier in TIERS else "T2"
789
800
  return verdict