@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +9 -3
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +144 -20
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +130 -35
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -0
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +30 -22
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +37 -6
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +25 -7
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +199 -43
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -54,7 +54,7 @@ EFFECT_STATES = ("not_started", "started", "succeeded_observed", "failed_observe
54
54
 
55
55
 
56
56
  #: DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
57
- #: `held` — it settles, reports on or keeps alive started work (com.renew, com.progress, com.complete, com.reconcile),
57
+ #: `held`: it settles, reports on or keeps alive started work (com.renew, com.progress, com.complete, com.reconcile),
58
58
  #: cancels or reads. The contract's HOLD_SCOPES.
59
59
  HOLD_SCOPES = {"com.submit": ("thread",), "com.reply": ("thread",), "com.enroll": ("project", "slot"),
60
60
  "com.responder_replace": ("project", "slot"), "com.claim": ("thread", "project", "slot"),
@@ -87,7 +87,7 @@ _SAFE = 2 ** 53 - 1
87
87
 
88
88
  class BoardError(Exception):
89
89
  """A board refusal (`code` from the contract's refusal codes) or a kit fault: `unreachable` (the hub did not answer;
90
- a write may have been stored — send it again with the same key), `session-ended`, `not-in-scope`, `receipt-shape`,
90
+ a write may have been stored, so send it again with the same key), `session-ended`, `not-in-scope`, `receipt-shape`,
91
91
  `schema-unsupported`, `no-epoch`, `checkpoint`, `digest-mismatch`, `not-addressed`, `directory-invalid`,
92
92
  `evidence`, `observation_required`. `str()` is one sentence and never carries a token or a body."""
93
93
 
@@ -264,7 +264,7 @@ def refusal_problems(value: Any) -> List[str]:
264
264
 
265
265
  def directory_findings(page: Any) -> List[Dict[str, str]]:
266
266
  """FAC-SEM-107 (DEC-0077), the contract's `commsDirectory`: a Project listed twice, or a capability listed twice
267
- within a Project, compared exactly as written (Project ids are opaque and capability names are names, not URIs —
267
+ within a Project, compared exactly as written (Project ids are opaque and capability names are names, not URIs,
268
268
  so not by normal form). The finding names the second entry. Never raises."""
269
269
  findings: List[Dict[str, str]] = []
270
270
  projects = page.get("projects") if isinstance(page, dict) else None
@@ -366,7 +366,7 @@ def hold_refuses(tool: Any, target: Any, holds: Any) -> bool:
366
366
 
367
367
 
368
368
  def find_project(page: Any, project_id: str, capability: Optional[str] = None) -> Optional[Dict[str, Any]]:
369
- """Look a Project up in one `com.directory` page (read by a Project session — a board session is not served the
369
+ """Look a Project up in one `com.directory` page (read by a Project session; a board session is not served the
370
370
  directory). The page is checked first: a page the contract would refuse, FAC-SEM-107 included, raises
371
371
  `BoardError("directory-invalid")` instead of answering from it. Ids and capability names compare exactly as
372
372
  written (DEC-0077). Answers the entry, or None when the page does not list it (or lists it without `capability`):
@@ -29,7 +29,6 @@ from pathlib import Path
29
29
  from typing import Any, Dict, List, Optional
30
30
 
31
31
  sys.path.insert(0, str(Path(__file__).resolve().parent))
32
- import fabric_delegation as _delegation # noqa: E402 - beside this file: RFC 8785
33
32
  import fabric_estate as _estate # noqa: E402 - beside this file: passkey assertions, times, show
34
33
 
35
34
  ENROLL_TOKEN_SCHEMA = "enroll-token/1"
@@ -2,19 +2,22 @@
2
2
 
3
3
  - ``ed25519_verify(public_key, message, signature)`` (RFC 8032) and ``is_small_order_key`` with
4
4
  ``SMALL_ORDER_ENCODINGS``: a key of small order verifies forged signatures and is refused (DEC-0045).
5
- - ``is_valid_p256_key`` and ``es256_verify`` (ECDSA P-256 over SHA-256, DER or raw signatures) for passkeys.
5
+ - ``is_valid_p256_key`` and ``es256_verify`` (ECDSA P-256 over SHA-256, the signature in DER) for passkeys.
6
+ - ``ed25519_sign`` and ``ed25519_seed_public_key`` (RFC 8032), which ``fabric_package`` signs with, over ``ed_mul_secret``.
6
7
  - base64url without padding (``b64url``, ``b64url_bytes``, ``node_base64``) and ``sha256``.
7
8
 
8
- Pure Python over integers: slow next to OpenSSL, constant inputs only, verification only. Nothing here signs.
9
- Standard library only; imports only ``fabric_jsvalue``.
9
+ Pure Python over integers, slow next to OpenSSL. Verification multiplies public values only and takes the short path;
10
+ a secret scalar goes through ``ed_mul_secret``, a ladder that runs the same 256 steps whatever its bits are (FAA-78,
11
+ finding 45). Python's integers do not promise constant-time arithmetic, so that removes the branch on the secret, not
12
+ every timing difference: the signer stays a local, one-shot command line, never a service that signs for others.
13
+ Standard library only; imports no other kit module.
10
14
  """
11
15
  from __future__ import annotations
12
16
 
13
17
  import base64
14
18
  import hashlib
15
- import re
16
- from typing import Any, Optional, Tuple
17
19
  import secrets
20
+ from typing import Optional, Tuple
18
21
 
19
22
 
20
23
  # The small-order points of edwards25519 and their non-canonical encodings, sign bit cleared (the contract's
@@ -67,6 +70,50 @@ def _ed_mul(scalar: int, point: Tuple[int, int, int, int]) -> Tuple[int, int, in
67
70
  return result
68
71
 
69
72
 
73
+ def _ed_cswap(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int], bit: int) -> Tuple[Tuple[int, int, int, int], Tuple[int, int, int, int]]:
74
+ """``(b, a)`` when ``bit`` is 1, ``(a, b)`` when it is 0, by arithmetic rather than a branch on ``bit``."""
75
+ deltas = [(x - y) * bit for x, y in zip(a, b)]
76
+ return (tuple(x - d for x, d in zip(a, deltas)), tuple(y + d for y, d in zip(b, deltas))) # type: ignore[return-value]
77
+
78
+
79
+ def ed_mul_secret(scalar: int, point: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
80
+ """``[scalar]point`` for a secret scalar below 2**256: a Montgomery ladder over all 256 bits with a conditional
81
+ swap, so every scalar takes one addition and one doubling per bit and no step depends on a bit (finding 45). The
82
+ addition law is complete on edwards25519, so the ladder needs no special case for the identity or a doubling."""
83
+ r0, r1 = (0, 1, 1, 0), point
84
+ for i in range(255, -1, -1):
85
+ bit = (scalar >> i) & 1
86
+ r0, r1 = _ed_cswap(r0, r1, bit)
87
+ r1 = _ed_add(r0, r1)
88
+ r0 = _ed_add(r0, r0)
89
+ r0, r1 = _ed_cswap(r0, r1, bit)
90
+ return r0
91
+
92
+
93
+ def _secret_scalar(seed: bytes) -> Tuple[int, bytes]:
94
+ h = hashlib.sha512(seed).digest()
95
+ a = int.from_bytes(h[:32], "little")
96
+ a &= (1 << 254) - 8
97
+ a |= 1 << 254
98
+ return a, h[32:]
99
+
100
+
101
+ def ed25519_seed_public_key(seed: bytes) -> bytes:
102
+ """The 32-byte public key of a 32-byte Ed25519 seed (RFC 8032 §5.1.5)."""
103
+ return _ed_encode_point(ed_mul_secret(_secret_scalar(seed)[0], _ED_G))
104
+
105
+
106
+ def ed25519_sign(seed: bytes, message: bytes) -> bytes:
107
+ """A deterministic Ed25519 signature (RFC 8032 §5.1.6); the same seed and message give the bytes Node's crypto.sign
108
+ gives. Both secret multiplications (the key's scalar, the nonce) go through ``ed_mul_secret``."""
109
+ a, prefix = _secret_scalar(seed)
110
+ public = _ed_encode_point(ed_mul_secret(a, _ED_G))
111
+ r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _ED_L
112
+ big_r = _ed_encode_point(ed_mul_secret(r, _ED_G))
113
+ k = int.from_bytes(hashlib.sha512(big_r + public + message).digest(), "little") % _ED_L
114
+ return big_r + ((r + k * a) % _ED_L).to_bytes(32, "little")
115
+
116
+
70
117
  def _ed_decode_point(data: bytes) -> Optional[Tuple[int, int, int, int]]:
71
118
  if len(data) != 32:
72
119
  return None
@@ -16,8 +16,8 @@ R compared by its encoding).
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- import base64
20
19
  import hashlib
20
+ import http.client
21
21
  import ipaddress
22
22
  import json
23
23
  import math
@@ -36,14 +36,14 @@ from pathlib import Path as _Path # noqa: E402
36
36
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
37
37
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
38
38
  from fabric_crypto import ( # noqa: E402 - the core (FAA-65)
39
- SMALL_ORDER_ENCODINGS,
40
- _ED_P as _P,
41
- _ed_decode_point as _decode_point,
39
+ SMALL_ORDER_ENCODINGS, # noqa: F401 - public here since v0.39.1 (test/fixtures/kit-api)
40
+ b64url as _b64encode,
42
41
  b64url_decode as _b64decode,
43
42
  ed25519_verify,
44
43
  is_small_order_key,
45
44
  )
46
45
  from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
46
+ MISSING as _MISSING,
47
47
  jcs,
48
48
  )
49
49
 
@@ -65,6 +65,8 @@ JWKS_MAX_AGE_SECONDS = 600
65
65
  JWKS_REREAD_SECONDS = 60
66
66
  # After a failed read, wait this long before trying again, so a down issuer does not cost every call a fetch.
67
67
  JWKS_RETRY_SECONDS = 10
68
+ # One read of the key set, headers and body together, ends within this many seconds (Node's AbortSignal.timeout(5000)).
69
+ JWKS_FETCH_SECONDS = 5.0
68
70
  # JavaScript's String.prototype.trim set, so a header is trimmed as the contract's verifier trims it.
69
71
  _JS_WHITESPACE = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
70
72
 
@@ -91,16 +93,15 @@ REFUSAL_MESSAGES = {
91
93
  "unknown-state": "The state cannot be established, so the call is refused.",
92
94
  }
93
95
 
94
- _B64URL = re.compile(r"^[A-Za-z0-9_-]+$")
96
+ _B64URL = re.compile(r"^[A-Za-z0-9_-]+\Z")
95
97
  # DL-2's grammar, as the contract's verifier types the claims (DEC-0045): an agent id, an opaque session or task id,
96
98
  # a URI, a jti and a scope.
97
- AGENT_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?$")
98
- OPAQUE_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
99
- _URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:\S+$")
100
- _JTI = re.compile(r"^[A-Za-z0-9._~-]{16,128}$")
101
- _SCOPE = re.compile(r"^[a-z][a-z0-9._-]{1,127}( [a-z][a-z0-9._-]{1,127})*$")
99
+ AGENT_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?\Z")
100
+ OPAQUE_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}\Z")
101
+ _URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:\S+\Z")
102
+ _JTI = re.compile(r"^[A-Za-z0-9._~-]{16,128}\Z")
103
+ _SCOPE = re.compile(r"^[a-z][a-z0-9._-]{1,127}( [a-z][a-z0-9._-]{1,127})*\Z")
102
104
  _SPEND_WINDOWS = ("total", "task", "project", "day", "week", "month")
103
- _MISSING = object()
104
105
 
105
106
 
106
107
  def _full(pattern: "re.Pattern[str]", value: Any) -> bool:
@@ -224,7 +225,8 @@ def _key_for(jwks: Dict[str, Any], kid: str) -> Dict[str, Any]:
224
225
  raw = _b64decode(jwk["x"])
225
226
  except ValueError:
226
227
  return {"problem": "unusable-key"}
227
- if len(raw) != 32:
228
+ # D4: exactly the 43 characters that write 32 bytes, so the key compared with the estate record is one string.
229
+ if len(raw) != 32 or _b64encode(raw) != jwk["x"]:
228
230
  return {"problem": "unusable-key"}
229
231
  if is_small_order_key(raw):
230
232
  return {"problem": "small-order-key"} # refused before any verification (DEC-0045, CT-29)
@@ -379,24 +381,27 @@ def _readable_clock(now: Any) -> bool:
379
381
  return False
380
382
 
381
383
 
382
- def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = None,
383
- revocations: Any = None) -> Optional[Tuple[str, str]]:
384
+ def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = _MISSING,
385
+ revocations: Any = _MISSING) -> Optional[Tuple[str, str]]:
384
386
  """DL-14 row 4a (DEC-0055, DEC-0060), right after the signature: when the callee reads the estate record, the
385
387
  token's kid and the published key must be certified by that revision. `estate` is a revision the caller verified
386
- (fabric_estate.verify_estate_record) and its machine applied, or a callable returning it; None skips the rule (a
387
- callable returning None is an unreadable source). `applied` is the highest revision the machine applied
388
- (`{"revision", "digest"?}`); `revocations` the revocation delta's entries applied since (a list or a callable). A
389
- source that raises is unreadable, never a pass. Returns ("token" | "issuer", detail) or None."""
390
- if estate is None:
388
+ (fabric_estate.verify_estate_record) and its machine applied, or a callable returning it; only an omitted `estate`
389
+ skips the rule, and None (given, or returned by the callable) is an unreadable source, as Node's null is (D3).
390
+ `applied` is the highest revision the machine applied (`{"revision", "digest"?}`); `revocations` the revocation
391
+ delta's entries applied since (a list or a callable). An omitted one is absent; None is unreadable. A source that
392
+ raises is unreadable, never a pass. Returns ("token" | "issuer", detail) or None."""
393
+ if estate is _MISSING:
391
394
  return None
392
395
  try:
393
396
  record = estate() if callable(estate) else estate
394
397
  extra = revocations() if callable(revocations) else revocations
395
398
  except Exception:
396
399
  return ("issuer", "estate-unreadable")
400
+ if applied is None: # the contract's hubKeyCertified refuses a null applied mark after its record checks: unreadable
401
+ return ("issuer", "estate-unreadable")
397
402
  import fabric_estate # beside this file; imported here, since fabric_estate imports this module
398
- verdict = fabric_estate.hub_key_certified(record, kid, x, now, applied,
399
- fabric_estate._MISSING if extra is None else extra)
403
+ given = {} if applied is _MISSING else {"applied": applied}
404
+ verdict = fabric_estate.hub_key_certified(record, kid, x, now, extra_revocations=extra, **given)
400
405
  if verdict["certified"]:
401
406
  return None
402
407
  # The clock was refused before row 1, so a verdict here is the token's or the trust source's.
@@ -404,8 +409,8 @@ def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = No
404
409
 
405
410
 
406
411
  def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
407
- issuer: Optional[str], read_only: bool = False, estate: Any = None,
408
- estate_applied: Any = None, estate_revocations: Any = None) -> Dict[str, Any]:
412
+ issuer: Optional[str], read_only: bool = False, estate: Any = _MISSING,
413
+ estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING) -> Dict[str, Any]:
409
414
  """DL-13, in its fixed order: nothing about a token is believed before its signature is.
410
415
  `now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
411
416
  {ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required.
@@ -525,8 +530,8 @@ UNSAFE_NO_JTI_STORE = _NoReplayProtection()
525
530
 
526
531
 
527
532
  def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: float, action_digest: str,
528
- jti_store: Any, issuer: Optional[str], estate: Any = None, estate_applied: Any = None,
529
- estate_revocations: Any = None) -> Dict[str, Any]:
533
+ jti_store: Any, issuer: Optional[str], estate: Any = _MISSING, estate_applied: Any = _MISSING,
534
+ estate_revocations: Any = _MISSING) -> Dict[str, Any]:
530
535
  """DL-18: a missing approval is approval-required carrying the action digest; any fault of the document is
531
536
  approval-invalid; a second use is approval-reused. The jti is consumed last."""
532
537
  if approval is None or approval == "":
@@ -624,18 +629,60 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
624
629
  return None
625
630
 
626
631
 
627
- _OPENER = urllib.request.build_opener(_NoRedirect)
632
+ def _opener(deadline: Dict[str, Any]) -> urllib.request.OpenerDirector:
633
+ """No redirect, and a timer that shuts the socket down at the deadline: a socket timeout bounds one read, so an
634
+ issuer trickling a byte a second would otherwise hold the caller for as long as it likes (finding 13)."""
635
+ def watched(base: type) -> type:
636
+ class Watched(base): # type: ignore[misc, valid-type]
637
+ def connect(self) -> None:
638
+ super().connect()
639
+ sock = self.sock
640
+
641
+ def fire() -> None:
642
+ deadline["fired"] = True
643
+ try:
644
+ sock.shutdown(2) # socket.SHUT_RDWR
645
+ except OSError:
646
+ pass
647
+ timer = threading.Timer(max(0.0, deadline["end"] - time.monotonic()), fire)
648
+ timer.daemon = True
649
+ deadline["timer"] = timer
650
+ timer.start()
651
+ return Watched
652
+
653
+ class Http(urllib.request.HTTPHandler):
654
+ def http_open(self, req: Any) -> Any:
655
+ return self.do_open(watched(http.client.HTTPConnection), req)
656
+
657
+ class Https(urllib.request.HTTPSHandler):
658
+ def https_open(self, req: Any) -> Any:
659
+ return self.do_open(watched(http.client.HTTPSConnection), req, context=self._context)
660
+ return urllib.request.build_opener(_NoRedirect, Http, Https)
628
661
 
629
662
 
630
663
  def _fetch(source: str) -> Any:
631
664
  if source.startswith(("https://", "http://")):
632
665
  request = urllib.request.Request(source, headers={"Accept": "application/json"})
666
+ deadline: Dict[str, Any] = {"end": time.monotonic() + JWKS_FETCH_SECONDS, "fired": False, "timer": None}
633
667
  try:
634
- with _OPENER.open(request, timeout=5) as response: # https or loopback http only, no redirect
635
- return json.loads(response.read(65537)[:65536])
668
+ with _opener(deadline).open(request, timeout=JWKS_FETCH_SECONDS) as response: # https or loopback http only
669
+ body = b""
670
+ while len(body) <= 65536 and not deadline["fired"]:
671
+ chunk = response.read1(65537 - len(body))
672
+ if not chunk:
673
+ break
674
+ body += chunk
675
+ if deadline["fired"] or time.monotonic() > deadline["end"]:
676
+ raise TimeoutError("the key set did not arrive within %s s" % JWKS_FETCH_SECONDS)
677
+ return json.loads(body[:65536])
636
678
  except urllib.error.HTTPError as exc:
637
679
  exc.close()
638
680
  raise
681
+ except http.client.HTTPException as exc: # a garbage status line or headers: the issuer cannot be read
682
+ raise ConnectionError(type(exc).__name__) from None
683
+ finally:
684
+ if deadline["timer"] is not None:
685
+ deadline["timer"].cancel()
639
686
  return json.loads(Path(source).expanduser().read_text())
640
687
 
641
688
 
@@ -663,28 +710,37 @@ class JwksCache:
663
710
  self._tried_at = -math.inf
664
711
  self._reread_at = -math.inf # the last re-read for a kid the set did not hold
665
712
  self._lock = threading.Lock()
666
-
667
- def _read(self, now: float) -> None:
668
- self._tried_at = now
669
- try:
670
- value = self._fetch(self.source)
671
- except (OSError, ValueError):
672
- return
673
- if _has_keys(value):
674
- self._keys, self._read_at = value, now
713
+ self._pending: Optional[threading.Event] = None
675
714
 
676
715
  def get(self, kid: Optional[str] = None) -> Optional[Dict[str, Any]]:
716
+ """The key set, or None. The read runs outside the lock (finding 13): a caller whose kid the set holds never
717
+ waits on another caller's fetch, and one read at a time goes out, as Node's `pending` does."""
718
+ leader, waiting = False, None
677
719
  with self._lock:
678
720
  now = self._clock()
679
721
  fresh = now - self._read_at < JWKS_MAX_AGE_SECONDS
680
722
  known = self._keys is not None and (kid is None or any(isinstance(k, dict) and k.get("kid") == kid for k in self._keys["keys"]))
681
- if not fresh:
682
- if now - self._tried_at >= JWKS_RETRY_SECONDS:
683
- self._read(now)
684
- elif not known and now - self._reread_at >= JWKS_REREAD_SECONDS:
723
+ due = (not fresh and now - self._tried_at >= JWKS_RETRY_SECONDS) or (fresh and not known and now - self._reread_at >= JWKS_REREAD_SECONDS)
724
+ if due and fresh:
685
725
  self._reread_at = now
686
- self._read(now)
687
- return self._keys if now - self._read_at < JWKS_MAX_AGE_SECONDS else None
726
+ if due and self._pending is None:
727
+ self._pending, self._tried_at, leader = threading.Event(), now, True
728
+ elif due:
729
+ waiting = self._pending
730
+ if leader:
731
+ try:
732
+ value = self._fetch(self.source)
733
+ except Exception: # noqa: BLE001 - whatever a source raises leaves the set unread; the age check decides
734
+ value = None
735
+ with self._lock:
736
+ if _has_keys(value):
737
+ self._keys, self._read_at = value, now
738
+ done, self._pending = self._pending, None
739
+ done.set()
740
+ elif waiting is not None:
741
+ waiting.wait(JWKS_FETCH_SECONDS + 1)
742
+ with self._lock:
743
+ return self._keys if self._clock() - self._read_at < JWKS_MAX_AGE_SECONDS else None
688
744
 
689
745
 
690
746
  def _kid_of(token: str) -> Optional[str]:
@@ -699,8 +755,8 @@ TIERS = ("T0", "T1", "T2")
699
755
 
700
756
 
701
757
  def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: str,
702
- read_only: bool = False, clock: Callable[[], float] = time.time, estate: Any = None,
703
- estate_applied: Any = None, estate_revocations: Any = None) -> Callable[..., Dict[str, Any]]:
758
+ 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]]:
704
760
  """A check for one tools/call: `gate(tool, headers, tier="T2")` answers the accepted delegation or a DL-19
705
761
  refusal. `effects` maps each tool to its declared effect class (its manifest entry); a tool with none is
706
762
  refused rather than guessed. `jwks` is a JwksCache or a key set; `issuer` (auth.delegation.issuer) is required.
@@ -45,7 +45,7 @@ from fabric_usage import (
45
45
  DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
46
46
 
47
47
 
48
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.1"
48
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.1.2"
49
49
 
50
50
 
51
51
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
@@ -54,14 +54,14 @@ REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "
54
54
  STATUSES = ("starting", "ready", "degraded", "stopping")
55
55
 
56
56
 
57
- _ORIGIN = re.compile(r"^http://127\.0\.0\.1:([0-9]{3,5})$")
57
+ _ORIGIN = re.compile(r"^http://127\.0\.0\.1:([0-9]{3,5})\Z")
58
58
 
59
59
 
60
60
  # DEC-0019: a remote placement — an online agent or dashboard — lives at an https DNS name.
61
- _REMOTE_ORIGIN = re.compile(r"^https://((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(?::([0-9]{1,5}))?$")
61
+ _REMOTE_ORIGIN = re.compile(r"^https://((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(?::([0-9]{1,5}))?\Z")
62
62
 
63
63
 
64
- _RESERVED_HOST = re.compile(r"(^|\.)(localhost|local|internal|home\.arpa|lan|localdomain)$")
64
+ _RESERVED_HOST = re.compile(r"(^|\.)(localhost|local|internal|home\.arpa|lan|localdomain)\Z")
65
65
 
66
66
 
67
67
  PLACEMENTS = ("local", "remote")
@@ -213,13 +213,13 @@ CREDENTIAL_KINDS = ("subscription", "key", "platform-metered", "none")
213
213
  SUBSCRIPTION_RUNNERS = {"claude-code": "claude", "kimi-code": "kimi", "codex": "chatgpt"}
214
214
 
215
215
 
216
- _PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$", re.ASCII)
216
+ _PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?\Z", re.ASCII)
217
217
 
218
218
 
219
- _RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$", re.ASCII)
219
+ _RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])\Z", re.ASCII)
220
220
 
221
221
 
222
- _RUNNER_KIND_NAME = re.compile(r"^[a-z][a-z0-9-]{1,62}$", re.ASCII)
222
+ _RUNNER_KIND_NAME = re.compile(r"^[a-z][a-z0-9-]{1,62}\Z", re.ASCII)
223
223
 
224
224
 
225
225
  def _pointer(name: str) -> str:
@@ -451,10 +451,10 @@ def build_info(repo_root: Optional[Path] = None, package_file: Optional[Path] =
451
451
  return info
452
452
 
453
453
 
454
- _THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
454
+ _THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,31}\Z")
455
455
 
456
456
 
457
- _KIT_REF = re.compile(r"^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$")
457
+ _KIT_REF = re.compile(r"^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?\Z", re.ASCII)
458
458
 
459
459
 
460
460
  def dashboard_extension_problems(value: Any) -> List[str]:
@@ -502,13 +502,13 @@ def dashboard_extension(min_width: int = 360, appearance: Sequence[str] = ("sche
502
502
  return {DASHBOARD_EXTENSION_KEY: value}
503
503
 
504
504
 
505
- _PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
505
+ _PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}\Z")
506
506
 
507
507
 
508
- _RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
508
+ _RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}\Z")
509
509
 
510
510
 
511
- _MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}$")
511
+ _MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}\Z")
512
512
 
513
513
 
514
514
  def configuration_problems(doc: Any) -> List[str]:
@@ -20,7 +20,6 @@ in ``fabric_delegation``.
20
20
  from __future__ import annotations
21
21
 
22
22
  import base64
23
- import datetime as _dt
24
23
  import hashlib
25
24
  import json
26
25
  import math
@@ -397,12 +396,13 @@ def _revision_number(value: Any) -> bool:
397
396
  return _safe_integer(value) and value >= 1
398
397
 
399
398
 
400
- def hub_key_certified(record: Any, kid: Any, x: Any, at: Any, applied: Any = None,
399
+ def hub_key_certified(record: Any, kid: Any, x: Any, at: Any, applied: Any = _MISSING,
401
400
  extra_revocations: Any = _MISSING) -> Dict[str, Any]:
402
401
  """DL-14 (DEC-0055, DEC-0060): whether ``record`` certifies the delegation key ``kid`` with the published key's ``x``
403
402
  at ``at`` (an aware datetime or epoch seconds), for a verifier whose machine applied ``applied``
404
- (``{"revision", "digest"?}``). ``extra_revocations`` are the revocation delta's entries applied since the revision
405
- (DEC-0054). The contract's ``hubKeyCertified``, in its order; never raises. Answers ``{"certified": True, "machine",
403
+ (``{"revision", "digest"?}``; ``_MISSING``, the default, when none is known; an explicit None is no revision and
404
+ fails closed as ``estate-unreadable``, as Node's null does, decision D3). ``extra_revocations`` are the revocation
405
+ delta's entries applied since the revision (DEC-0054). The contract's ``hubKeyCertified``, in its order; never raises. Answers ``{"certified": True, "machine",
406
406
  "revision"}`` or ``{"certified": False, "fault": "token"|"estate"|"clock", "detail"}`` with detail
407
407
  ``kid-not-certified`` (the token's fault), ``estate-unreadable``, ``estate-outside-window``, ``estate-rolled-back``,
408
408
  ``estate-forked`` (the trust source's) or ``no-clock``."""
@@ -421,7 +421,7 @@ def hub_key_certified(record: Any, kid: Any, x: Any, at: Any, applied: Any = Non
421
421
  # A window that is not a time, not positive or longer than 30 days is no window: NaN fails every comparison below.
422
422
  if math.isnan(issued_at) or math.isnan(valid_until) or valid_until <= issued_at or valid_until - issued_at > _MAX_WINDOW_MS:
423
423
  return _refused("estate", "estate-unreadable")
424
- if applied is not None:
424
+ if applied is not _MISSING:
425
425
  if (not isinstance(applied, dict) or not _revision_number(_get(applied, "revision"))
426
426
  or (_get(applied, "digest") is not _MISSING and not isinstance(_get(applied, "digest"), str))):
427
427
  return _refused("estate", "estate-unreadable")
@@ -14,6 +14,7 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
14
14
  from fabric_base import (
15
15
  ServiceError,
16
16
  now_iso,
17
+ store_lock,
17
18
  )
18
19
  from fabric_paths import (
19
20
  atomic_write,
@@ -32,13 +33,13 @@ EVENTS_MAX_LIMIT = 200
32
33
  LEVELS = ("info", "notice", "warning", "error")
33
34
 
34
35
 
35
- _KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$")
36
+ _KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}\Z")
36
37
 
37
38
 
38
- _TRACE_ID = re.compile(r"^(?!0{32}$)[0-9a-f]{32}$")
39
+ _TRACE_ID = re.compile(r"^(?!0{32}\Z)[0-9a-f]{32}\Z")
39
40
 
40
41
 
41
- _SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
42
+ _SPAN_ID = re.compile(r"^(?!0{16}\Z)[0-9a-f]{16}\Z")
42
43
 
43
44
 
44
45
  def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
@@ -105,6 +106,7 @@ class JsonlEventLog:
105
106
  def __init__(self, path: Path, keep: int = 5000):
106
107
  self.path = Path(path)
107
108
  self.keep = keep
109
+ self._lock = store_lock(self.path) # R3: appends from a threaded server's handlers never lose one another
108
110
 
109
111
  def _all(self) -> List[Dict[str, Any]]:
110
112
  if not self.path.exists():
@@ -118,13 +120,14 @@ class JsonlEventLog:
118
120
  return out
119
121
 
120
122
  def append(self, kind: str, level: str, text: str, **extra: Any) -> Dict[str, Any]:
121
- rows = self._all()
122
- next_id = int(rows[-1]["id"]) + 1 if rows else 1
123
- event = make_event(next_id, now_iso(), kind, level, text, **extra)
124
- rows.append(event)
125
- rows = rows[-self.keep:]
126
- atomic_write(self.path, ("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n").encode(), 0o600)
127
- return event
123
+ with self._lock:
124
+ rows = self._all()
125
+ next_id = int(rows[-1]["id"]) + 1 if rows else 1
126
+ event = make_event(next_id, now_iso(), kind, level, text, **extra)
127
+ rows.append(event)
128
+ rows = rows[-self.keep:]
129
+ atomic_write(self.path, ("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n").encode(), 0o600)
130
+ return event
128
131
 
129
132
  def fetch(self, after: Optional[str], limit: int) -> List[Dict[str, Any]]:
130
133
  rows = self._all()
@@ -372,7 +372,7 @@ def request(method: str, url: str, *, headers: Optional[Mapping[str, str]] = Non
372
372
  status, got_headers, got = send(method, url, dict(sent), data, left, max_bytes)
373
373
  except HttpError:
374
374
  raise
375
- except TimeoutError:
375
+ except (TimeoutError, socket.timeout): # socket.timeout is no TimeoutError before Python 3.10
376
376
  failure = HttpError("%s %s: timed out" % (method, where), url=where, attempts=attempt, kind="timeout")
377
377
  except Exception as exc: # noqa: BLE001 - as the Node twin: whatever the transport throws is a connection error
378
378
  failure = HttpError("%s %s: connection error (%s)" % (method, where, type(exc).__name__), url=where,
@@ -44,7 +44,7 @@ import fabric_service as _fs # noqa: E402 - beside this file: token files, atom
44
44
  HUB_PROTOCOL = "fabric-hub/0.1"
45
45
  MCP_PROTOCOL_VERSION = "2025-06-18"
46
46
  READ_CAP = 64 * 1024
47
- LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{4,5})")
47
+ LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:([0-9]{4,5})")
48
48
  TOKEN = re.compile(r"[A-Za-z0-9_-]{43}")
49
49
  UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
50
50
  CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}")
@@ -163,8 +163,12 @@ def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_a
163
163
  if estate is not None and isinstance(doc.get("hubKey"), dict):
164
164
  import fabric_estate # beside this file
165
165
  key = doc["hubKey"]
166
+ # A missing or null applied is "none known", as fabric-hub.mjs reads it (`estate.applied ?? undefined`); only a
167
+ # present one is checked, and hub_key_certified refuses an explicit null of its own (decision D3).
168
+ applied = estate.get("applied")
166
169
  verdict = fabric_estate.hub_key_certified(estate.get("record"), key.get("kid"), key.get("x"),
167
- time.time() if now is None else now, estate.get("applied"))
170
+ time.time() if now is None else now,
171
+ fabric_estate._MISSING if applied is None else applied)
168
172
  if not verdict.get("certified"):
169
173
  raise HubError("hub-key-refused", "The estate record does not certify the hub's key (%s)." % verdict.get("detail"),
170
174
  data={"fault": verdict.get("fault"), "detail": verdict.get("detail")})