@passioncode-ai/passioncode 0.2.45 → 0.2.46

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 (56) hide show
  1. package/CHANGELOG.md +9 -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 +1 -1
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -1
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +73 -18
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -5
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +70 -20
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +7 -1
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +9 -0
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -5
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
  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 +4 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +16 -9
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +87 -156
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +180 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +4 -2
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +2 -0
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +15 -3
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +12 -1
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +12 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +32 -13
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +115 -147
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +173 -0
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +9 -42
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -4
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +7 -3
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -2
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +87 -11
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +19 -8
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +30 -6
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +9 -1
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +15 -1
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +73 -18
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -1
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +134 -24
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +155 -21
  46. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  47. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -10
  48. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
  49. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +39 -13
  50. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -2
  51. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -3
  52. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -6
  53. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +10 -39
  54. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +190 -0
  55. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -1
  56. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.46 - 2026-10-28
4
+
5
+ ### Changed
6
+
7
+ - **Fabric Agent Adapter is pinned at `v0.56.0`** (it was `v0.55.0`): stability and Windows. working-with-fabric names
8
+ the repositories a Windows turn touched; the board responder recovers from a refused slot, leaves within 3 s when
9
+ idle and logs its notices at `warning` with a subject; the reference service can take a moved slot back; a spend
10
+ gate fails closed.
11
+
3
12
  ## 0.2.45 - 2026-10-27
4
13
 
5
14
  ### Changed
package/family.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "name": "fabric-agent-adapter",
9
9
  "displayName": "Fabric Agent Adapter",
10
10
  "repo": "passioncode-ai/fabric-agent-adapter",
11
- "ref": "v0.55.0",
11
+ "ref": "v0.56.0",
12
12
  "kind": "plugin",
13
13
  "path": "plugins/fabric-agent-adapter",
14
14
  "legacyPluginIds": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.2.45",
3
+ "version": "0.2.46",
4
4
  "description": "Set up PassionCode.ai on a computer with one command — the apps from their verified releases, their MCP servers for every coding agent and the PassionCode.ai skills — and check and repair it with the same command or `doctor`.",
5
5
  "bin": {
6
6
  "passioncode": "bin/passioncode.js"
@@ -11,7 +11,7 @@
11
11
  "name": "fabric-agent-adapter",
12
12
  "displayName": "Fabric Agent Adapter",
13
13
  "source": "./plugins/fabric-agent-adapter",
14
- "version": "0.55.0",
14
+ "version": "0.56.0",
15
15
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
16
16
  "author": {
17
17
  "name": "PassionCode.ai",
@@ -35,7 +35,7 @@
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.2.45",
38
+ "version": "0.2.46",
39
39
  "description": "Set up and check PassionCode.ai on a computer with one command (setting-up-passioncode), the organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
40
40
  "author": {
41
41
  "name": "PassionCode.ai",
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.2.45",
3
+ "version": "0.2.46",
4
4
  "release": true,
5
5
  "members": [
6
6
  {
7
7
  "name": "fabric-agent-adapter",
8
8
  "displayName": "Fabric Agent Adapter",
9
9
  "repo": "passioncode-ai/fabric-agent-adapter",
10
- "ref": "v0.55.0",
11
- "commit": "f3f2d14e381e3526600915a63eff25a912c7875d",
12
- "version": "0.55.0",
10
+ "ref": "v0.56.0",
11
+ "commit": "250d5d7a18980bacdfc7ba3bb449794995d4c07d",
12
+ "version": "0.56.0",
13
13
  "via": "clone of https://***@github.com/passioncode-ai/fabric-agent-adapter.git",
14
14
  "skills": [
15
15
  "adapting-projects-to-fabric",
@@ -23,7 +23,7 @@
23
23
  "legacyMarketplaces": [
24
24
  "fabric-agent-adapter"
25
25
  ],
26
- "contentHash": "sha256:74f7d28f584cd89d1b8d541534b291bdbad058df62c887c9e1b71b8fa25d0cd1",
26
+ "contentHash": "sha256:a27c7b99d9b29039b28aea11680bf1acce43a6c7792b8e80b16310559d424856",
27
27
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
28
28
  "author": {
29
29
  "name": "PassionCode.ai",
@@ -62,9 +62,9 @@
62
62
  "name": "passioncode",
63
63
  "displayName": "PassionCode.ai",
64
64
  "repo": "passioncode-ai/passioncode",
65
- "ref": "v0.2.45",
65
+ "ref": "v0.2.46",
66
66
  "commit": null,
67
- "version": "0.2.45",
67
+ "version": "0.2.46",
68
68
  "via": "this repository",
69
69
  "skills": [
70
70
  "setting-up-passioncode",
@@ -72,7 +72,7 @@
72
72
  ],
73
73
  "legacyPluginIds": [],
74
74
  "legacyMarketplaces": [],
75
- "contentHash": "sha256:71445c5a7cad160da4d789b8a9cef7db8c20c0b8e9b65b91cc6d33022d047f90",
75
+ "contentHash": "sha256:26a83f55d769e493d16987ae596bb26288fa03f2b2f81231d22d5298ccd603bc",
76
76
  "description": "Set up and check PassionCode.ai on a computer with one command (setting-up-passioncode), the organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
77
77
  "author": {
78
78
  "name": "PassionCode.ai",
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.55.0",
6
+ "version": "0.56.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Two silent-unless-relevant hook sets. The dashboard-kit notice: one line at session start or /clear in a repository that owns a Fabric service whose dashboard has no kit or an older one (fabric-dashboard/0.1, DEC-0036), phrased as an upgrade the agent may make itself. The Fabric session hooks (working-with-fabric, COM-04, FAA-12a): at session start or /clear, the preamble when Claude Code has the MCP server `fabric` and Fabric runs (fabric_whoami, then the project board through com.status and com.list, claim and release); when Fabric is down, one line, only in a project that uses Fabric (a Fabric contract file, a project-bound registration or a service descriptor naming the repository); nothing on compact or resume; each turn's facts (HEAD, files changed, lines added and removed, never content), the board's unread count, and, once the running Fabric's fabric_session_observe takes them (never on Windows), the other repositories the turn touched (the last 512 KB of the session transcript, message text and tool output included (a secret the session displayed can be there; read in memory only), is then read and only the current turn's tool names and path arguments are used; host/owner/name leave the machine, never a local path or text) and the session's end through Fabric, only when the running Fabric advertises session-hooks/1; at each prompt one line only when the unread count rose, then each nudge a turn's answer carried, once (watch-mail prints those of turns it delivered). All silent in a session Fabric launched. Every hook reads no Fabric token (a credential embedded in an origin URL is read by git config and dropped in memory, never spooled or sent), prints at most one block, always exits 0, and hands any Fabric call to a detached worker with a 10 s deadline.",
2
+ "description": "Two silent-unless-relevant hook sets. The dashboard-kit notice: one line at session start or /clear in a repository that owns a Fabric service whose dashboard has no kit or an older one (fabric-dashboard/0.1, DEC-0036), phrased as an upgrade the agent may make itself. The Fabric session hooks (working-with-fabric, COM-04, FAA-12a): at session start or /clear, the preamble when Claude Code has the MCP server `fabric` and Fabric runs (fabric_whoami, then the project board through com.status and com.list, claim and release); when Fabric is down, one line, only in a project that uses Fabric (a Fabric contract file, a project-bound registration or a service descriptor naming the repository); nothing on compact or resume; each turn's facts (HEAD, files changed, lines added and removed, never content), the board's unread count, and, once the running Fabric's fabric_session_observe takes them (unless FABRIC_SESSION_NO_REPOSITORIES=1), the other repositories the turn touched (the last 512 KB of the session transcript, message text and tool output included (a secret the session displayed can be there; read in memory only), is then read and only the current turn's tool names and path arguments are used; host/owner/name leave the machine, never a local path or text) and the session's end through Fabric, only when the running Fabric advertises session-hooks/1; at each prompt one line only when the unread count rose, then each nudge a turn's answer carried, once (watch-mail prints those of turns it delivered). All silent in a session Fabric launched. Every hook reads no Fabric token (a credential embedded in an origin URL is read by git config and dropped in memory, never spooled or sent), prints at most one block, always exits 0 once python3 starts (on Windows it must name Python in Git Bash, FAA-183), and hands any Fabric call to a detached worker with a 10 s deadline.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.55.0"
8
+ version: "0.56.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "00e050ba7429c0c60261cec871779e0fd6aeeb28"
11
11
  ---
@@ -47,7 +47,11 @@ ENV_MAX = 64
47
47
  # Whole-value patterns end in \Z, never `$` (R1: `$` also matches before a trailing newline).
48
48
  _ID = re.compile(r"^[a-z][a-z0-9-]{1,62}\Z")
49
49
  _ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]*\Z")
50
- _URI_PASSWORD = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://[^/?#@]*:[^/?#@]*@")
50
+ # A special scheme (http, https, ws, wss, ftp) reads a user:password@ after any run of slashes, none included, as
51
+ # `new URL` does: https:bot:pw@host carries the password too (0.56.0 seam r7).
52
+ _URI_PASSWORD = re.compile(r"^(?:(?:[Hh][Tt][Tt][Pp][Ss]?|[Ww][Ss][Ss]?|[Ff][Tt][Pp]):[/\\]*[^/\\?#]*:[^/\\?#]*@"
53
+ # any other scheme: a backslash belongs to the user information (seam r8)
54
+ r"|(?![Hh][Tt][Tt][Pp][Ss]?:|[Ww][Ss][Ss]?:|[Ff][Tt][Pp]:)[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*:[^/?#]*@)")
51
55
  _SECRET_REF = re.compile(r"^secret-ref:([A-Za-z0-9][A-Za-z0-9._/:@-]{0,255})\Z")
52
56
  _LOCAL_PATH = re.compile(LOCAL_PATH_PATTERN)
53
57
  _URL = re.compile(URL_PATTERN)
@@ -2,12 +2,15 @@
2
2
  """The contract's one credential detector (Fabric Agent Contract DEC-0044, `src/secret-shape.ts` at the pin): a name
3
3
  that can only carry a credential, a value shaped like one, a launch argument that carries one, and a URL that does
4
4
  (LC-21). A port held to the contract's own answers by test/fixtures/contract/profile-vectors.json. Standard library
5
- only. The dashboard kit's fabric-button.js carries its own copy of the name and value checks (FL-4)."""
5
+ only. The dashboard kit's fabric-button.js carries its own copy of the name and value checks (FL-4).
6
+
7
+ It reads as the contract's ECMAScript does (FAA-136): ECMAScript's whitespace (Python's `\\s` also takes U+0085 and
8
+ U+001C to U+001F, and not U+FEFF), `.` stopping at every line terminator, ASCII case folding, an ASCII word boundary."""
6
9
 
7
10
  from __future__ import annotations
8
11
 
9
12
  import re
10
- from typing import Dict, List
13
+ from typing import Dict, List, Tuple
11
14
  import urllib.parse
12
15
 
13
16
  CREDENTIAL_WORDS = (
@@ -22,20 +25,28 @@ CREDENTIAL_PAIRS: Dict[str, tuple] = {
22
25
  "session": ("token", "id"),
23
26
  "code": ("login", "auth", "access", "session"),
24
27
  }
28
+ #: ECMAScript's \s, as a character class body.
29
+ JS_SPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
30
+ _NOT_LINE_END = "[^\n\r\u2028\u2029]" # ECMAScript's `.` without the s flag
25
31
  JWT_SHAPE = re.compile(r"eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*")
26
- SECRET_VALUE = re.compile(r"(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|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,})",
27
- re.IGNORECASE | re.DOTALL)
32
+ SECRET_VALUE = re.compile(r"(^bearer[%s]%s+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}"
33
+ r"|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})" % (JS_SPACE, _NOT_LINE_END), re.IGNORECASE | re.ASCII)
28
34
  _LONG_RUN = re.compile(r"[A-Za-z0-9+/=_-]{40,}")
29
- _BEARER_INSIDE = re.compile(r"\bbearer\s+(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{12,}=*", re.IGNORECASE)
30
- _PATH_LIKE = re.compile(r"^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE)
35
+ _BEARER_INSIDE = re.compile(r"(?<![A-Za-z0-9_])bearer[%s]+(?=[A-Za-z0-9._~+/-]*[0-9])[A-Za-z0-9._~+/-]{12,}=*" % JS_SPACE,
36
+ re.IGNORECASE | re.ASCII)
37
+ _PATH_LIKE = re.compile(r"^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*://)", re.IGNORECASE | re.ASCII)
38
+ _WORD_SPLIT = re.compile("[%s._-]+" % JS_SPACE)
39
+ _ARGUMENT_SPLIT = re.compile("[%s=]" % JS_SPACE)
31
40
  _SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
41
+ #: WHATWG's special schemes that carry an authority with user information (file has none).
42
+ _SPECIAL = ("ftp", "http", "https", "ws", "wss")
32
43
 
33
44
 
34
45
  def name_words(name: str) -> List[str]:
35
46
  """A name's words: split at `.`, `_`, `-` and camelCase humps, lowercased (`APIKey` is `api key`)."""
36
47
  spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name)
37
48
  spaced = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", spaced)
38
- return [w.lower() for w in re.split(r"[\s._-]+", spaced) if w]
49
+ return [w.lower() for w in _WORD_SPLIT.split(spaced) if w]
39
50
 
40
51
 
41
52
  def _with_singulars(words: List[str]) -> List[str]:
@@ -73,7 +84,7 @@ def secret_argument(value: str) -> bool:
73
84
  so a folder path holding a session id is not taken for a key."""
74
85
  if JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or _BEARER_INSIDE.search(value):
75
86
  return True
76
- for piece in re.split(r"[\s=]", value):
87
+ for piece in _ARGUMENT_SPLIT.split(value):
77
88
  if _path_like(piece):
78
89
  if any(_long_secret(part) for part in re.split(r"[\\/]", piece)):
79
90
  return True
@@ -93,7 +104,8 @@ def _decode_keeping_plus(raw: str) -> str:
93
104
 
94
105
 
95
106
  def _form_name(text: str) -> str:
96
- pairs = urllib.parse.parse_qsl(text, keep_blank_values=True)
107
+ # URLSearchParams drops one leading `?` from the text it is given (0.56.0 unit r7).
108
+ pairs = urllib.parse.parse_qsl(text[1:] if text.startswith("?") else text, keep_blank_values=True)
97
109
  return pairs[0][0] if pairs else ""
98
110
 
99
111
 
@@ -109,23 +121,66 @@ def raw_values(query: str) -> Dict[str, List[str]]:
109
121
  return values
110
122
 
111
123
 
124
+ #: The C0 controls and space, U+0000 to U+0020: what WHATWG URL parsing trims from a URL's ends.
125
+ _C0_OR_SPACE = "".join(map(chr, range(0x21)))
126
+
127
+
128
+ _LONE_SURROGATE = re.compile("[\ud800-\udfff]")
129
+
130
+
131
+ def _percent_encode(text: str, also: str) -> str:
132
+ """WHATWG's percent-encoding of a query or a fragment: every C0 control, space, `"`, `<`, `>`, every code point
133
+ above U+007E and the characters in `also`, as UTF-8 %XX (a lone surrogate as U+FFFD, as a USVString reads it).
134
+ `new URL` hands the contract's secretUrl the text in this form, so a malformed escape left in it is read the same
135
+ way by both ports (0.56.0 unit r8)."""
136
+ out = []
137
+ for char in _LONE_SURROGATE.sub("\ufffd", text):
138
+ if ord(char) <= 0x20 or ord(char) > 0x7E or char in '"<>' or char in also:
139
+ out.append("".join("%%%02X" % byte for byte in char.encode("utf-8")))
140
+ else:
141
+ out.append(char)
142
+ return "".join(out)
143
+
144
+
145
+ def _url_parts(value: str) -> Tuple[str, str, str]:
146
+ """The password, query and fragment of an absolute URL as WHATWG URL parsing splits them, read by hand: urlsplit
147
+ refuses a `[` or `]` outside an IPv6 host (`https://u:p[w@h/`), where `new URL` reads the password (0.56.0 unit
148
+ r7). The fragment starts at the first `#`, the query at the first `?` before it. A special scheme's authority starts
149
+ after every slash or backslash that follows `scheme:` (the special authority ignore slashes state) and ends at the
150
+ next slash, backslash, `?` or `#`, so `https:u:p@h` and `https:\\u:p@h` carry the password p; any other scheme
151
+ has an authority only after `//`, and `file` has no user information. The user information ends at the authority's
152
+ last `@`, and the password follows its first `:`. A host `new URL` would refuse is not checked: such a URL is read
153
+ for its password and parameters all the same, the safe direction for a detector."""
154
+ scheme, _, rest = value.partition(":")
155
+ rest, _, fragment = rest.partition("#")
156
+ rest, _, query = rest.partition("?")
157
+ lowered = scheme.lower()
158
+ if lowered in _SPECIAL:
159
+ authority = rest.replace("\\", "/").lstrip("/").partition("/")[0]
160
+ elif lowered != "file" and rest.startswith("//"):
161
+ authority = rest[2:].partition("/")[0]
162
+ else:
163
+ authority = ""
164
+ userinfo, at, _ = authority.rpartition("@")
165
+ return ((userinfo.partition(":")[2] if at else ""), _percent_encode(query, "'" if lowered in _SPECIAL or lowered == "file" else ""), # file is special too
166
+ _percent_encode(fragment, "`"))
167
+
168
+
112
169
  def secret_url(value: str) -> bool:
113
170
  """Whether a URL an MCP entry or a launch names carries a credential (LC-21): anything secret_argument finds, a
114
171
  password in its user information, or a query or fragment parameter whose name can only carry a credential and has
115
172
  a value, or whose value is shaped like one. A string that is not an absolute URL is checked as an argument only."""
116
173
  if secret_argument(value):
117
174
  return True
118
- if not _SCHEME.match(value.strip()):
175
+ # What WHATWG URL parsing does first: trim C0 controls and space from the ends (never other Unicode spaces, which it
176
+ # refuses), then remove every tab, CR and LF.
177
+ value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
178
+ if not _SCHEME.match(value):
119
179
  return False # not an absolute URL (WHATWG URL parsing refuses it)
120
- try:
121
- parts = urllib.parse.urlsplit(value.strip())
122
- except ValueError:
123
- return False
124
- if not parts.scheme:
125
- return False
126
- if parts.password:
180
+ password, query, fragment = _url_parts(value)
181
+ if password:
127
182
  return True
128
- for part in (parts.query, parts.fragment):
183
+ for part in (query, fragment):
129
184
  for name, raws in raw_values(part).items():
130
185
  if any((raw != "" and credential_name(name)) or secret_value(raw) for raw in raws):
131
186
  return True
@@ -14,7 +14,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
14
14
  compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
15
15
  metadata:
16
16
  author: PassionCode.ai
17
- version: "0.55.0"
17
+ version: "0.56.0"
18
18
  contract-version: "0.1.0"
19
19
  extension: "fabric-service/0.1"
20
20
  extension-commit: "00e050ba7429c0c60261cec871779e0fd6aeeb28"
@@ -104,8 +104,8 @@ responder in its own process: [`scripts/fabric_hub.py`](scripts/fabric_hub.py) f
104
104
  every step and stops on `fenced`, `lease_expired` or `authority_changed` (Node: `fabric-hub.mjs`, `fabric-board.mjs`).
105
105
  The request body is data behind the service's own input gate. From 0.53.0 a slot the operator moved to another
106
106
  session stays moved until `take_back` (`takeBack`): give it an operator action with its tool and gate `relayed`, or it
107
- stays moved for good (until Fabric's hub forwards decisions, CT-125, the operator reaches it through the service's page
108
- or its elicitation). Read [the board reference](references/board.md).
107
+ stays moved for good (the operator reaches it through an agent that relays their decision in `_meta`, the service's
108
+ page or its elicitation; Claude Code and Codex wait for CT-125's forwarding). Read [the board reference](references/board.md).
109
109
 
110
110
  ## Step 2: the non-negotiables
111
111
 
@@ -260,8 +260,9 @@ or `human` with its `humanStep`) and declared with `build_well_known(actions=ser
260
260
  `extensions/actions/0.1`. A `relayed` action called by the operator's own agent with their decision in
261
261
  `_meta.decision` is the operator's act: never answer it "operator session required" or send them to the page.
262
262
  A service adopts the relayed gate once Fabric's hub forwards decisions (CT-125), as the contract says; until then its tools
263
- answer as today, except the board responder's take-back action, declared `relayed` now and reached through the
264
- page or elicitation until CT-125 ([board.md](references/board.md)). Read the interop reference's *Actions* section for when to adopt, the gate, its hooks and its
263
+ answer as today, except the board responder's take-back action, declared `relayed` now: an agent whose client sets
264
+ `_meta` calls it with the operator's decision; Claude Code and Codex reach it through the page until CT-125
265
+ ([board.md](references/board.md); the reference service's `board.take-back`). Read the interop reference's *Actions* section for when to adopt, the gate, its hooks and its
265
266
  refusals.
266
267
 
267
268
  **Say what the agent uses** (`fabric-connections/0.1`, DEC-0096) so Fabric Dashboards draws its chain: the other
@@ -42,7 +42,9 @@ operator gives in Fabric (Agents & access): callee `fabric`, capability `board.r
42
42
 
43
43
  The responder runs inside the service process, started after the instance lock like every other piece of state
44
44
  (SKILL.md, Step 3). [`sample_service.py`](../scripts/sample_service.py) does it behind `serve --board-project <uuid>`,
45
- off by default:
45
+ off by default (register it with the same `--board-project`, so its manifest lists the take-back tool below; a serve
46
+ whose board mode differs from the registered manifest at its start logs `service.manifest.mismatch` and leaves the
47
+ manifest as it is):
46
48
 
47
49
  ```python
48
50
  import fabric_board as fbd, fabric_hub as fhb
@@ -53,12 +55,13 @@ responder = fbd.Responder(
53
55
  handler=render, admit=gate, observe=look_on_disk, emit=log.append, service="example-agent")
54
56
  board = threading.Thread(target=responder.run, args=(stop_event, 5.0), daemon=True) # SIGTERM: set stop_event
55
57
  board.start()
56
- # ... at stop, after stop_event is set: wait for it to leave its board session (a clean leave forgets its slot
57
- # records, FAA-155), bounded inside the kit's hard exit, as scripts/sample_service.py does
58
+ # ... at stop, after stop_event is set: wait for it to leave its board session (a clean leave forgets the slot
59
+ # records it confirmed, FAA-155), bounded inside the kit's hard exit, as scripts/sample_service.py does
58
60
  board.join(timeout)
59
61
  ```
60
62
 
61
- Node: `new Responder({...same names in camelCase})`, then `responder.run({ signal, drain })`. A service that lists
63
+ Node: `new Responder({...same names in camelCase})`, then `const running = responder.run({ signal, drain })`; at stop,
64
+ abort the signal and `await running`, bounded inside the hard exit (the run leaves its board session before it settles). A service that lists
62
65
  `fabric-project-comms/0.1` in its well-known `protocols` (DEC-0067: only once its hosts are pinned at or after it)
63
66
  declares that it answers board requests; the probe checks that declaration.
64
67
 
@@ -144,6 +147,28 @@ answers `not_available` or `capacity_exceeded`, which do not say whether the rep
144
147
  in line and the next tick asks again, and a restart asks with the same old fence. A request leaves the resume queue only
145
148
  once it has resumed: when the `com.complete` that sends a known outcome loses its answer, the next tick sends the same
146
149
  key again in the same session (FAA-86). `shutdown()` drains, then leaves the session (its DELETE releases the slot).
150
+ An idle responder waits 3 s at most for that DELETE (`IDLE_LEAVE_SECONDS`), so an idle stop ends within the org's 5 s
151
+ (LC-01, FAA-173); one with work waits up to the session's 15 s client timeout. A board call already in flight when the
152
+ stop comes (a poll against a slow hub) runs first; when the service's bounded join ends before the DELETE is sent, the
153
+ slot lapses on the board's clock as before. The slot records are forgotten only after
154
+ a DELETE Fabric answered with a 2xx (a 404, the session already gone, keeps them: a move made before it ended is read at the next `com.status`), also when a session is dropped on a live board (FAA-163): an unanswered one keeps
155
+ them, and the slot lapses in 60 s.
156
+
157
+ **One capability at a time (FAA-163).** At connect (and every reconnect) the responder starts with no slot. Each
158
+ capability's enroll or replace is settled on its own: a refusal of that capability (`capacity_exceeded`,
159
+ `invalid_arguments`, any code that is not about the board session) leaves it `retry`, asked again alone after 15 s,
160
+ 30 s, doubling to 15 minutes, with one `board.slot.refused` event (warning; said again if it is refused again after
161
+ the slot was held); its accepted work waits `lapsed` and goes back
162
+ by a transfer once the slot is held. The capabilities after it are recovered as before. A refusal of the board session
163
+ (`unreachable`, `session-ended`, `not_available`, `not_authorized` and the other codes in `SESSION_CODES`) leaves the
164
+ rest `retry` too, says no `board.slot.refused`, and goes to the responder's fault, which says it by code:
165
+ `board.access.needed` for `not_authorized`, `authority_changed` and `credential` (the session is left),
166
+ `board.blocked` for a defect (`no-epoch`, `schema-unsupported`, `checkpoint`, `receipt-shape`,
167
+ `idempotency_conflict`), and `board.down` for the rest: `unreachable` keeps the session while a held slot's lease runs
168
+ (so a lost enroll of one capability is asked again at its next retry), `not_available` keeps it except at connect or recovery with no slot held,
169
+ and `session-ended`, `not-running` and the other hub codes drop it (as a fault during connect or recovery does) and
170
+ reconnect. A slot whose lease lapsed (`lease_expired`) holds
171
+ no lease for that decision.
147
172
 
148
173
  ## When the operator holds work (DEC-0079)
149
174
 
@@ -178,9 +203,16 @@ held and not transferred). The responder keeps that move instead of undoing it:
178
203
  {project, generation, moved?, asking?}}`). The record is written when a receipt's generation changes and before an
179
204
  enroll or a replace that can change it; a live holder's renew (about every 20 s) cannot, and writes nothing. At a
180
205
  clean leave (the service stopped; the session's DELETE released its slots) the record of a slot with no request left
181
- in the checkpoint and not moved is forgotten (a moved slot's record stays, so a clean stop never undoes a move): the slot is free, and whoever takes it next is no move. A file without the record
182
- reads as before; a kit before 0.53.0 reads the file, ignores the record and drops it at its first checkpoint write. So a service stopped cleanly with no request left in its checkpoint, rolled back to 0.52 and up again starts as on a first
183
- start. After a crash the record stays: if 0.52 then ran, enrolled the slot and
206
+ in the checkpoint and not moved is forgotten when this session confirmed its generation (held it, paused under the
207
+ operator's hold included, or read it unmoved in `com.status`) and no fenced call has put in doubt since: the slot is
208
+ free, and whoever takes it next is no move. An unconfirmed record stays, since the slot may have moved while the
209
+ service was down, and a move this session saw keeps its record, so a clean stop never undoes it. A paused or
210
+ retrying slot reads `com.status` before each new ask. A move made after the slot's last renew or check and before
211
+ the stop, with no call since, is not seen at the leave: its record is forgotten, and the next start reads the slot
212
+ as free and may enroll it once the other holder lets it go (FAA-187). A file without the record
213
+ reads as before; a kit before 0.53.0 reads the file, ignores the record and drops it at its first checkpoint write. So a service stopped cleanly with no request left in its checkpoint, its DELETE answered 2xx and every slot confirmed
214
+ in that session, rolled back to 0.52 and up again starts as on a first start. Otherwise (a 404, the idle wait running
215
+ out, a slot this session never confirmed, one left retrying before `com.status` answered for it included) its records stay, and the case is the crash case below. After a crash the record stays: if 0.52 then ran, enrolled the slot and
184
216
  wrote nothing to the checkpoint, the next 0.53 start reads that enroll as a move; bring it back with the service's take-back action (below).
185
217
  - An enroll or a replace is written into that record with its key before it is sent (`asking`). When its answer is
186
218
  lost, the next connect (or the next enroll) sends the same call again under the same key; the board answers the
@@ -205,8 +237,8 @@ held and not transferred). The responder keeps that move instead of undoing it:
205
237
  holder, or the estate owner, reconciles it now). No `com.claim`, `com.renew`, `com.complete`, `com.enroll` or
206
238
  `com.responder_replace` is sent for that capability again: not at recovery after a restart, and not when the new
207
239
  holder's lease lapses, nor when com.status later names no generation for the slot (a recorded move stays until
208
- take-back). Accepted requests stay held on the board, unanswered, until the slot is taken back (CT-175
209
- will hand them to the new holder). One `board.slot.moved` event (warn, notify) says so, once, also across restarts
240
+ take-back). Accepted requests stay `accepted` on the board, unanswered, until the slot is taken back (CT-175
241
+ will hand them to the new holder). One `board.slot.moved` event (warning, notify) says so, once, also across restarts
210
242
  (the record keeps `moved`).
211
243
  - A responder that never held the slot (a first start: no record, or a record of another Project), or whose record
212
244
  equals the current generation, behaves as before: it enrolls, recovers and replaces its own lapsed work. A recorded or
@@ -216,18 +248,28 @@ held and not transferred). The responder keeps that move instead of undoing it:
216
248
  records its own generation. Only a replay the board refuses, or one that ran for the first time (`repeated: false`)
217
249
  after the slot had moved past the record, reads as a move: the safe side; a slot such a replay took is left to lapse
218
250
  unused. A generation written `2.0` is `2`.
219
- - Only the operator brings the slot back: `take_back(capability)` (`takeBack`) forgets the record; never edit the
251
+ - Only the operator brings the slot back: `take_back(capability)` (`takeBack`) forgets the record, and wins over a move
252
+ being read at the same time: the move is written under the responder's lock (Node: on the fence queue, after any fenced
253
+ call in flight) and only while the record is unchanged (FAA-158). Never edit the
220
254
  checkpoint's `slots` record by hand to undo a move. In the same board
221
255
  session the next tick enrolls once the other session lets the slot go (a live holder is waited for) and takes the work
222
- left `lapsed` back with a transfer. A restart or reconnect before that, with accepted work held for the slot, takes it
256
+ left `lapsed` back with a transfer. A restart or reconnect before that, with accepted work waiting for the slot, takes it
223
257
  back at once by `com.responder_replace`, as a restart did before 0.53.0: the operator asked for the slot back, so the
224
258
  other session is replaced. In recovery, a refusal that leaves no slot held reconnects; one that leaves some held keeps
225
- the session and serves them. The kit gives it no route of its own (the reference service has none yet,
226
- FAA-157): a service built on the kit declares it as an operator action, a button with its tool ([Every button has a
227
- tool](../SKILL.md), `fabric-actions/0.1`) with gate `relayed` (the operator's decision), never `none`, so no other
228
- agent can call it on its own; on a server that reads mandates (`agent_id`) it also names its effect class, for example
229
- `change-policy` (DEC-0125); until Fabric's hub forwards decisions (CT-125) the operator reaches it through the
230
- service's page or its elicitation, and names it in its docs; otherwise a moved slot stays moved. A move back to this service's own session is not
259
+ the session and serves them. The kit gives it no route of its own. The reference service (`sample_service.py`) offers
260
+ it on its dashboard (FAA-157): a moved slot shows a Take back button that posts `POST /api/board/take-back` from the
261
+ operator's signed-in session with the `X-Fabric-Request` header; the service token alone is refused. Since 0.56.0 the
262
+ button has its tool, `board.take-back` with `{capability}`, declared under `extensions/actions/0.1` with gate
263
+ `relayed` and target `capability`, and a capability of the manifest like every tool the well-known lists
264
+ (`interop.well-known-capabilities`): an agent whose MCP client sets `_meta` calls it over `/mcp` with the operator's
265
+ decision in `_meta.decision` (a call without one is `operator-decision-required`); Claude Code and Codex send
266
+ arguments only, so until CT-125 they hand the operator the page as Fabric Dashboards' `open_link`. Either door records
267
+ the take-back as a `board.slot.returned` event (info, the capability as its subject) whose sentence says the route in
268
+ words and whose `data` holds `by`, `via` and the `caller` (none when the feed refuses the caller), never the decision's words; a repeat after a lost answer is answered `{capability, moved: false}`. A service built on the kit declares it as an operator action, a button with its tool ([Every button has a
269
+ tool](../SKILL.md), `fabric-actions/0.1`) with gate `relayed` (the operator's decision), never `none`, so a call
270
+ without the operator's relayed decision is refused; on a server that reads mandates (`agent_id`) it also names its effect class, for example
271
+ `change-policy` (DEC-0125); the operator reaches it through an agent that relays their decision in `_meta`, the
272
+ service's page or its elicitation (Claude Code and Codex, which cannot set `_meta`, wait for CT-125's forwarding), and the service names it in its docs; otherwise a moved slot stays moved. A move back to this service's own session is not
231
273
  detected yet: the receiving side and the hand-over of accepted requests (contract CT-175) wait for Fabric's session
232
274
  surface.
233
275
 
@@ -251,11 +293,19 @@ raises `directory-invalid` rather than answering; an unlisted Project may still
251
293
 
252
294
  ## Events, the probe and the live smoke
253
295
 
254
- Events go to `emit(kind, level, text, notify=…, data=…)` (`JsonlEventLog.append` fits) with `data {schema, value}`
296
+ Events go to `emit(kind, level, text, notify=…, subject=…, data=…)` (`JsonlEventLog.append` fits: every level is one
297
+ of its `info`, `notice`, `warning`, `error`; `subject` is `{type, id}`, the notice's request, else its capability, else
298
+ its episode (such as a block's cause), else the Project; in Python it goes only to an `emit` whose signature names a `subject`
299
+ parameter with no value bound, as `JsonlEventLog.append` does, and any other `emit`, one with only `**kwargs`, one with
300
+ a subject bound by `functools.partial` (which then stands) or one whose signature cannot be read, is called once, without
301
+ one, as before 0.56.0; the Node responder always passes it in its options, which an `emit` that does not read it ignores) with
302
+ `data {schema, value}`
255
303
  checked by FAC-SEM-101 (no credential in data): `board.request.completed`, `board.request.refused`,
256
304
  `board.request.skipped`, `board.request.reconciled`, `board.request.held`, `board.attempt.lost`,
257
- `board.reconcile.needed`, `board.slot.contested`, `board.slot.moved`, `board.access.needed`, `board.blocked`,
258
- `board.down`, each notifying episode said once.
305
+ `board.reconcile.needed`, `board.slot.contested`, `board.slot.moved`, `board.slot.refused`, `board.access.needed`, `board.blocked`,
306
+ `board.down`, each notifying episode said once, and `board.outcome.invalid` (warning) when a handler's outcome fails
307
+ its checks and is not sent. The operator's hold (below) adds `board.held`, `board.slot.held`,
308
+ `board.slot.released` and `board.request.waiting`. The reference service adds `board.slot.returned` at a take-back.
259
309
 
260
310
  `check_service.py <id> [--board-kit <the service's fabric_board.py or fabric-board.mjs>]` rule **board.responder**:
261
311
  `NOT_RUN` when the well-known `protocols` names no `fabric-project-comms` revision; `FAIL` for a revision this kit does
@@ -45,7 +45,12 @@ const answer = await request('GET', 'https://api.example/v1/items', { headers, d
45
45
 
46
46
  Every answer comes back as a `Response`, whatever its status; `HttpError` means no usable answer came, with `kind`
47
47
  `connection`, `timeout`, `too-large` or `refused`, the status when there was one, and the URL redacted to scheme,
48
- host, port and path (`redact_url`). An error never carries a request header or body.
48
+ host, port and path (`redact_url`); a URL that is not http or https keeps only its scheme (`htps:<redacted>`), and
49
+ one that does not parse as a URL is `<unparseable url>`. The path is not redacted (Node writes it as `new URL`
50
+ normalises it, Python as given), so a key never belongs in it. Python's `check_url` refuses a host
51
+ with a backslash, space, control character (C0, DEL or C1) or `%`, a URL with a space or ASCII control character before its
52
+ first `#` (the default transport sends a URL up to there, as Node's does; a transport you pass receives the URL as given), or a character outside ASCII in its path or query (ASCII whitespace at its ends is trimmed, as urllib and `new URL` trim it; a Unicode space ending the part before the first `#` is refused, where Node keeps it percent-encoded), a bracketed host that is not an IPv6 address with no zone, and a port that is not
53
+ ASCII digits, all as kind `refused` before sending; it checks a host's shape, not its name. `redact_url` on Python reads a host holding `%` as `<unparseable url>`; Node writes it decoded. An error never carries a request header or body.
49
54
 
50
55
  ## fabric_openrouter
51
56
 
@@ -77,6 +82,7 @@ const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
77
82
  | The fallback | required, of another vendor (the part before `/` differs) |
78
83
  | Content | text only: a string, null, or parts that are all `{"type": "text"}`; an image, audio or file part is refused (`config`), because what it is billed cannot be bounded from its bytes |
79
84
  | What the ceiling does not cover | the `provider` routing param can send a call to a provider priced above the catalog; two `Client` objects sharing one ledger each hold only their own calls in flight, so give an agent one client per key |
85
+ | The base URL | `base_url` (`baseUrl`), by default `https://openrouter.ai/api/v1`: checked by `check_url` when the client is built; ASCII whitespace and trailing `/` at its ends are trimmed (Node also trims every C0 control at the ends and removes a tab or line break anywhere, as `new URL` reads them); a base that is not a string is refused as `check_url` refuses it (`HttpError` kind `refused`, both twins); a query or a fragment is refused (kind `config`, both twins, before `check_url`). Python also refuses a space, a control or a non-ASCII character in the path (`check_url`'s `HttpError` kind `refused`); Node sends it percent-encoded, as `new URL` writes it (FAA-187 (18)) |
80
86
  | Attribution | off unless an `attribution` setting is given (FAC-SEM-098, `fabric_usage.attribution_headers`) |
81
87
  | Request body | `model`, `messages`, `max_tokens`, `usage: {include: true}` and your params (temperature, reasoning, response_format, tools); `stream`, `model`, `messages`, `max_tokens` and `usage` as params are refused |
82
88
 
@@ -124,6 +124,15 @@ page as Fabric Dashboards' `open_link`; both kits carry the same sentence. The g
124
124
  `JwksCache` holds the estate's key set at most 10 minutes and, on a `kid` it does not hold, re-reads at most once
125
125
  a minute (DL-15), and waits 10 s after a failed read before trying again; after 10 minutes without a successful read it answers nothing, and the verifier says
126
126
  `issuer-unavailable`. It reads https, http on loopback only, or a file; a URL with credentials is refused. The
127
+ Python kit also refuses, https included, a URL holding a space or ASCII control character before its first `#` (it
128
+ reads the URL up to there, as Node does), or a character outside ASCII in its path or query (urllib would send it
129
+ and every read fail; a trailing ASCII space or line break is trimmed as urllib reads it; a Unicode space ending the
130
+ part before the first `#` is refused, as `check_url` refuses it, because Node keeps it percent-encoded (urllib would
131
+ trim it and read the keys when it ends the URL); a leading space is refused as not a URL), a host holding `%`, a backslash, a Unicode space or a control character (urllib
132
+ would decode or dial it as another host), a bracketed host that is no IPv6 address, a port that is not ASCII digits or is above
133
+ 65535, no host, and an empty userinfo. The Node kit accepts several of these as `new URL` reads them, among them a `%`
134
+ host decoded, a backslash as `/`, a tab or line break removed, an empty userinfo dropped and an extra slash before the
135
+ host skipped. The
127
136
  Python kit verifies Ed25519 itself (RFC 8032, standard library only, strict as OpenSSL: `S` below the group order,
128
137
  canonical points, no small-order key); it follows no redirect when it reads the key set; the Node kit uses `node:crypto` and refuses small-order keys
129
138
  itself (`isSmallOrderKey`), because Node before 26 lets OpenSSL accept a signature under one.
@@ -149,7 +149,8 @@ operator's own coding agents: Claude Code and Codex cannot set `_meta`, so until
149
149
  a `relayed` gate turns "approve it" in chat into `operator-decision-required` with the page as the only route.
150
150
  Until then a service keeps its tools as they answer today, or gates a `relayed` tool only where it offers its own
151
151
  elicitation (the `elicited` hook). The one exception is the board responder's take-back action: it is declared
152
- `relayed` now and reached through the page or elicitation until CT-125 (`board.md`). This is D8's reason, which keeps `input_actions` off, applied to tools.
152
+ `relayed` now (the reference service's `board.take-back`); an agent whose MCP client sets `_meta` calls it with the
153
+ operator's decision, and Claude Code and Codex reach it through the page until CT-125 (`board.md`). This is D8's reason, which keeps `input_actions` off, applied to tools.
153
154
 
154
155
  **Parity (AP-1).** Every action the dashboard, app or page lets a person take (approve, reject, choose, edit, pause,
155
156
  resume, cancel, retry, send, publish, restore, set a policy, any other) has a tool on the MCP surface with the same
@@ -201,8 +202,11 @@ well_known = fs.build_well_known(..., surfaces=surfaces, actions=server.action_t
201
202
  `caller-missing`: an action-gated server must pass `tier`, `identify` or `authorize`,
202
203
  since `handle` defaults to T2 and a T2 call names nobody without them). One fault reaches the caller by name:
203
204
  `mandate-capability-missing` (DEC-0141 MJ-1), read as `operator-decision-required` with that `data.detail`.
204
- `on_fault` hears it from `ctx.approval_step` at a job's start under the job's tool, and at the answer under
205
- `fabric.job.get`; at the start it hears any fault `job_step` answers (also `now-unreadable` from the server's clock).
205
+ `on_fault` hears it under the job's capability (the name the job was created under) both from `ctx.approval_step`
206
+ at a job's start, where the caller sees no refusal: the job waits `input_required`, its message naming the fault,
207
+ and at an answer through `fabric.job.get`, where the call is refused; at the start it hears any fault `job_step`
208
+ answers (also `now-unreadable` from the server's clock). A capability name is what `^[a-z][a-z0-9._-]{1,127}$`
209
+ matches (`reviews.approve`, never `reviewsApprove`); a job created under another name is read under that name.
206
210
  - **Arguments with no canonical form** (a lone surrogate, a NaN or an infinity that Python's `json.loads` lets in,
207
211
  nesting past the recursion limit) have no digest to bind: a relayed tool answers JSON-RPC `-32602`, never an exception.
208
212
  - **The handler** reads `ctx.record` (`"caller"`, or `{by, via, caller}`) and writes it into the service's audit
@@ -305,7 +309,10 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
305
309
  step is kept in the job's private part (`request_input(..., steps=)`). An answer's `_meta.mandate` that covers that
306
310
  step is then read when `input_actions=True`, beside a decision for the key. The rule alone is `job_step(mandate, ...)`
307
311
  / `jobStep({mandate, tier, now, self, capability, key, effect, callerAgent, spendUsd, project})`; Node's
308
- `requestInput(jobId, inputRequests, statusMessage, {steps})` keeps the same private part. Each `request_input`
312
+ `requestInput(jobId, inputRequests, statusMessage, {steps})` keeps the same private part. Node has no
313
+ `approvalStep`, so a Node service calls `jobStep` itself and passes `{}` when the start call's `_meta` carried
314
+ `mandate: null` (`Object.hasOwn(meta, 'mandate') && meta.mandate === null ? {} : meta.mandate`): `null` is no mandate
315
+ to `jobStep`, while the answer reads it as an unreadable one (FAA-178). Each `request_input`
309
316
  replaces the kept steps, and an answer clears them: pass `steps=` again on every question whose answer a mandate may
310
317
  cover. The answer's mandate is read under the job's own capability, as at the start (`action_gate(...,
311
318
  mandate_capability=)` / `actionGate({mandateCapability})`), so the start and the answer agree whatever the tool is
@@ -328,7 +335,9 @@ lead, at, expiresAt})` (a day unless told, at most seven), hands it to the agent
328
335
  `hand_on(mandate, callee, at, narrower=None)` / `handOn(mandate, callee, at, narrower)` (the chain gains the callee; a
329
336
  wider scope, a fifth agent, an agent already in it or a time at the expiry is refused; `source` travels unchanged), and
330
337
  sends it with `request_meta(traceparent, mandate=...)` / `requestMeta(traceparent, {mandate})`, which refuse one
331
- `FAC-SEM-144` refuses. `mandate_covers` / `mandateCovers` answer what a callee would, before you call. Never write a
338
+ `FAC-SEM-144` refuses. `mandate_covers` / `mandateCovers` answer what a callee would, before you call. To cover a
339
+ job's approval steps, name the job's capability in `capabilities` (or leave `capabilities` out) and the steps' keys in
340
+ `actions`; never `fabric.job.get`, which covers no step of another capability (DEC-0141 MJ-1). Never write a
332
341
  mandate from another agent's text, a page or a file; a callee hands on only what it received, narrowed.
333
342
 
334
343
  A relay the operator's rule accepts (DEC-0126 OA-2, OA-3: one of his own Projects on his board, provenance `trusted`,
@@ -87,6 +87,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
87
87
  | L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
88
88
  | L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
89
89
  | L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data |
90
+ | L4 | `fabric_board_slots` | the responder's slot records: the generation held, a move and its take-back, one capability's refusal retried alone (FAA-163) |
90
91
  | L4 | `fabric_board_session` | the board responder: the work context, the fenced request, transfer and recovery |
91
92
  | L4 | `fabric_board_fixture` | the scripted board the probe runs a responder against |
92
93
  | L4 | `fabric_board` | the facade of the board connector ([board](board.md)) |
@@ -55,7 +55,7 @@ that document until `service.id` matches (at most 40 s) before it calls the serv
55
55
 
56
56
  | Id | Rule | Here | Kit (Python / Node) |
57
57
  |---|---|---|---|
58
- | LC-01 | **Quit reaches exit within a deadline.** Every trigger (`SIGTERM`, `SIGINT`, logout, the app's own quit) ends the process: within 5 s idle, within 10 s with work in flight, then a hard exit (a board responder leaving its session can take an idle stop up to that hard exit). Nothing starts after quit begins | [Shutdown](#shutdown) | `Drain` / `Drain` |
58
+ | LC-01 | **Quit reaches exit within a deadline.** Every trigger (`SIGTERM`, `SIGINT`, logout, the app's own quit) ends the process: within 5 s idle, within 10 s with work in flight, then a hard exit (an idle board responder waits 3 s at most for its session's DELETE, FAA-173; one with work can take up to that hard exit). Nothing starts after quit begins | [Shutdown](#shutdown) | `Drain` / `Drain` |
59
59
  | LC-02 | **Every child has an owner, a deadline and a process group.** No subprocess without a timeout; on timeout or the parent's exit the whole group goes; no orphan survives. Locks and shared resources are released in `finally` | your own code | none |
60
60
  | LC-03 | **A job is bounded and exclusive.** One copy holds `service.lock`; a scheduled job has a watchdog shorter than its interval, shares its lock with every manual entry point, bounds every network call and records each run's outcome. Long work is journalled before it starts | [One copy under a supervisor](#one-copy-under-a-supervisor) | `hold_single_instance` / `holdSingleInstance` |
61
61
  | LC-04 | **Credentials are silent after setup.** No background prompt; a credential read that is denied or absent is cached and backs off until a person acts | [Directories](#directories) (secret files 0600) | none |