@passioncode-ai/fabric-agent-adapter 0.24.0 → 0.25.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 (19) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +17 -5
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +182 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +7 -2
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +14 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -4
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +156 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +9 -6
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +11 -4
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +182 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +11 -6
  17. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  18. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  19. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.0 - 2026-10-13
4
+
5
+ Identifier equality by normal form (contract DEC-0076) in both kits, and the contract pin moves from `fe55fc6` to
6
+ contract `main` `a046f57` (FAA-39). `fe55fc6` joins the supported revisions. DEC-0077 (`com.directory` pages) is in the
7
+ pin. Its consumer is the kits' board client, which is not built yet (FAA-12b), so nothing reads it.
8
+
9
+ ### Added
10
+
11
+ - **`normal_identifier` / `normalIdentifier` and `same_identifier` / `sameIdentifier`** (`fabric_identifiers.py`,
12
+ byte-identical in both skills, and `fabric-identifiers.mjs`). They port the contract's `src/identifiers.ts` and are
13
+ held to its `identifiers.json` (33 normal forms, 8 same, 8 different, 6 lax-port mistakes).
14
+ - A URI follows RFC 3986 §6.2.2.
15
+ - A `urn:` follows RFC 8141 §3.1 (the NSS's case is kept and nothing is decoded).
16
+ - Any other string is its own normal form.
17
+
18
+ ### Changed
19
+
20
+ - **Compared by normal form:**
21
+ - FAC-SEM-030, FAC-SEM-032 and FAC-SEM-034 compare a revision reference's id and the project and capability of
22
+ admissions, bindings and events;
23
+ - FAC-SEM-076 compares share grants and `grant` revocations;
24
+ - FAC-SEM-014 compares a provider entry's `providerId`.
25
+
26
+ Two spellings of one identifier now read as one, and two identifiers stay two.
27
+ - **Written in normal form:** the scaffolded `provider.id`, `identity.subject` and capability `id`, and the `providerId`
28
+ that `write_provider_entry` stores.
29
+
30
+ ### Evidence
31
+
32
+ - The route and estate generators now add respelled-id cases (`urn` and the NID upper-cased, and the NSS's case
33
+ changed), each answered by the contract's own functions. The contract's fixtures are in normal form, so without these
34
+ cases the kits' byte comparisons stayed green.
35
+ - The compiled-schema revision tests pass at `2ce3922` → `a046f57`.
36
+ - 20 planted defects, all caught.
37
+ - The scenario eval `identifiers-by-normal-form` was written before the prose.
38
+
3
39
  ## 0.24.0 - 2026-10-13
4
40
 
5
41
  An app's own descriptor (contract DEC-0069) and the descriptor for listings (DEC-0061) in both kits (FAA-34). The contract
package/README.md CHANGED
@@ -235,7 +235,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
235
235
  The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
236
236
 
237
237
  - version: `0.1.0`
238
- - commit: `fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0`
238
+ - commit: `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b`
239
239
 
240
240
  Every other mention of the default contract revision — this section, the skills' metadata, the
241
241
  skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -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.24.0",
6
+ "version": "0.25.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,9 +5,9 @@ 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.24.0"
8
+ version: "0.25.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
10
+ contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
11
11
  ---
12
12
 
13
13
  # Adapting projects to Fabric
@@ -70,7 +70,7 @@ Use exactly:
70
70
 
71
71
  - contract version `0.1.0`;
72
72
  - repository `https://github.com/passioncode-ai/fabric-agent-contract`;
73
- - commit `fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0`.
73
+ - commit `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b`.
74
74
 
75
75
  This is the default for new bundles. When checking an issued legacy bundle, preserve its
76
76
  explicit supported lock and use that exact clean checkout. Never silently upgrade or
@@ -21,10 +21,10 @@ import fabric_secrets as _secrets # noqa: E402 - the contract's credential dete
21
21
 
22
22
  CONTRACT_VERSION = "0.1.0"
23
23
  CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
24
- CONTRACT_COMMIT = "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
24
+ CONTRACT_COMMIT = "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
25
25
  # #region supported-contract-revisions — docs: README.md#contract-pin
26
26
  # Only this declaration may name a legacy revision in live files.
27
- SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
27
+ SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
28
28
  # #endregion supported-contract-revisions
29
29
  INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
30
30
  MCP_REVISION = "2026-07-28"
@@ -47,6 +47,15 @@ class AdaptationError(Exception):
47
47
  """Expected user-facing adaptation error."""
48
48
 
49
49
 
50
+
51
+ def _normal_identifier(value: str) -> str:
52
+ """DEC-0076's normal form, from fabric_identifiers.py beside this file (byte-identical to the service kit's)."""
53
+ import importlib.util
54
+ spec = importlib.util.spec_from_file_location("fabric_identifiers", Path(__file__).resolve().parent / "fabric_identifiers.py")
55
+ module = importlib.util.module_from_spec(spec)
56
+ spec.loader.exec_module(module)
57
+ return module.normal_identifier(value)
58
+
50
59
  def _read_text(path: Path, limit: int = 262_144) -> str:
51
60
  try:
52
61
  if path.stat().st_size > limit:
@@ -237,24 +246,27 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
237
246
  raise AdaptationError("a runtime argument carries a credential; argv is visible to every process (LC-21): read it from a secret reference instead")
238
247
 
239
248
  created_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
249
+ # DEC-0076: a writer emits identifiers in their normal form, so every reader finds them under one spelling.
250
+ provider_id = _normal_identifier(args.provider_id)
251
+ capability_id = _normal_identifier(args.capability_id)
240
252
  manifest = {
241
253
  "contractVersion": CONTRACT_VERSION,
242
254
  "provider": {
243
- "id": args.provider_id,
255
+ "id": provider_id,
244
256
  "revision": 1,
245
257
  "contentHash": "sha256:" + ("0" * 64),
246
258
  "createdAt": created_at,
247
259
  "createdBy": "urn:fabric:adapter:author",
248
260
  "name": args.provider_name,
249
261
  "identity": {
250
- "subject": args.provider_id,
262
+ "subject": provider_id,
251
263
  "method": "local-install" if args.profile == "local-runner" or (args.profile == "mcp" and args.mcp_mode == "stdio") else "tls",
252
264
  },
253
265
  "supportedContractVersions": [CONTRACT_VERSION],
254
266
  },
255
267
  "capabilities": [
256
268
  {
257
- "id": args.capability_id,
269
+ "id": capability_id,
258
270
  "name": args.capability_name,
259
271
  "inputSchema": _schema_uri(args.schema_base, "schemas/capability-input.schema.json"),
260
272
  "outputSchema": _schema_uri(args.schema_base, "schemas/capability-output.schema.json"),
@@ -0,0 +1,182 @@
1
+ """Identifier equality (contract DEC-0076): two identifiers are the same when their normal forms are equal.
2
+
3
+ A port of the contract's `src/identifiers.ts`, held to its `fixtures/vectors/identifiers.json`. The normal form of a URI
4
+ is RFC 3986 §6.2.2 syntax-based normalization: the scheme and the host lower-cased and percent-encoding hex upper-cased
5
+ (§6.2.2.1), percent-encoded unreserved characters decoded (§6.2.2.2), dot segments removed from a hierarchical path
6
+ (§6.2.2.3), and only the leading ones from a rootless path; nothing scheme-based (§6.2.3). A `urn:` URI follows RFC 8141
7
+ §3.1 instead: the scheme and the NID lower-cased, percent-encoding hex upper-cased, nothing decoded, the NSS's case kept,
8
+ the r-, q- and f-components ignored. A string that is not a URI by RFC 3986's `URI` production is its own normal form.
9
+ Nothing here raises. Standard library only; this file is byte-identical in both skills' `scripts/`."""
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Any, Optional, Tuple
14
+
15
+ _UNRESERVED = r"A-Za-z0-9\-._~"
16
+ _SUB_DELIMS = r"!$&'()*+,;="
17
+ _PCT = r"%[0-9A-Fa-f]{2}"
18
+
19
+
20
+ def _run(chars: str) -> "re.Pattern[str]":
21
+ """`*( <chars> / pct-encoded )`, linear: one character class beside one fixed triplet."""
22
+ return re.compile(r"(?:[%s]|%s)*" % (chars, _PCT), re.ASCII)
23
+
24
+
25
+ _USERINFO = _run(_UNRESERVED + _SUB_DELIMS + ":")
26
+ _REG_NAME = _run(_UNRESERVED + _SUB_DELIMS)
27
+ _PATH = _run(_UNRESERVED + _SUB_DELIMS + ":@/")
28
+ _QUERY = _run(_UNRESERVED + _SUB_DELIMS + ":@/?")
29
+ _PORT = re.compile(r"[0-9]*", re.ASCII)
30
+ _SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*", re.ASCII)
31
+
32
+ # RFC 3986 §3.2.2: IP-literal = "[" ( IPv6address / IPvFuture ) "]".
33
+ _H16 = "[0-9A-Fa-f]{1,4}"
34
+ _DEC_OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])"
35
+ _IPV4 = r"%s(?:\.%s){3}" % (_DEC_OCTET, _DEC_OCTET)
36
+ _LS32 = "(?:%s:%s|%s)" % (_H16, _H16, _IPV4)
37
+ _IPV6 = "|".join([
38
+ "(?:%s:){6}%s" % (_H16, _LS32),
39
+ "::(?:%s:){5}%s" % (_H16, _LS32),
40
+ "(?:%s)?::(?:%s:){4}%s" % (_H16, _H16, _LS32),
41
+ "(?:(?:%s:){0,1}%s)?::(?:%s:){3}%s" % (_H16, _H16, _H16, _LS32),
42
+ "(?:(?:%s:){0,2}%s)?::(?:%s:){2}%s" % (_H16, _H16, _H16, _LS32),
43
+ "(?:(?:%s:){0,3}%s)?::%s:%s" % (_H16, _H16, _H16, _LS32),
44
+ "(?:(?:%s:){0,4}%s)?::%s" % (_H16, _H16, _LS32),
45
+ "(?:(?:%s:){0,5}%s)?::%s" % (_H16, _H16, _H16),
46
+ "(?:(?:%s:){0,6}%s)?::" % (_H16, _H16),
47
+ ])
48
+ _IP_LITERAL = re.compile(r"\[(?:%s|[Vv][0-9A-Fa-f]+\.[%s%s:]+)\]" % (_IPV6, _UNRESERVED, _SUB_DELIMS), re.ASCII)
49
+
50
+ # RFC 8141 §2: NID = (alphanum) 0*30(ldh) (alphanum), then ":" and a non-empty NSS.
51
+ _URN_NAME = re.compile(r"([A-Za-z0-9][A-Za-z0-9-]{0,30}[A-Za-z0-9]):(.+)", re.ASCII | re.DOTALL)
52
+ _SPLIT = re.compile(r"([^:/?#]+):(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?", re.DOTALL)
53
+ _TRIPLET = re.compile(r"%([0-9A-Fa-f]{2})")
54
+ _UNRESERVED_CHAR = re.compile(r"[A-Za-z0-9\-._~]", re.ASCII)
55
+
56
+
57
+ def _percent(text: str, keep_encoded: bool = False) -> str:
58
+ """Percent-encoding hex upper-cased (§6.2.2.1), and unreserved characters decoded unless ``keep_encoded``."""
59
+ def one(match: "re.Match[str]") -> str:
60
+ char = chr(int(match.group(1), 16))
61
+ return char if not keep_encoded and _UNRESERVED_CHAR.fullmatch(char) else "%" + match.group(1).upper()
62
+ return _TRIPLET.sub(one, text)
63
+
64
+
65
+ def _strip_leading_dots(path: str) -> str:
66
+ """A rootless path loses its leading "./" and "../" segments while what is left stays rootless (DEC-0076)."""
67
+ start = 0
68
+ while path.startswith("./", start) or path.startswith("../", start):
69
+ start += 2 if path.startswith("./", start) else 3
70
+ rest = path[start:]
71
+ return path if rest == "" or rest.startswith("/") else rest
72
+
73
+
74
+ def _remove_dot_segments(path: str) -> str:
75
+ """RFC 3986 §5.2.4 remove_dot_segments over a path whose "." and ".." are already decoded."""
76
+ absolute = path.startswith("/")
77
+ segments = (path[1:] if absolute else path).split("/")
78
+ output = []
79
+ for index, segment in enumerate(segments):
80
+ last = index == len(segments) - 1
81
+ if segment in (".", ".."):
82
+ if segment == ".." and output:
83
+ output.pop()
84
+ if last: # a dot segment that ends the path leaves it ending in "/"
85
+ output.append("")
86
+ continue
87
+ output.append(segment)
88
+ return ("/" if absolute else "") + "/".join(output)
89
+
90
+
91
+ def _authority_parts(authority: str) -> Optional[Tuple[Optional[str], str, Optional[str]]]:
92
+ at = authority.rfind("@")
93
+ userinfo = None if at == -1 else authority[:at]
94
+ host_port = authority if at == -1 else authority[at + 1:]
95
+ bracket = host_port.find("]") if host_port.startswith("[") else -1
96
+ colon = host_port.find(":", 0 if bracket == -1 else bracket)
97
+ host = host_port if colon == -1 else host_port[:colon]
98
+ port = None if colon == -1 else host_port[colon + 1:]
99
+ if userinfo is not None and not _USERINFO.fullmatch(userinfo):
100
+ return None
101
+ if port is not None and not _PORT.fullmatch(port):
102
+ return None
103
+ if not (_IP_LITERAL.fullmatch(host) if host.startswith("[") else _REG_NAME.fullmatch(host)):
104
+ return None
105
+ return userinfo, host, port
106
+
107
+
108
+ def _parse(text: str) -> Optional[Tuple[str, Optional[str], str, Optional[str], Optional[str]]]:
109
+ """RFC 3986 Appendix B's split, then each component checked against its §3 production; None when one fails."""
110
+ match = _SPLIT.fullmatch(text)
111
+ if not match:
112
+ return None
113
+ scheme, authority, path, query, fragment = match.groups()
114
+ path = path or ""
115
+ if not _SCHEME.fullmatch(scheme) or not _PATH.fullmatch(path):
116
+ return None
117
+ if query is not None and not _QUERY.fullmatch(query):
118
+ return None
119
+ if fragment is not None and not _QUERY.fullmatch(fragment):
120
+ return None
121
+ if authority is not None and _authority_parts(authority) is None:
122
+ return None
123
+ if authority is None and path == "":
124
+ return None
125
+ return scheme, authority, path, query, fragment
126
+
127
+
128
+ def _urn_form(authority: Optional[str], path: str, query: Optional[str]) -> str:
129
+ name = _URN_NAME.fullmatch(path)
130
+ path = "%s:%s" % (name.group(1).lower(), _percent(name.group(2), True)) if name else _percent(path, True)
131
+ # A "?" not followed by "+" or "=" is no URN component (RFC 8141 §2): it is kept, never dropped unread.
132
+ kept = "?" + _percent(query, True) if query is not None and not re.match(r"[+=]", query) else ""
133
+ auth = "//" + _percent(authority, True) if authority is not None else ""
134
+ return "urn:%s%s%s" % (auth, path, kept)
135
+
136
+
137
+ def _generic_form(scheme: str, authority: Optional[str], path: str, query: Optional[str], fragment: Optional[str]) -> str:
138
+ auth = ""
139
+ if authority is not None:
140
+ userinfo, host, port = _authority_parts(authority) # type: ignore[misc]
141
+ # The host is case-insensitive (§3.2.2); its remaining percent triplets keep upper-case hex.
142
+ lowered = re.sub(r"%[0-9a-f]{2}", lambda m: m.group(0).upper(), _percent(host).lower())
143
+ auth = "//%s%s%s" % ("" if userinfo is None else _percent(userinfo) + "@", lowered, "" if port is None else ":" + port)
144
+ decoded = _percent(path)
145
+ hierarchical = authority is not None or decoded.startswith("/")
146
+ out = _remove_dot_segments(decoded) if hierarchical else _strip_leading_dots(decoded)
147
+ if authority is None and out.startswith("//"): # it would read as an authority: keep the decoded path
148
+ out = decoded
149
+ q = "" if query is None else "?" + _percent(query)
150
+ f = "" if fragment is None else "#" + _percent(fragment)
151
+ return "%s:%s%s%s%s" % (scheme.lower(), auth, out, q, f)
152
+
153
+
154
+ def normal_identifier(value: Any) -> Optional[str]:
155
+ """The normal form of an identifier: a URI's syntax-normalized form (RFC 3986 §6.2.2; RFC 8141 §3.1 for `urn:`),
156
+ any other string as it is, None for a value that is not a string. The contract's `normalIdentifier`."""
157
+ if not isinstance(value, str):
158
+ return None
159
+ parts = _parse(value)
160
+ if parts is None:
161
+ return value
162
+ scheme, authority, path, query, fragment = parts
163
+ return _urn_form(authority, path, query) if scheme.lower() == "urn" else _generic_form(scheme, authority, path, query, fragment)
164
+
165
+
166
+ def _strict_equal(a: Any, b: Any) -> bool:
167
+ """JavaScript's === on parsed JSON: numbers by value, booleans only with booleans, objects by identity."""
168
+ if isinstance(a, bool) or isinstance(b, bool):
169
+ return isinstance(a, bool) and isinstance(b, bool) and a == b
170
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)):
171
+ return a == b
172
+ if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
173
+ return a is b
174
+ return type(a) is type(b) and a == b
175
+
176
+
177
+ def same_identifier(a: Any, b: Any) -> bool:
178
+ """Two identifiers are the same when their normal forms are equal (DEC-0076). A value that is not a string is no
179
+ identifier and is compared as it is (JavaScript's ===). The contract's `sameIdentifier`."""
180
+ if not isinstance(a, str) or not isinstance(b, str):
181
+ return _strict_equal(a, b)
182
+ return normal_identifier(a) == normal_identifier(b)
@@ -142,7 +142,8 @@ def _run_problems(run: Any) -> List[str]:
142
142
 
143
143
 
144
144
  def manifest_problems(entry: Dict[str, Any]) -> List[str]:
145
- """FAC-SEM-014 (DEC-0017), URI half: the manifest resolves and its provider.id equals providerId."""
145
+ """FAC-SEM-014 (DEC-0017), URI half: the manifest resolves and its provider.id equals providerId, compared by normal
146
+ form (DEC-0076)."""
146
147
  try:
147
148
  manifest = json.loads(Path(os.path.expanduser(str(entry["manifest"]))).read_text(encoding="utf-8"))
148
149
  except (OSError, ValueError):
@@ -150,7 +151,8 @@ def manifest_problems(entry: Dict[str, Any]) -> List[str]:
150
151
  provider = manifest.get("provider") if isinstance(manifest, dict) else None
151
152
  if not isinstance(provider, dict) or not isinstance(manifest.get("capabilities"), list):
152
153
  return ["%s is not a provider manifest (FAC-SEM-014)" % entry["manifest"]]
153
- if provider.get("id") != entry["providerId"]:
154
+ import fabric_identifiers # noqa: E402 - beside this file; DEC-0076: URIs compared by normal form
155
+ if not fabric_identifiers.same_identifier(provider.get("id"), entry["providerId"]):
154
156
  return ["providerId %s is not the manifest's provider.id %s (FAC-SEM-014)" % (entry["providerId"], provider.get("id"))]
155
157
  return []
156
158
 
@@ -195,6 +197,9 @@ def write_provider_entry(entry: Dict[str, Any], directory: Optional[Path] = None
195
197
  clash = _service_ids(services or services_dir()).get(entry["id"])
196
198
  if clash:
197
199
  raise ProviderError("%s is already a service (%s); an id is a service or a provider, never both (FAC-SEM-013)." % (entry["id"], clash))
200
+ import fabric_identifiers # noqa: E402 - beside this file
201
+ # DEC-0076: written in its normal form, so a reader that looks it up finds one spelling.
202
+ entry = dict(entry, providerId=fabric_identifiers.normal_identifier(entry["providerId"]))
198
203
  target = (directory or providers_dir()) / ("%s.json" % entry["id"])
199
204
  _atomic_write(target, (json.dumps(entry, indent=2, ensure_ascii=False) + "\n").encode())
200
205
  return target
@@ -16,10 +16,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
16
16
  compatibility: Python 3.9+ or Node.js 20+ for the kits; 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.
17
17
  metadata:
18
18
  author: PassionCode.ai
19
- version: "0.24.0"
19
+ version: "0.25.0"
20
20
  contract-version: "0.1.0"
21
21
  extension: "fabric-service/0.1"
22
- extension-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
22
+ extension-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
23
23
  ---
24
24
 
25
25
  # Building Fabric services
@@ -1,13 +1,14 @@
1
1
  # fabric-service/0.1 — wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0`
3
+ Pinned to `fabric-agent-contract` commit `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b`
4
4
  (`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
5
5
  DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
6
6
  delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
7
7
  canonical forms DEC-0043; `degraded[].setup` and `run.mcp.stdio.scope` DEC-0044; delegation sealed and trust tiers
8
8
  DEC-0045; runner and model profiles DEC-0046; the estate record DEC-0055; one copy under any supervisor and its marker,
9
9
  the Windows token-file reader DEC-0057; the hub key the estate certifies DEC-0060; signing credentials DEC-0063; the
10
- estate record's 60 s skew DEC-0066). The contract's schemas are normative;
10
+ estate record's 60 s skew DEC-0066; an app's own descriptor DEC-0069; the descriptor for listings DEC-0061; identifier equality
11
+ by normal form DEC-0076). The contract's schemas are normative;
11
12
  this page is the working summary. Extension key:
12
13
  `https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
13
14
 
@@ -41,6 +42,17 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
41
42
  | `runner` | optional `{kind?, credentialKind}` (DEC-0061): `credentialKind` `subscription`, `key`, `platform-metered` or `none` — a kind, never a credential (`FAC-SEM-086` refuses a credential-shaped value here or in `installedBy`); a subscription names its vendor's runner (`claude-code`, `kimi-code`, `codex`) |
42
43
  | `peer` | optional `{endpoint, custody, residency, maxDataClass}` (DEC-0061): the service is offered to others. `endpoint` `https://<dns-name>[:<port>][/<path>]`, never a reserved name (`FAC-SEM-085`); `custody` `organization-managed` or `personal`; `residency` 1–32 ISO 3166-1 alpha-2 codes, `EU` admitted, user-assigned codes refused; `maxDataClass` `public` < `internal` < `confidential` < `restricted`. An offer needs `runner` and never runs on a subscription. A host sends a request from anyone but the operator only when `peer_refusal` / `peerRefusal` answers null (`FAC-SEM-088`); usage rows may take `route: "platform-metered"` |
43
44
 
45
+ ## Identifier equality (DEC-0076)
46
+
47
+ Two identifiers are the same when their normal forms are equal. `normal_identifier` / `normalIdentifier` and
48
+ `same_identifier` / `sameIdentifier` (`fabric_identifiers.py`, `fabric-identifiers.mjs`, ports of the contract's
49
+ `src/identifiers.ts`, held to its `identifiers.json`) apply RFC 3986 §6.2.2 to a URI: scheme and host lower-cased,
50
+ percent-encoding hex upper-cased, unreserved characters decoded, dot segments removed. A `urn:` follows RFC 8141 §3.1
51
+ instead: `urn` and the NID lower-cased, nothing decoded, the NSS's case kept. Any other string is its own normal form,
52
+ and a value that is not a string is compared as it is. The kits compare by it in FAC-SEM-030/032/034 (routes, bindings,
53
+ events), FAC-SEM-076 (estate grants and their revocations) and FAC-SEM-014 (a provider entry's `providerId`), and they
54
+ write the identifiers they scaffold in normal form.
55
+
44
56
  ## Well-known document — `GET /.well-known/fabric-service`
45
57
 
46
58
  No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
@@ -12,6 +12,7 @@
12
12
 
13
13
  import crypto from 'node:crypto';
14
14
  import { jcs } from './fabric-delegation.mjs';
15
+ import { normalIdentifier } from './fabric-identifiers.mjs';
15
16
  import { secretValue } from './fabric-secrets.mjs';
16
17
 
17
18
  export const ESTATE_RECORD_SCHEMA = 'estate-record/1';
@@ -428,12 +429,14 @@ function secretReferences(value, flag) {
428
429
  }
429
430
 
430
431
  function grantsAndRevocations(value, flag) {
432
+ // A grant's id is a URI, so grants and their revocations are keyed by its normal form (DEC-0076).
433
+ const grantKey = (id) => (typeof id === 'string' ? normalIdentifier(id) : id);
431
434
  const grantIds = new Set();
432
435
  list(value.shareGrants).forEach((grant, index) => {
433
436
  if (grant.principalKind !== 'external') flag(`/shareGrants/${index}/principalKind`, 'a share grant is to a principal outside the estate: principalKind external');
434
437
  const id = isObject(grant.meta) ? grant.meta.id : undefined;
435
- if (grantIds.has(id)) flag(`/shareGrants/${index}/meta/id`, `grant ${String(id)} is listed twice`);
436
- grantIds.add(id);
438
+ if (grantIds.has(grantKey(id))) flag(`/shareGrants/${index}/meta/id`, `grant ${String(id)} is listed twice`);
439
+ grantIds.add(grantKey(id));
437
440
  });
438
441
  const machines = list(value.machines);
439
442
  // A Map, so a kind such as `constructor` or `__proto__` reads nothing from Object.prototype.
@@ -448,10 +451,11 @@ function grantsAndRevocations(value, flag) {
448
451
  }));
449
452
  const seen = new Set();
450
453
  list(value.revocations).forEach((revocation, index) => {
451
- const key = `${String(revocation.kind)} ${String(revocation.ref)}`;
454
+ const ref = revocation.kind === 'grant' ? grantKey(revocation.ref) : revocation.ref;
455
+ const key = `${String(revocation.kind)} ${String(ref)}`;
452
456
  if (seen.has(key)) flag(`/revocations/${index}`, `the ${String(revocation.kind)} ${String(revocation.ref)} is revoked twice`);
453
457
  seen.add(key);
454
- if (listed.get(String(revocation.kind))?.has(revocation.ref)) flag(`/revocations/${index}/ref`, `the record still lists the ${String(revocation.kind)} it revokes: revocation outranks addition`);
458
+ if (listed.get(String(revocation.kind))?.has(ref)) flag(`/revocations/${index}/ref`, `the record still lists the ${String(revocation.kind)} it revokes: revocation outranks addition`);
455
459
  });
456
460
  }
457
461
 
@@ -0,0 +1,156 @@
1
+ // Identifier equality (contract DEC-0076): two identifiers are the same when their normal forms are equal. A port of the
2
+ // contract's src/identifiers.ts (types removed), held to its fixtures/vectors/identifiers.json; the twin of
3
+ // fabric_identifiers.py. Nothing here throws.
4
+
5
+ const UNRESERVED = "A-Za-z0-9\\-._~";
6
+ const SUB_DELIMS = "!$&'()*+,;=";
7
+ const PCT = "%[0-9A-Fa-f]{2}";
8
+ /** `*( <chars> / pct-encoded )` — each a single character class beside one fixed triplet, so the match is linear. */
9
+ const run = (chars) => new RegExp(`^(?:[${chars}]|${PCT})*$`);
10
+ const USERINFO = run(`${UNRESERVED}${SUB_DELIMS}:`);
11
+ const REG_NAME = run(`${UNRESERVED}${SUB_DELIMS}`);
12
+ const PATH = run(`${UNRESERVED}${SUB_DELIMS}:@/`);
13
+ const QUERY = run(`${UNRESERVED}${SUB_DELIMS}:@/?`);
14
+ const PORT = /^[0-9]*$/;
15
+ const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/;
16
+
17
+ // RFC 3986 §3.2.2: IP-literal = "[" ( IPv6address / IPvFuture ) "]".
18
+ const H16 = "[0-9A-Fa-f]{1,4}";
19
+ const DEC_OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])";
20
+ const IPV4 = `${DEC_OCTET}(?:\\.${DEC_OCTET}){3}`;
21
+ const LS32 = `(?:${H16}:${H16}|${IPV4})`;
22
+ const IPV6 = [
23
+ `(?:${H16}:){6}${LS32}`,
24
+ `::(?:${H16}:){5}${LS32}`,
25
+ `(?:${H16})?::(?:${H16}:){4}${LS32}`,
26
+ `(?:(?:${H16}:){0,1}${H16})?::(?:${H16}:){3}${LS32}`,
27
+ `(?:(?:${H16}:){0,2}${H16})?::(?:${H16}:){2}${LS32}`,
28
+ `(?:(?:${H16}:){0,3}${H16})?::${H16}:${LS32}`,
29
+ `(?:(?:${H16}:){0,4}${H16})?::${LS32}`,
30
+ `(?:(?:${H16}:){0,5}${H16})?::${H16}`,
31
+ `(?:(?:${H16}:){0,6}${H16})?::`,
32
+ ].join("|");
33
+ const IP_LITERAL = new RegExp(`^\\[(?:${IPV6}|[Vv][0-9A-Fa-f]+\\.[${UNRESERVED}${SUB_DELIMS}:]+)\\]$`);
34
+
35
+ // RFC 8141 §2: NID = (alphanum) 0*30(ldh) (alphanum), then ":" and a non-empty NSS.
36
+ const URN_NAME = /^([A-Za-z0-9][A-Za-z0-9-]{0,30}[A-Za-z0-9]):(.+)$/s;
37
+
38
+ /** Percent-encoding hex upper-cased (§6.2.2.1), and unreserved characters decoded unless `keepEncoded` (§6.2.2.2). */
39
+ function percent(text, keepEncoded = false) {
40
+ return text.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => {
41
+ const char = String.fromCharCode(Number.parseInt(hex, 16));
42
+ return !keepEncoded && /^[A-Za-z0-9\-._~]$/.test(char) ? char : `%${hex.toUpperCase()}`;
43
+ });
44
+ }
45
+
46
+ /**
47
+ * A rootless path (`agent:./x`) loses its leading "./" and "../" segments — §5.2.4's step A — while what is left stays
48
+ * rootless: not empty and not beginning with "/". Dot segments further in are kept, because removing them by §5.2.4 can
49
+ * turn a rootless path into an absolute one (`a/../b` → `/b`). That narrowing is the contract's own, under RFC 3986
50
+ * §6.1's aim of no false positives (DEC-0076).
51
+ */
52
+ function stripLeadingDots(path) {
53
+ let start = 0;
54
+ while (path.startsWith("./", start) || path.startsWith("../", start)) start += path.startsWith("./", start) ? 2 : 3;
55
+ const rest = path.slice(start);
56
+ return rest === "" || rest.startsWith("/") ? path : rest;
57
+ }
58
+
59
+ /**
60
+ * RFC 3986 §5.2.4 remove_dot_segments, over a path whose `.` and `..` are already decoded. Its input buffer is read
61
+ * segment by segment: the path split on "/" gives the same steps without copying the rest of the input at each one.
62
+ */
63
+ function removeDotSegments(path) {
64
+ const absolute = path.startsWith("/");
65
+ const segments = (absolute ? path.slice(1) : path).split("/");
66
+ const output = [];
67
+ segments.forEach((segment, index) => {
68
+ const last = index === segments.length - 1;
69
+ if (segment === "." || segment === "..") {
70
+ if (segment === ".." && output.length > 0) output.pop();
71
+ // A dot segment that ends the path leaves the path ending in "/" (§5.2.4 steps B and C on "/." and "/..").
72
+ if (last) output.push("");
73
+ return;
74
+ }
75
+ output.push(segment);
76
+ });
77
+ return (absolute ? "/" : "") + output.join("/");
78
+ }
79
+
80
+
81
+ /** RFC 3986 Appendix B's split, then each component checked against its §3 production; undefined when one fails. */
82
+ function parse(text) {
83
+ const match = /^([^:/?#]+):(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/s.exec(text);
84
+ if (!match) return undefined;
85
+ const [, scheme, authority, path, query, fragment] = match;
86
+ if (!SCHEME.test(scheme) || !PATH.test(path)) return undefined;
87
+ if (query !== undefined && !QUERY.test(query)) return undefined;
88
+ if (fragment !== undefined && !QUERY.test(fragment)) return undefined;
89
+ if (authority !== undefined && !authorityParts(authority)) return undefined;
90
+ // An empty path with no authority (`x:`, RFC 3986's path-empty) is left as it is: the schema's `format: uri` refuses
91
+ // it, so no admitted value has it. (A path never begins with "//" here: Appendix B's split reads that as an authority.)
92
+ if (authority === undefined && path === "") return undefined;
93
+ return { scheme, authority, path, query, fragment };
94
+ }
95
+
96
+ function authorityParts(authority) {
97
+ const at = authority.lastIndexOf("@");
98
+ const userinfo = at === -1 ? undefined : authority.slice(0, at);
99
+ const hostPort = at === -1 ? authority : authority.slice(at + 1);
100
+ const bracket = hostPort.startsWith("[") ? hostPort.indexOf("]") : -1;
101
+ const colon = hostPort.indexOf(":", bracket === -1 ? 0 : bracket);
102
+ const host = colon === -1 ? hostPort : hostPort.slice(0, colon);
103
+ const port = colon === -1 ? undefined : hostPort.slice(colon + 1);
104
+ if (userinfo !== undefined && !USERINFO.test(userinfo)) return undefined;
105
+ if (port !== undefined && !PORT.test(port)) return undefined;
106
+ if (host.startsWith("[") ? !IP_LITERAL.test(host) : !REG_NAME.test(host)) return undefined;
107
+ return { userinfo, host, port };
108
+ }
109
+
110
+ /** RFC 8141 §3.1: `urn` and the NID lower-cased, hex upper-cased, nothing decoded; r-, q- and f-components ignored. */
111
+ function urnForm(parts) {
112
+ const name = URN_NAME.exec(parts.path);
113
+ const path = name ? `${name[1].toLowerCase()}:${percent(name[2], true)}` : percent(parts.path, true);
114
+ // A "?" not followed by "+" or "=" is no URN component (RFC 8141 §2): it is kept, never dropped unread.
115
+ const query = parts.query !== undefined && !/^[+=]/.test(parts.query) ? `?${percent(parts.query, true)}` : "";
116
+ const authority = parts.authority !== undefined ? `//${percent(parts.authority, true)}` : "";
117
+ return `urn:${authority}${path}${query}`;
118
+ }
119
+
120
+ function genericForm(parts) {
121
+ let authority = "";
122
+ if (parts.authority !== undefined) {
123
+ const { userinfo, host, port } = authorityParts(parts.authority);
124
+ // The host is case-insensitive (§3.2.2); its remaining percent triplets keep upper-case hex.
125
+ const lowered = percent(host).toLowerCase().replace(/%[0-9a-f]{2}/g, (triplet) => triplet.toUpperCase());
126
+ authority = `//${userinfo !== undefined ? `${percent(userinfo)}@` : ""}${lowered}${port !== undefined ? `:${port}` : ""}`;
127
+ }
128
+ const decoded = percent(parts.path);
129
+ const hierarchical = parts.authority !== undefined || decoded.startsWith("/");
130
+ let path = hierarchical ? removeDotSegments(decoded) : stripLeadingDots(decoded);
131
+ // A path left starting with "//" and no authority would read as one: keep the decoded path instead.
132
+ if (parts.authority === undefined && path.startsWith("//")) path = decoded;
133
+ const query = parts.query !== undefined ? `?${percent(parts.query)}` : "";
134
+ const fragment = parts.fragment !== undefined ? `#${percent(parts.fragment)}` : "";
135
+ return `${parts.scheme.toLowerCase()}:${authority}${path}${query}${fragment}`;
136
+ }
137
+
138
+ /**
139
+ * The normal form of an identifier: a URI's syntax-normalized form (RFC 3986 §6.2.2; RFC 8141 §3.1 for `urn:`), any
140
+ * other string as it is, and undefined for a value that is not a string.
141
+ */
142
+ export function normalIdentifier(value) {
143
+ if (typeof value !== "string") return undefined;
144
+ const parts = parse(value);
145
+ if (!parts) return value;
146
+ return parts.scheme.toLowerCase() === "urn" ? urnForm(parts) : genericForm(parts);
147
+ }
148
+
149
+ /**
150
+ * Two identifiers are the same when their normal forms are equal (DEC-0076). A value that is not a string is no
151
+ * identifier and is compared as it is (`===`), so a rule reads a schema-invalid member exactly as it did before.
152
+ */
153
+ export function sameIdentifier(a, b) {
154
+ if (typeof a !== "string" || typeof b !== "string") return a === b;
155
+ return normalIdentifier(a) === normalIdentifier(b);
156
+ }
@@ -4,6 +4,7 @@
4
4
  // the well-known `configuration` with its summary tiles ("Runner & model"). No dependencies.
5
5
 
6
6
  import { spawnSync } from 'node:child_process';
7
+ import { normalIdentifier, sameIdentifier } from './fabric-identifiers.mjs';
7
8
  import fs from 'node:fs';
8
9
  import path from 'node:path';
9
10
  import { secretValue } from './fabric-secrets.mjs';
@@ -210,7 +211,8 @@ export const PROBE_RESULTS = Object.freeze(['not-catalogued', 'not-admitted', 'n
210
211
 
211
212
  const routeCandidates = (value) => (Array.isArray(value?.candidates) ? value.candidates.filter(isObject) : []);
212
213
  const objectAt = (value, key) => (isObject(value?.[key]) ? value[key] : {});
213
- const refKey = (ref) => (isObject(ref) ? `${String(ref.id)}@${String(ref.revision)}#${String(ref.contentHash)}` : '');
214
+ // A revision reference compared whole; its id is an identifier, so it enters the key in its normal form (DEC-0076).
215
+ const refKey = (ref) => (isObject(ref) ? `${String(normalIdentifier(ref.id) ?? ref.id)}@${String(ref.revision)}#${String(ref.contentHash)}` : '');
214
216
  const sameRef = (a, b) => refKey(a) !== '' && refKey(a) === refKey(b);
215
217
 
216
218
  function runnerRoute(value) {
@@ -229,7 +231,7 @@ function runnerRoute(value) {
229
231
  function routeBundle(value) {
230
232
  const route = objectAt(value, 'route');
231
233
  const admissions = Array.isArray(value.admissions) ? value.admissions.filter(isObject) : [];
232
- const admitted = new Set(admissions.filter((a) => a.state === 'admitted' && a.capability === route.capability).map((a) => refKey(a.provider)));
234
+ const admitted = new Set(admissions.filter((a) => a.state === 'admitted' && sameIdentifier(a.capability, route.capability)).map((a) => refKey(a.provider)));
233
235
  const findings = [];
234
236
  routeCandidates(route).forEach((candidate, index) => {
235
237
  const key = refKey(candidate.provider);
@@ -238,8 +240,9 @@ function routeBundle(value) {
238
240
  if (isObject(value.binding)) {
239
241
  const binding = value.binding;
240
242
  const disagree = (where, message) => findings.push(finding('FAC-SEM-032', `/binding/${where}`, message));
241
- if (binding.project !== route.project) disagree('project', `the binding is for ${String(binding.project)}, the route for ${String(route.project)}`);
242
- if (binding.capability !== route.capability) disagree('capability', `the binding binds ${String(binding.capability)}, the route orders ${String(route.capability)}`);
243
+ // DEC-0076: project and capability are identifiers, compared by normal form.
244
+ if (!sameIdentifier(binding.project, route.project)) disagree('project', `the binding is for ${String(binding.project)}, the route for ${String(route.project)}`);
245
+ if (!sameIdentifier(binding.capability, route.capability)) disagree('capability', `the binding binds ${String(binding.capability)}, the route orders ${String(route.capability)}`);
243
246
  if (binding.profileKind !== 'local-runner') disagree('profileKind', `a runner route serves the local-runner profile, not ${String(binding.profileKind)}`);
244
247
  if (!routeCandidates(route).some((c) => sameRef(c.provider, binding.provider))) disagree('provider', `the binding's provider ${refKey(binding.provider)} is not a candidate of the route`);
245
248
  if (binding.runnerRoute !== undefined && !sameRef(binding.runnerRoute, route.meta)) disagree('runnerRoute', `the binding pins ${refKey(binding.runnerRoute)}, not this route ${refKey(route.meta)}`);
@@ -263,8 +266,8 @@ function routeEvent(value) {
263
266
  const findings = [];
264
267
  const wrong = (where, message) => findings.push(finding('FAC-SEM-034', `/event${where}`, message));
265
268
  if (!sameRef(event.route, route.meta)) wrong('/route', `the event names route ${refKey(event.route)}, not ${refKey(route.meta)}`);
266
- if (event.project !== route.project) wrong('/project', `the event is for ${String(event.project)}, the route for ${String(route.project)}`);
267
- if (event.capability !== route.capability) wrong('/capability', `the event names ${String(event.capability)}, the route orders ${String(route.capability)}`);
269
+ if (!sameIdentifier(event.project, route.project)) wrong('/project', `the event is for ${String(event.project)}, the route for ${String(route.project)}`);
270
+ if (!sameIdentifier(event.capability, route.capability)) wrong('/capability', `the event names ${String(event.capability)}, the route orders ${String(route.capability)}`);
268
271
  const indexOf = (ref) => (typeof ref.index === 'number' && Number.isInteger(ref.index) ? ref.index : -1);
269
272
  const candidateAt = (ref, where) => {
270
273
  const index = indexOf(ref);
@@ -826,15 +826,21 @@ def _secret_references(value: Dict[str, Any], flag) -> None:
826
826
  slots.append(key)
827
827
 
828
828
 
829
+ def _grant_key(ident: Any) -> Any:
830
+ """A grant's id is a URI, so grants and their revocations are keyed by its normal form (DEC-0076)."""
831
+ import fabric_identifiers # noqa: E402 - beside this file
832
+ return fabric_identifiers.normal_identifier(ident) if isinstance(ident, str) else ident
833
+
834
+
829
835
  def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
830
836
  grant_ids: List[Any] = []
831
837
  for index, grant in enumerate(_list(_get(value, "shareGrants"))):
832
838
  if _get(grant, "principalKind") != "external":
833
839
  flag("/shareGrants/%d/principalKind" % index, "a share grant is to a principal outside the estate: principalKind external")
834
840
  ident = _get(_get(grant, "meta"), "id") if isinstance(_get(grant, "meta"), dict) else _MISSING
835
- if _contains(grant_ids, ident):
841
+ if _contains(grant_ids, _grant_key(ident)):
836
842
  flag("/shareGrants/%d/meta/id" % index, "grant %s is listed twice" % _js_string(ident))
837
- grant_ids.append(ident)
843
+ grant_ids.append(_grant_key(ident))
838
844
  machines = _list(_get(value, "machines"))
839
845
  listed = {
840
846
  "authority": [_get(e, "credentialId") for e in _list(_get(value, "authorities"))],
@@ -847,11 +853,12 @@ def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
847
853
  }
848
854
  seen: List[str] = []
849
855
  for index, revocation in enumerate(_list(_get(value, "revocations"))):
850
- key = "%s %s" % (_s(revocation, "kind"), _s(revocation, "ref"))
856
+ ref = _grant_key(_get(revocation, "ref")) if _get(revocation, "kind") == "grant" else _get(revocation, "ref")
857
+ key = "%s %s" % (_s(revocation, "kind"), _js_string(ref))
851
858
  if key in seen:
852
859
  flag("/revocations/%d" % index, "the %s %s is revoked twice" % (_s(revocation, "kind"), _s(revocation, "ref")))
853
860
  seen.append(key)
854
- if _contains(listed.get(_s(revocation, "kind"), []), _get(revocation, "ref")):
861
+ if _contains(listed.get(_s(revocation, "kind"), []), ref):
855
862
  flag("/revocations/%d/ref" % index, "the record still lists the %s it revokes: revocation outranks addition" % _s(revocation, "kind"))
856
863
 
857
864
 
@@ -0,0 +1,182 @@
1
+ """Identifier equality (contract DEC-0076): two identifiers are the same when their normal forms are equal.
2
+
3
+ A port of the contract's `src/identifiers.ts`, held to its `fixtures/vectors/identifiers.json`. The normal form of a URI
4
+ is RFC 3986 §6.2.2 syntax-based normalization: the scheme and the host lower-cased and percent-encoding hex upper-cased
5
+ (§6.2.2.1), percent-encoded unreserved characters decoded (§6.2.2.2), dot segments removed from a hierarchical path
6
+ (§6.2.2.3), and only the leading ones from a rootless path; nothing scheme-based (§6.2.3). A `urn:` URI follows RFC 8141
7
+ §3.1 instead: the scheme and the NID lower-cased, percent-encoding hex upper-cased, nothing decoded, the NSS's case kept,
8
+ the r-, q- and f-components ignored. A string that is not a URI by RFC 3986's `URI` production is its own normal form.
9
+ Nothing here raises. Standard library only; this file is byte-identical in both skills' `scripts/`."""
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Any, Optional, Tuple
14
+
15
+ _UNRESERVED = r"A-Za-z0-9\-._~"
16
+ _SUB_DELIMS = r"!$&'()*+,;="
17
+ _PCT = r"%[0-9A-Fa-f]{2}"
18
+
19
+
20
+ def _run(chars: str) -> "re.Pattern[str]":
21
+ """`*( <chars> / pct-encoded )`, linear: one character class beside one fixed triplet."""
22
+ return re.compile(r"(?:[%s]|%s)*" % (chars, _PCT), re.ASCII)
23
+
24
+
25
+ _USERINFO = _run(_UNRESERVED + _SUB_DELIMS + ":")
26
+ _REG_NAME = _run(_UNRESERVED + _SUB_DELIMS)
27
+ _PATH = _run(_UNRESERVED + _SUB_DELIMS + ":@/")
28
+ _QUERY = _run(_UNRESERVED + _SUB_DELIMS + ":@/?")
29
+ _PORT = re.compile(r"[0-9]*", re.ASCII)
30
+ _SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*", re.ASCII)
31
+
32
+ # RFC 3986 §3.2.2: IP-literal = "[" ( IPv6address / IPvFuture ) "]".
33
+ _H16 = "[0-9A-Fa-f]{1,4}"
34
+ _DEC_OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])"
35
+ _IPV4 = r"%s(?:\.%s){3}" % (_DEC_OCTET, _DEC_OCTET)
36
+ _LS32 = "(?:%s:%s|%s)" % (_H16, _H16, _IPV4)
37
+ _IPV6 = "|".join([
38
+ "(?:%s:){6}%s" % (_H16, _LS32),
39
+ "::(?:%s:){5}%s" % (_H16, _LS32),
40
+ "(?:%s)?::(?:%s:){4}%s" % (_H16, _H16, _LS32),
41
+ "(?:(?:%s:){0,1}%s)?::(?:%s:){3}%s" % (_H16, _H16, _H16, _LS32),
42
+ "(?:(?:%s:){0,2}%s)?::(?:%s:){2}%s" % (_H16, _H16, _H16, _LS32),
43
+ "(?:(?:%s:){0,3}%s)?::%s:%s" % (_H16, _H16, _H16, _LS32),
44
+ "(?:(?:%s:){0,4}%s)?::%s" % (_H16, _H16, _LS32),
45
+ "(?:(?:%s:){0,5}%s)?::%s" % (_H16, _H16, _H16),
46
+ "(?:(?:%s:){0,6}%s)?::" % (_H16, _H16),
47
+ ])
48
+ _IP_LITERAL = re.compile(r"\[(?:%s|[Vv][0-9A-Fa-f]+\.[%s%s:]+)\]" % (_IPV6, _UNRESERVED, _SUB_DELIMS), re.ASCII)
49
+
50
+ # RFC 8141 §2: NID = (alphanum) 0*30(ldh) (alphanum), then ":" and a non-empty NSS.
51
+ _URN_NAME = re.compile(r"([A-Za-z0-9][A-Za-z0-9-]{0,30}[A-Za-z0-9]):(.+)", re.ASCII | re.DOTALL)
52
+ _SPLIT = re.compile(r"([^:/?#]+):(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?", re.DOTALL)
53
+ _TRIPLET = re.compile(r"%([0-9A-Fa-f]{2})")
54
+ _UNRESERVED_CHAR = re.compile(r"[A-Za-z0-9\-._~]", re.ASCII)
55
+
56
+
57
+ def _percent(text: str, keep_encoded: bool = False) -> str:
58
+ """Percent-encoding hex upper-cased (§6.2.2.1), and unreserved characters decoded unless ``keep_encoded``."""
59
+ def one(match: "re.Match[str]") -> str:
60
+ char = chr(int(match.group(1), 16))
61
+ return char if not keep_encoded and _UNRESERVED_CHAR.fullmatch(char) else "%" + match.group(1).upper()
62
+ return _TRIPLET.sub(one, text)
63
+
64
+
65
+ def _strip_leading_dots(path: str) -> str:
66
+ """A rootless path loses its leading "./" and "../" segments while what is left stays rootless (DEC-0076)."""
67
+ start = 0
68
+ while path.startswith("./", start) or path.startswith("../", start):
69
+ start += 2 if path.startswith("./", start) else 3
70
+ rest = path[start:]
71
+ return path if rest == "" or rest.startswith("/") else rest
72
+
73
+
74
+ def _remove_dot_segments(path: str) -> str:
75
+ """RFC 3986 §5.2.4 remove_dot_segments over a path whose "." and ".." are already decoded."""
76
+ absolute = path.startswith("/")
77
+ segments = (path[1:] if absolute else path).split("/")
78
+ output = []
79
+ for index, segment in enumerate(segments):
80
+ last = index == len(segments) - 1
81
+ if segment in (".", ".."):
82
+ if segment == ".." and output:
83
+ output.pop()
84
+ if last: # a dot segment that ends the path leaves it ending in "/"
85
+ output.append("")
86
+ continue
87
+ output.append(segment)
88
+ return ("/" if absolute else "") + "/".join(output)
89
+
90
+
91
+ def _authority_parts(authority: str) -> Optional[Tuple[Optional[str], str, Optional[str]]]:
92
+ at = authority.rfind("@")
93
+ userinfo = None if at == -1 else authority[:at]
94
+ host_port = authority if at == -1 else authority[at + 1:]
95
+ bracket = host_port.find("]") if host_port.startswith("[") else -1
96
+ colon = host_port.find(":", 0 if bracket == -1 else bracket)
97
+ host = host_port if colon == -1 else host_port[:colon]
98
+ port = None if colon == -1 else host_port[colon + 1:]
99
+ if userinfo is not None and not _USERINFO.fullmatch(userinfo):
100
+ return None
101
+ if port is not None and not _PORT.fullmatch(port):
102
+ return None
103
+ if not (_IP_LITERAL.fullmatch(host) if host.startswith("[") else _REG_NAME.fullmatch(host)):
104
+ return None
105
+ return userinfo, host, port
106
+
107
+
108
+ def _parse(text: str) -> Optional[Tuple[str, Optional[str], str, Optional[str], Optional[str]]]:
109
+ """RFC 3986 Appendix B's split, then each component checked against its §3 production; None when one fails."""
110
+ match = _SPLIT.fullmatch(text)
111
+ if not match:
112
+ return None
113
+ scheme, authority, path, query, fragment = match.groups()
114
+ path = path or ""
115
+ if not _SCHEME.fullmatch(scheme) or not _PATH.fullmatch(path):
116
+ return None
117
+ if query is not None and not _QUERY.fullmatch(query):
118
+ return None
119
+ if fragment is not None and not _QUERY.fullmatch(fragment):
120
+ return None
121
+ if authority is not None and _authority_parts(authority) is None:
122
+ return None
123
+ if authority is None and path == "":
124
+ return None
125
+ return scheme, authority, path, query, fragment
126
+
127
+
128
+ def _urn_form(authority: Optional[str], path: str, query: Optional[str]) -> str:
129
+ name = _URN_NAME.fullmatch(path)
130
+ path = "%s:%s" % (name.group(1).lower(), _percent(name.group(2), True)) if name else _percent(path, True)
131
+ # A "?" not followed by "+" or "=" is no URN component (RFC 8141 §2): it is kept, never dropped unread.
132
+ kept = "?" + _percent(query, True) if query is not None and not re.match(r"[+=]", query) else ""
133
+ auth = "//" + _percent(authority, True) if authority is not None else ""
134
+ return "urn:%s%s%s" % (auth, path, kept)
135
+
136
+
137
+ def _generic_form(scheme: str, authority: Optional[str], path: str, query: Optional[str], fragment: Optional[str]) -> str:
138
+ auth = ""
139
+ if authority is not None:
140
+ userinfo, host, port = _authority_parts(authority) # type: ignore[misc]
141
+ # The host is case-insensitive (§3.2.2); its remaining percent triplets keep upper-case hex.
142
+ lowered = re.sub(r"%[0-9a-f]{2}", lambda m: m.group(0).upper(), _percent(host).lower())
143
+ auth = "//%s%s%s" % ("" if userinfo is None else _percent(userinfo) + "@", lowered, "" if port is None else ":" + port)
144
+ decoded = _percent(path)
145
+ hierarchical = authority is not None or decoded.startswith("/")
146
+ out = _remove_dot_segments(decoded) if hierarchical else _strip_leading_dots(decoded)
147
+ if authority is None and out.startswith("//"): # it would read as an authority: keep the decoded path
148
+ out = decoded
149
+ q = "" if query is None else "?" + _percent(query)
150
+ f = "" if fragment is None else "#" + _percent(fragment)
151
+ return "%s:%s%s%s%s" % (scheme.lower(), auth, out, q, f)
152
+
153
+
154
+ def normal_identifier(value: Any) -> Optional[str]:
155
+ """The normal form of an identifier: a URI's syntax-normalized form (RFC 3986 §6.2.2; RFC 8141 §3.1 for `urn:`),
156
+ any other string as it is, None for a value that is not a string. The contract's `normalIdentifier`."""
157
+ if not isinstance(value, str):
158
+ return None
159
+ parts = _parse(value)
160
+ if parts is None:
161
+ return value
162
+ scheme, authority, path, query, fragment = parts
163
+ return _urn_form(authority, path, query) if scheme.lower() == "urn" else _generic_form(scheme, authority, path, query, fragment)
164
+
165
+
166
+ def _strict_equal(a: Any, b: Any) -> bool:
167
+ """JavaScript's === on parsed JSON: numbers by value, booleans only with booleans, objects by identity."""
168
+ if isinstance(a, bool) or isinstance(b, bool):
169
+ return isinstance(a, bool) and isinstance(b, bool) and a == b
170
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)):
171
+ return a == b
172
+ if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
173
+ return a is b
174
+ return type(a) is type(b) and a == b
175
+
176
+
177
+ def same_identifier(a: Any, b: Any) -> bool:
178
+ """Two identifiers are the same when their normal forms are equal (DEC-0076). A value that is not a string is no
179
+ identifier and is compared as it is (JavaScript's ===). The contract's `sameIdentifier`."""
180
+ if not isinstance(a, str) or not isinstance(b, str):
181
+ return _strict_equal(a, b)
182
+ return normal_identifier(a) == normal_identifier(b)
@@ -19,6 +19,7 @@ import shutil
19
19
  import subprocess
20
20
  from typing import Any, Callable, Dict, List, Optional, Sequence
21
21
 
22
+ import fabric_identifiers as _ids
22
23
  import fabric_secrets as _secrets
23
24
  import fabric_service as _fs
24
25
 
@@ -314,9 +315,12 @@ def _object_at(value: Any, key: str) -> Dict[str, Any]:
314
315
 
315
316
 
316
317
  def _ref_key(ref: Any) -> str:
318
+ """A revision reference compared whole: id, revision and content hash. The id is an identifier, so it enters the key
319
+ in its normal form (DEC-0076)."""
317
320
  if not isinstance(ref, dict):
318
321
  return ""
319
- return "%s@%s#%s" % (_field(ref, "id"), _field(ref, "revision"), _field(ref, "contentHash"))
322
+ normal = _ids.normal_identifier(ref.get("id"))
323
+ return "%s@%s#%s" % (normal if normal is not None else _field(ref, "id"), _field(ref, "revision"), _field(ref, "contentHash"))
320
324
 
321
325
 
322
326
  def _same_ref(a: Any, b: Any) -> bool:
@@ -343,7 +347,7 @@ def _runner_route(value: Dict[str, Any]) -> List[Dict[str, str]]:
343
347
  def _route_bundle(value: Dict[str, Any]) -> List[Dict[str, str]]:
344
348
  route = _object_at(value, "route")
345
349
  admitted = {_ref_key(a.get("provider")) for a in _list(value.get("admissions"))
346
- if a.get("state") == "admitted" and a.get("capability", _MISSING) == route.get("capability", _MISSING)}
350
+ if a.get("state") == "admitted" and _ids.same_identifier(a.get("capability", _MISSING), route.get("capability", _MISSING))}
347
351
  findings = []
348
352
  for index, candidate in enumerate(_candidates(route)):
349
353
  key = _ref_key(candidate.get("provider"))
@@ -363,9 +367,10 @@ def _binding_agreement(binding: Dict[str, Any], route: Dict[str, Any]) -> List[D
363
367
  def disagree(path: str, message: str) -> None:
364
368
  findings.append(_finding("FAC-SEM-032", "/binding/" + path, message))
365
369
 
366
- if binding.get("project", _MISSING) != route.get("project", _MISSING):
370
+ # DEC-0076: project and capability are identifiers, compared by normal form.
371
+ if not _ids.same_identifier(binding.get("project", _MISSING), route.get("project", _MISSING)):
367
372
  disagree("project", "the binding is for %s, the route for %s" % (_field(binding, "project"), _field(route, "project")))
368
- if binding.get("capability", _MISSING) != route.get("capability", _MISSING):
373
+ if not _ids.same_identifier(binding.get("capability", _MISSING), route.get("capability", _MISSING)):
369
374
  disagree("capability", "the binding binds %s, the route orders %s" % (_field(binding, "capability"), _field(route, "capability")))
370
375
  if binding.get("profileKind") != "local-runner":
371
376
  disagree("profileKind", "a runner route serves the local-runner profile, not %s" % _field(binding, "profileKind"))
@@ -408,9 +413,9 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
408
413
 
409
414
  if not _same_ref(event.get("route"), route.get("meta")):
410
415
  wrong("/route", "the event names route %s, not %s" % (_ref_key(event.get("route")), _ref_key(route.get("meta"))))
411
- if event.get("project", _MISSING) != route.get("project", _MISSING):
416
+ if not _ids.same_identifier(event.get("project", _MISSING), route.get("project", _MISSING)):
412
417
  wrong("/project", "the event is for %s, the route for %s" % (_field(event, "project"), _field(route, "project")))
413
- if event.get("capability", _MISSING) != route.get("capability", _MISSING):
418
+ if not _ids.same_identifier(event.get("capability", _MISSING), route.get("capability", _MISSING)):
414
419
  wrong("/capability", "the event names %s, the route orders %s" % (_field(event, "capability"), _field(route, "capability")))
415
420
 
416
421
  def candidate_at(ref: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]:
@@ -5,9 +5,9 @@ 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. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.24.0"
8
+ version: "0.25.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
10
+ contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
11
11
  ---
12
12
 
13
13
  # Creating Fabric-compatible agents
@@ -123,7 +123,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
123
123
  The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
124
124
  does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
125
125
 
126
- Pin exactly contract `0.1.0` at commit `fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0` and
126
+ Pin exactly contract `0.1.0` at commit `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b` and
127
127
  read the pinned guide before implementing protocol details. If this skill is installed
128
128
  without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
129
129
  contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
@@ -18,7 +18,7 @@ compatibility: >-
18
18
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.24.0"
21
+ version: "0.25.0"
22
22
  ---
23
23
 
24
24
  # Working with Fabric
@@ -60,7 +60,7 @@ WATCH_MAX_BACKOFF_S = 900
60
60
  SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
61
61
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
62
62
  LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
63
- VERSION = "0.24.0"
63
+ VERSION = "0.25.0"
64
64
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"}
65
65
  # A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
66
66
  # what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value