@passioncode-ai/fabric-agent-adapter 0.33.0 → 0.34.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.34.0 - 2026-10-15
4
+
5
+ The adapter is pinned at contract `d8146cf`. The profile kits now read what a lane reads (DEC-0083; FAA-45, contract
6
+ CT-52): a model profile that reads what members, clients or the public send keeps an open-vendor model on its route,
7
+ so the lane never runs on closed vendors alone, which refuse the content it exists to handle.
8
+
9
+ ### Changed
10
+
11
+ - **Repin** from `82e1636` to `d8146cf`. Every vendored vector is byte-identical except `profile-vectors.json`.
12
+ `82e1636` stays a supported revision.
13
+ - **`open-vendor` is descriptive** in `resolve_model_profile` / `resolveModelProfile` and in the doctor's `model_usable` /
14
+ `modelUsable`: a project's `modelClasses` never admit or exclude a model by it, and a model whose only class is
15
+ `open-vendor` is admitted on nothing. Both copies of the every-class rule answer the contract's shared
16
+ `model-admission.json` vectors.
17
+ - **FAC-SEM-058 and FAC-SEM-060 are total on hostile input**, as the contract's became in DEC-0083's fix round: an
18
+ `instancePath` names an item by its index as written (a non-object before it no longer shifts it, and every model
19
+ counts at its own index), a duplicate id is what JavaScript's `Set` calls one (`1` and `true`, `null` and absent, are
20
+ two), and an id whose own `toString` member is data is shown as its JSON.
21
+
22
+ ### Added
23
+
24
+ - **FAC-SEM-112** on a `profile-resolution`: a profile with `reads: ["user-content"]` has an open-vendor route — with
25
+ `fallback: none` its first model is `open-vendor` in the catalogue, with `between-turns` at least one is — and the
26
+ models the project's policy leaves must still give one, or the finding at `/resolved/models` names the policy.
27
+ `closed_user_content_route` / `closedUserContentRoute` name the models that would run on a closed route.
28
+ - `creating-fabric-agents` asks in its grill which lanes read user content; `profile-selection.md` and the profiles
29
+ reference say how such a lane is declared and routed, and that a content refusal or an empty answer is a failed turn.
30
+
31
+ ### Fixed
32
+
33
+ - **`make_profile_vectors.mjs` read the contract's newer fixtures vacuously.** Since DEC-0083 a profile fixture wraps
34
+ its document as `{description, kind, expect, input}`; the generator fed the wrapper to the rules, so every expectation
35
+ came out empty and the Node test passed without checking anything. It now unwraps them and stops when a fixture's own
36
+ `expect` disagrees with the contract's answer.
37
+ - `resolve_model_profile` takes `catalogue` and `profiles` as optional, as the contract's resolver does, so an absent
38
+ catalogue resolves instead of raising.
39
+
40
+ ### Evidence
41
+
42
+ - Both kits answer 72 rule cases (45 crafted: FAC-SEM-112 edges, the policy's outcome, hostile ids and indexes), 81
43
+ resolutions and 8 admission cases.
44
+ - 51 planted defects (30 in Python, 21 in Node), all caught but two that cannot differ on JSON input: a redundant
45
+ type check, now removed from the code, and the Node message of a duplicate object id, which parsed JSON never
46
+ produces. A unit test covers `1` and `1.0` as one id, a case JSON written by JavaScript cannot hold.
47
+ - Eval scenarios `lanes-that-read-user-content` and `user-content-open-vendor-route`.
48
+
3
49
  ## 0.33.0 - 2026-10-15
4
50
 
5
51
  The adapter is pinned at contract `82e1636`. The estate kits now read contexts (DEC-0082; FAA-41, the RM-28 pilot): one
package/README.md CHANGED
@@ -251,7 +251,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
251
251
  The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
252
252
 
253
253
  - version: `0.1.0`
254
- - commit: `82e163665e156a08aa3b92e4711015582c06162c`
254
+ - commit: `d8146cfb0a1c74fc6432284c4356bb0c864b37b5`
255
255
 
256
256
  Every other mention of the default contract revision — this section, the skills' metadata, the
257
257
  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.33.0",
3
+ "version": "0.34.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.33.0",
6
+ "version": "0.34.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.33.0"
8
+ version: "0.34.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "82e163665e156a08aa3b92e4711015582c06162c"
10
+ contract-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
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 `82e163665e156a08aa3b92e4711015582c06162c`.
73
+ - commit `d8146cfb0a1c74fc6432284c4356bb0c864b37b5`.
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
@@ -57,7 +57,10 @@ Use when Fabric launches an installed process such as a coding CLI. Require:
57
57
  - project-scoped account selection outside the manifest.
58
58
 
59
59
  The user may choose the project's default terminal and override it per agent. Model choice
60
- stays inside the selected agent/provider; the Fabric profile remains model-agnostic.
60
+ stays inside the selected agent/provider; the Fabric profile remains model-agnostic. The one
61
+ exception is a lane that reads what members, clients or the public send: its model profile
62
+ declares `reads: ["user-content"]` and has an open-vendor route (contract DEC-0083,
63
+ FAC-SEM-112), whichever models the agent picks.
61
64
 
62
65
  ## Reject or defer
63
66
 
@@ -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 = "82e163665e156a08aa3b92e4711015582c06162c"
24
+ CONTRACT_COMMIT = "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
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, "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
27
+ SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "82e163665e156a08aa3b92e4711015582c06162c", "1636fa97262f4a26e2086572d540c2ee701e0010", "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "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"
@@ -18,10 +18,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
18
18
  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.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.33.0"
21
+ version: "0.34.0"
22
22
  contract-version: "0.1.0"
23
23
  extension: "fabric-service/0.1"
24
- extension-commit: "82e163665e156a08aa3b92e4711015582c06162c"
24
+ extension-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
25
25
  ---
26
26
 
27
27
  # Building Fabric services
@@ -11,6 +11,7 @@ Python: [`scripts/fabric_profiles.py`](../scripts/fabric_profiles.py); Node:
11
11
 
12
12
  - Resolving the model profile
13
13
  - Checking the documents
14
+ - A lane that reads user content
14
15
  - Declaring and showing what applies
15
16
  - The doctor
16
17
  - Runner routes
@@ -27,7 +28,8 @@ resolution = fp.resolve_model_profile(catalogue=catalogue, profiles=estate_and_a
27
28
 
28
29
  The first level that names a profile wins: **request, agent, binding or project, estate default**. The project's
29
30
  `modelClasses` narrow the profile's models — a model stays only if every class the catalogue gives it is allowed — and
30
- never choose one. An empty result is `{"refused": "model-classes-empty", "policy": {id, revision}}`: refuse the launch
31
+ never choose one. `open-vendor` is descriptive (DEC-0083): it is never checked against the policy, and a model whose
32
+ only class is `open-vendor` does not stay. An empty result is `{"refused": "model-classes-empty", "policy": {id, revision}}`: refuse the launch
31
33
  and name the policy; never fall back to a model outside it. No level at all is `no-profile`; an id nobody offers is
32
34
  `unknown-profile`.
33
35
 
@@ -39,6 +41,27 @@ and name the policy; never fall back to a model outside it. No level at all is `
39
41
  Code, Kimi in Kimi Code, ChatGPT in Codex; a key route's allow-list is its own model profile; a key named by reference,
40
42
  never by value) and a recorded `profile-resolution` (it equals the order's answer).
41
43
 
44
+ ## A lane that reads user content
45
+
46
+ A model profile names what the lane it serves reads (DEC-0083, `reads`): `user-content` — what a member, a client or
47
+ the public sends (messages, questions, images, reviews, screenshots, voice); `operator` — only what the estate's own
48
+ operator or its agents write; `web` — pages fetched from the open web. Absent is "not declared", which stays valid.
49
+
50
+ A profile that reads `user-content` has an **open-vendor route** (FAC-SEM-112): with `fallback: none` (or any value the
51
+ contract does not name) its first model is `open-vendor` in the catalogue; with `between-turns` at least one of its
52
+ models is. Closed vendors refuse the content such a lane exists to handle, so a closed route alone skips the worst of
53
+ it. Which models are `open-vendor` is the catalogue writer's fact; the service never keeps a vendor list of its own,
54
+ and a model the catalogue does not hold counts as closed.
55
+
56
+ - `profile_problems("profile-resolution", doc)` / `profileProblems(...)` answer FAC-SEM-112 beside FAC-SEM-060, on each
57
+ profile (`/profiles/<i>/models`) and on the resolution's outcome: when the resolved profile's own route is open but the
58
+ models the project's policy leaves are all closed, the finding is at `/resolved/models` and names the policy.
59
+ - `closed_user_content_route(profile, catalogue)` / `closedUserContentRoute(profile, catalogue)` name the models that
60
+ would run on such a lane when none is open-vendor, or `None`/`undefined` when the route holds.
61
+ - On such a lane a content refusal, or an empty answer to a non-empty request, is a **failed turn**: under
62
+ `between-turns` the runner moves to the next model; it never counts that answer as done.
63
+ - `operator` and `web` carry no model rule in this revision; they let a lane say that it reads no user content.
64
+
42
65
  ## Declaring and showing what applies
43
66
 
44
67
  ```python
@@ -57,7 +80,8 @@ declaration (`configuration.declared`).
57
80
  and answers whether the runner is installed, which version answers and whether it is signed in. It runs only a bare
58
81
  catalogued binary found on `PATH`, only with the catalogued version argv (and `auth` argv), without a shell, within
59
82
  the entry's timeout (at most 5 s); an entry outside these rules is refused and nothing runs. `model_usable(model,
60
- catalogue, policy)` names why a model cannot be used here: not catalogued, or a class the project does not allow.
83
+ catalogue, policy)` names why a model cannot be used here: not catalogued, a class the project does not allow, or
84
+ only `open-vendor`, which admits nothing — the same admission the resolution applies.
61
85
 
62
86
  `probe` is the contract's closed probe result for that runner, the one Fabric's walk records (DEC-0026):
63
87
  `not-installed` (no catalogued binary on `PATH`), `not-responding` (the version probe did not answer in its bound,
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1 — wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `82e163665e156a08aa3b92e4711015582c06162c`
3
+ Pinned to `fabric-agent-contract` commit `d8146cfb0a1c74fc6432284c4356bb0c864b37b5`
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;
@@ -21,6 +21,21 @@ export class ProfileError extends Error {}
21
21
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
22
22
  const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
23
23
  const strings = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === 'string') : []);
24
+ /** The object items of an array with their index as written, so an instancePath names the item even after a non-object. */
25
+ const indexed = (value) => (Array.isArray(value) ? value.flatMap((item, index) => (isObject(item) ? [[item, index]] : [])) : []);
26
+ /** DEC-0083: the model class of a model whose vendor publishes its weights openly — descriptive: a policy admits and
27
+ * excludes nothing by it — and what a lane reads when it reads what a member, a client or the public sends. */
28
+ export const OPEN_VENDOR = 'open-vendor';
29
+ export const USER_CONTENT = 'user-content';
30
+ /** The contract's `show`: String(value), or its JSON where String() throws (an object whose own `toString` is data). */
31
+ const show = (value) => {
32
+ if (typeof value === 'string') return value;
33
+ try {
34
+ return String(value);
35
+ } catch {
36
+ return JSON.stringify(value) ?? typeof value;
37
+ }
38
+ };
24
39
  const jsonEqual = (a, b) => JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b));
25
40
  function sortKeys(value) {
26
41
  if (Array.isArray(value)) return value.map(sortKeys);
@@ -39,7 +54,12 @@ export function resolveModelProfile({ catalogue, profiles, choices = {}, policy
39
54
  if (!isObject(policy) || !Array.isArray(policy.modelClasses)) return { from: level, profile: id, models };
40
55
  const allowed = new Set(strings(policy.modelClasses));
41
56
  const rows = new Map(list(isObject(catalogue) ? catalogue.models : undefined).map((row) => [row.id, row]));
42
- const within = models.filter((model) => { const row = rows.get(model); return row !== undefined && strings(row.classes).every((name) => allowed.has(name)); });
57
+ // The descriptive class open-vendor is not checked (DEC-0083), and a model with no other class does not stay.
58
+ const within = models.filter((model) => {
59
+ const row = rows.get(model);
60
+ const admission = row === undefined ? [] : strings(row.classes).filter((name) => name !== OPEN_VENDOR);
61
+ return admission.length > 0 && admission.every((name) => allowed.has(name));
62
+ });
43
63
  if (within.length) return { from: level, profile: id, models: within };
44
64
  const meta = isObject(policy.meta) ? policy.meta : {};
45
65
  return { refused: 'model-classes-empty', profile: id, policy: { id: String(meta.id), revision: Number(meta.revision) } };
@@ -50,13 +70,13 @@ const finding = (code, instancePath, message) => ({ code, instancePath, message
50
70
  function modelCatalogue(value) {
51
71
  const findings = [];
52
72
  const seen = new Set();
53
- list(value.models).forEach((row, index) => {
54
- if (seen.has(row.id)) findings.push(finding('FAC-SEM-058', `/models/${index}/id`, `model ${String(row.id)} is catalogued more than once`));
73
+ indexed(value.models).forEach(([row, index]) => {
74
+ if (seen.has(row.id)) findings.push(finding('FAC-SEM-058', `/models/${index}/id`, `model ${show(row.id)} is catalogued more than once`));
55
75
  seen.add(row.id);
56
76
  const routing = isObject(row.provider) ? row.provider : {};
57
77
  const ignored = new Set(strings(routing.ignore));
58
78
  for (const slug of strings(routing.order)) {
59
- if (ignored.has(slug) || ignored.has(slug.split('/')[0])) findings.push(finding('FAC-SEM-058', `/models/${index}/provider`, `model ${String(row.id)} both orders and ignores provider ${slug}`));
79
+ if (ignored.has(slug) || ignored.has(slug.split('/')[0])) findings.push(finding('FAC-SEM-058', `/models/${index}/provider`, `model ${show(row.id)} both orders and ignores provider ${slug}`));
60
80
  }
61
81
  });
62
82
  return findings;
@@ -65,8 +85,8 @@ function modelCatalogue(value) {
65
85
  function modelProfiles(value) {
66
86
  const findings = [];
67
87
  const seen = new Set();
68
- list(value.profiles).forEach((profile, index) => {
69
- if (seen.has(profile.id)) findings.push(finding('FAC-SEM-058', `/profiles/${index}/id`, `model profile ${String(profile.id)} is declared more than once`));
88
+ indexed(value.profiles).forEach(([profile, index]) => {
89
+ if (seen.has(profile.id)) findings.push(finding('FAC-SEM-058', `/profiles/${index}/id`, `model profile ${show(profile.id)} is declared more than once`));
70
90
  seen.add(profile.id);
71
91
  });
72
92
  return findings;
@@ -90,11 +110,11 @@ function profileResolution(value) {
90
110
  const findings = [];
91
111
  const catalogued = new Set(list(isObject(value.catalogue) ? value.catalogue.models : undefined).map((row) => row.id));
92
112
  const ids = new Set();
93
- list(value.profiles).forEach((profile, i) => {
94
- if (ids.has(profile.id)) findings.push(finding('FAC-SEM-060', `/profiles/${i}/id`, `profile id ${String(profile.id)} is offered more than once to one resolution`));
113
+ indexed(value.profiles).forEach(([profile, i]) => {
114
+ if (ids.has(profile.id)) findings.push(finding('FAC-SEM-060', `/profiles/${i}/id`, `profile id ${show(profile.id)} is offered more than once to one resolution`));
95
115
  ids.add(profile.id);
96
- strings(profile.models).forEach((model, j) => {
97
- if (!catalogued.has(model)) findings.push(finding('FAC-SEM-060', `/profiles/${i}/models/${j}`, `profile ${String(profile.id)} names ${model}, which the catalogue does not hold`));
116
+ (Array.isArray(profile.models) ? profile.models : []).forEach((model, j) => {
117
+ if (typeof model === 'string' && !catalogued.has(model)) findings.push(finding('FAC-SEM-060', `/profiles/${i}/models/${j}`, `profile ${show(profile.id)} names ${model}, which the catalogue does not hold`));
98
118
  });
99
119
  });
100
120
  const choices = isObject(value.choices) ? value.choices : {};
@@ -103,9 +123,51 @@ function profileResolution(value) {
103
123
  return findings;
104
124
  }
105
125
 
126
+ /**
127
+ * FAC-SEM-112 (DEC-0083) for one profile: the models that run when the lane reads user content and none of them is an
128
+ * open-vendor model, or undefined when the profile does not read user content or its route has one. Under
129
+ * `between-turns` every model may run; under `none`, or any other value (failing closed), only the first. A model the
130
+ * catalogue does not hold, or whose row is not `open-vendor`, counts as closed. Never throws.
131
+ */
132
+ export function closedUserContentRoute(profile, catalogue) {
133
+ if (!isObject(profile) || !strings(profile.reads).includes(USER_CONTENT)) return undefined;
134
+ const models = Array.isArray(profile.models) ? profile.models : [];
135
+ const route = profile.fallback === 'between-turns' ? models : models.slice(0, 1);
136
+ const open = new Set();
137
+ for (const row of list(isObject(catalogue) ? catalogue.models : undefined)) {
138
+ if (typeof row.id === 'string' && strings(row.classes).includes(OPEN_VENDOR)) open.add(row.id);
139
+ }
140
+ if (route.some((model) => typeof model === 'string' && open.has(model))) return undefined;
141
+ return route.map((model) => show(model));
142
+ }
143
+
144
+ /** FAC-SEM-112: a profile that reads user content has an open-vendor route, so the lane never runs on closed vendors alone. */
145
+ const closedRoute = (fallback, closed) => (fallback === 'between-turns' ? 'none of its models is' : `its first model, ${closed[0] ?? 'none'}, is not`);
146
+
147
+ /** FAC-SEM-112 on the profiles and on the resolution's outcome: the models the policy leaves keep an open-vendor route. */
148
+ function openVendorRoutes(value) {
149
+ const findings = [];
150
+ const profiles = list(value.profiles);
151
+ indexed(value.profiles).forEach(([profile, i]) => {
152
+ const closed = closedUserContentRoute(profile, value.catalogue);
153
+ if (closed === undefined) return;
154
+ findings.push(finding('FAC-SEM-112', `/profiles/${i}/models`, `profile ${show(profile.id)} reads user content, but ${closedRoute(profile.fallback, closed)} open-vendor in the catalogue: the lane would run on closed vendors alone`));
155
+ });
156
+ const choices = isObject(value.choices) ? value.choices : {};
157
+ const outcome = resolveModelProfile({ catalogue: value.catalogue, profiles: value.profiles, choices, policy: value.policy });
158
+ if (!('models' in outcome)) return findings;
159
+ const profile = profiles.find((item) => item.id === outcome.profile);
160
+ if (!profile || closedUserContentRoute(profile, value.catalogue) !== undefined) return findings;
161
+ const closed = closedUserContentRoute({ ...profile, models: outcome.models }, value.catalogue);
162
+ if (closed === undefined) return findings;
163
+ const meta = isObject(value.policy) && isObject(value.policy.meta) ? value.policy.meta : {};
164
+ findings.push(finding('FAC-SEM-112', '/resolved/models', `the policy ${show(meta.id)} leaves profile ${show(profile.id)}'s lane on closed vendors alone: of the models it admits, ${closedRoute(profile.fallback, closed)} open-vendor in the catalogue`));
165
+ return findings;
166
+ }
167
+
106
168
  /** The contract's profileRules: findings for a model-catalogue, model-profiles, runner-profile or profile-resolution; else undefined. */
107
169
  export function profileProblems(kind, value) {
108
- return { 'model-catalogue': modelCatalogue, 'model-profiles': modelProfiles, 'runner-profile': runnerProfile, 'profile-resolution': profileResolution }[kind]?.(value);
170
+ return { 'model-catalogue': modelCatalogue, 'model-profiles': modelProfiles, 'runner-profile': runnerProfile, 'profile-resolution': (v) => [...profileResolution(v), ...openVendorRoutes(v)] }[kind]?.(value);
109
171
  }
110
172
 
111
173
  // ---- the doctor's checks ----
@@ -173,7 +235,10 @@ export function modelUsable(model, catalogue, policy) {
173
235
  if (!rows.has(model)) return [`model ${model} is not in the model catalogue`];
174
236
  if (isObject(policy) && Array.isArray(policy.modelClasses)) {
175
237
  const allowed = new Set(strings(policy.modelClasses));
176
- const outside = strings(rows.get(model).classes).filter((c) => !allowed.has(c));
238
+ const admission = strings(rows.get(model).classes).filter((c) => c !== OPEN_VENDOR);
239
+ const policyId = String((isObject(policy.meta) ? policy.meta : {}).id);
240
+ if (!admission.length) return [`model ${model} has no class but ${OPEN_VENDOR}, which admits nothing, so policy ${policyId} does not allow it`];
241
+ const outside = admission.filter((c) => !allowed.has(c));
177
242
  if (outside.length) return [`model ${model} has class ${outside.join(', ')}, which policy ${String((isObject(policy.meta) ? policy.meta : {}).id)} does not allow`];
178
243
  }
179
244
  return [];
@@ -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_estate as _estate
22
23
  import fabric_identifiers as _ids
23
24
  import fabric_secrets as _secrets
24
25
  import fabric_service as _fs
@@ -27,6 +28,10 @@ RESOLUTION_ORDER = ("request", "agent", "binding", "estate")
27
28
  # The vendor login each subscription is, and the one runner kind it runs in (DEC-0046, ADR-0147 decision 2).
28
29
  SUBSCRIPTION_RUNNER = {"claude": "claude-code", "kimi": "kimi-code", "chatgpt": "codex"}
29
30
  MAX_PROBE_MS = 5000
31
+ #: DEC-0083: the model class of a model whose vendor publishes its weights openly — descriptive: a policy admits and
32
+ #: excludes nothing by it — and what a lane reads when it reads what a member, a client or the public sends.
33
+ OPEN_VENDOR = "open-vendor"
34
+ USER_CONTENT = "user-content"
30
35
  _BARE_BINARY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
31
36
 
32
37
 
@@ -38,6 +43,35 @@ def _list(value: Any) -> List[Dict[str, Any]]:
38
43
  return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
39
44
 
40
45
 
46
+ def _indexed(value: Any) -> List[tuple]:
47
+ """The object items of an array with their index as written, so an instancePath names the item even after a
48
+ non-object (the contract's ``indexed``)."""
49
+ return [(item, i) for i, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
50
+
51
+
52
+ def _set_key(value: Any) -> Any:
53
+ """A key with JavaScript's Set semantics for a JSON value: 1 and true, null and absent, are different; two objects
54
+ are never the same member however equal."""
55
+ if value is _MISSING_ID:
56
+ return ("undefined",)
57
+ if value is None:
58
+ return ("null",)
59
+ if isinstance(value, bool):
60
+ return ("boolean", value)
61
+ if isinstance(value, (int, float)):
62
+ return ("number", float(value))
63
+ if isinstance(value, str):
64
+ return ("string", value)
65
+ return ("object", id(value))
66
+
67
+
68
+ _MISSING_ID = object()
69
+
70
+
71
+ def _id_of(item: Dict[str, Any]) -> Any:
72
+ return item.get("id", _MISSING_ID)
73
+
74
+
41
75
  def _strings(value: Any) -> List[str]:
42
76
  return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []
43
77
 
@@ -75,10 +109,11 @@ def _js_number(value: Any) -> Optional[float]:
75
109
  return None
76
110
 
77
111
 
78
- def resolve_model_profile(*, catalogue: Any, profiles: Any, choices: Any, policy: Any = None) -> Dict[str, Any]:
112
+ def resolve_model_profile(*, catalogue: Any = None, profiles: Any = None, choices: Any = None, policy: Any = None) -> Dict[str, Any]:
79
113
  """FAC-SEM-060: the request's profile, else the agent's, else the binding's or project's, else the estate default.
80
114
  When the project's policy names `modelClasses`, a model stays only if every class the catalogue gives it is
81
- allowed; an empty result refuses the launch, naming the policy."""
115
+ allowed; the descriptive class `open-vendor` is not checked (DEC-0083), and a model with no other class does not stay.
116
+ An empty result refuses the launch, naming the policy."""
82
117
  choices = choices if isinstance(choices, dict) else {}
83
118
  level = next((name for name in RESOLUTION_ORDER if isinstance(choices.get(name), str)), None)
84
119
  if level is None:
@@ -94,7 +129,11 @@ def resolve_model_profile(*, catalogue: Any, profiles: Any, choices: Any, policy
94
129
  rows = {}
95
130
  for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None):
96
131
  rows[json.dumps(row.get("id"))] = row
97
- within = [m for m in models if json.dumps(m) in rows and all(c in allowed for c in _strings(rows[json.dumps(m)].get("classes")))]
132
+ def admitted(model: str) -> bool:
133
+ row = rows.get(json.dumps(model))
134
+ admission = [c for c in _strings(row.get("classes")) if c != OPEN_VENDOR] if row is not None else []
135
+ return bool(admission) and all(c in allowed for c in admission)
136
+ within = [m for m in models if admitted(m)]
98
137
  if within:
99
138
  return {"from": level, "profile": profile_id, "models": within}
100
139
  meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
@@ -106,25 +145,25 @@ def _finding(code: str, path: str, message: str) -> Dict[str, str]:
106
145
 
107
146
 
108
147
  def _model_catalogue(value: Dict[str, Any]) -> List[Dict[str, str]]:
109
- findings, seen = [], []
110
- for index, row in enumerate(_list(value.get("models"))):
111
- if row.get("id") in seen:
112
- findings.append(_finding("FAC-SEM-058", "/models/%d/id" % index, "model %s is catalogued more than once" % _js_string(row.get("id"))))
113
- seen.append(row.get("id"))
148
+ findings, seen = [], set()
149
+ for row, index in _indexed(value.get("models")):
150
+ if _set_key(_id_of(row)) in seen:
151
+ findings.append(_finding("FAC-SEM-058", "/models/%d/id" % index, "model %s is catalogued more than once" % _show_id(row)))
152
+ seen.add(_set_key(_id_of(row)))
114
153
  routing = row.get("provider") if isinstance(row.get("provider"), dict) else {}
115
154
  ignored = set(_strings(routing.get("ignore")))
116
155
  for slug in _strings(routing.get("order")):
117
156
  if slug in ignored or slug.split("/")[0] in ignored:
118
- findings.append(_finding("FAC-SEM-058", "/models/%d/provider" % index, "model %s both orders and ignores provider %s" % (_js_string(row.get("id")), slug)))
157
+ findings.append(_finding("FAC-SEM-058", "/models/%d/provider" % index, "model %s both orders and ignores provider %s" % (_show_id(row), slug)))
119
158
  return findings
120
159
 
121
160
 
122
161
  def _model_profiles(value: Dict[str, Any]) -> List[Dict[str, str]]:
123
- findings, seen = [], []
124
- for index, profile in enumerate(_list(value.get("profiles"))):
125
- if profile.get("id") in seen:
126
- findings.append(_finding("FAC-SEM-058", "/profiles/%d/id" % index, "model profile %s is declared more than once" % _js_string(profile.get("id"))))
127
- seen.append(profile.get("id"))
162
+ findings, seen = [], set()
163
+ for profile, index in _indexed(value.get("profiles")):
164
+ if _set_key(_id_of(profile)) in seen:
165
+ findings.append(_finding("FAC-SEM-058", "/profiles/%d/id" % index, "model profile %s is declared more than once" % _show_id(profile)))
166
+ seen.add(_set_key(_id_of(profile)))
128
167
  return findings
129
168
 
130
169
 
@@ -154,14 +193,15 @@ def _profile_resolution(value: Dict[str, Any]) -> List[Dict[str, str]]:
154
193
  findings = []
155
194
  catalogue = value.get("catalogue")
156
195
  catalogued = [row.get("id") for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None)]
157
- ids: List[Any] = []
158
- for i, profile in enumerate(_list(value.get("profiles"))):
159
- if profile.get("id") in ids:
160
- findings.append(_finding("FAC-SEM-060", "/profiles/%d/id" % i, "profile id %s is offered more than once to one resolution" % _js_string(profile.get("id"))))
161
- ids.append(profile.get("id"))
162
- for j, model in enumerate(_strings(profile.get("models"))):
163
- if model not in catalogued:
164
- findings.append(_finding("FAC-SEM-060", "/profiles/%d/models/%d" % (i, j), "profile %s names %s, which the catalogue does not hold" % (_js_string(profile.get("id")), model)))
196
+ ids = set()
197
+ for profile, i in _indexed(value.get("profiles")):
198
+ if _set_key(_id_of(profile)) in ids:
199
+ findings.append(_finding("FAC-SEM-060", "/profiles/%d/id" % i, "profile id %s is offered more than once to one resolution" % _show_id(profile)))
200
+ ids.add(_set_key(_id_of(profile)))
201
+ # Every model by its index as written; only a string can name a catalogued model.
202
+ for j, model in enumerate(profile.get("models") if isinstance(profile.get("models"), list) else []):
203
+ if isinstance(model, str) and model not in catalogued:
204
+ findings.append(_finding("FAC-SEM-060", "/profiles/%d/models/%d" % (i, j), "profile %s names %s, which the catalogue does not hold" % (_show_id(profile), model)))
165
205
  choices = value.get("choices") if isinstance(value.get("choices"), dict) else {}
166
206
  expected = resolve_model_profile(catalogue=catalogue, profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
167
207
  if "resolved" not in value or value["resolved"] != expected:
@@ -169,11 +209,69 @@ def _profile_resolution(value: Dict[str, Any]) -> List[Dict[str, str]]:
169
209
  return findings
170
210
 
171
211
 
212
+ def closed_user_content_route(profile: Any, catalogue: Any) -> Optional[List[str]]:
213
+ """FAC-SEM-112 (DEC-0083) for one profile: the models that run when the lane reads user content and none of them is
214
+ an open-vendor model, or None when the profile does not read user content or its route has one. Under
215
+ ``between-turns`` every model may run; under ``none``, or any other value (failing closed), only the first. A model
216
+ the catalogue does not hold, or whose row is not ``open-vendor``, counts as closed. Never raises."""
217
+ if not isinstance(profile, dict) or USER_CONTENT not in _strings(profile.get("reads")):
218
+ return None
219
+ models = profile.get("models") if isinstance(profile.get("models"), list) else []
220
+ route = models if profile.get("fallback") == "between-turns" else models[:1]
221
+ open_ids = {row["id"] for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None)
222
+ if isinstance(row.get("id"), str) and OPEN_VENDOR in _strings(row.get("classes"))}
223
+ if any(isinstance(model, str) and model in open_ids for model in route):
224
+ return None
225
+ return [_estate._show(model) for model in route]
226
+
227
+
228
+ def _closed_route(fallback: Any, closed: List[str]) -> str:
229
+ """How a closed route is named in a finding: under ``between-turns`` all of it, otherwise its first model."""
230
+ return "none of its models is" if fallback == "between-turns" else "its first model, %s, is not" % (closed[0] if closed else "none")
231
+
232
+
233
+ def _open_vendor_routes(value: Dict[str, Any]) -> List[Dict[str, str]]:
234
+ """FAC-SEM-112: a profile that reads user content has an open-vendor route, so the lane never runs on closed vendors
235
+ alone, which refuse the content it exists to handle. The resolution's outcome is held to the same test: when the
236
+ resolved profile reads user content and its own route is open, the models the policy leaves must still give one,
237
+ or the policy is named as what leaves the lane closed (one cause, one finding)."""
238
+ findings = []
239
+ profiles = _list(value.get("profiles"))
240
+ for profile, i in _indexed(value.get("profiles")):
241
+ closed = closed_user_content_route(profile, value.get("catalogue"))
242
+ if closed is None:
243
+ continue
244
+ findings.append(_finding("FAC-SEM-112", "/profiles/%d/models" % i, "profile %s reads user content, but %s open-vendor in "
245
+ "the catalogue: the lane would run on closed vendors alone" % (_show_id(profile), _closed_route(profile.get("fallback"), closed))))
246
+ choices = value.get("choices") if isinstance(value.get("choices"), dict) else {}
247
+ outcome = resolve_model_profile(catalogue=value.get("catalogue"), profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
248
+ if "models" not in outcome:
249
+ return findings
250
+ profile = next((item for item in profiles if item.get("id") == outcome["profile"]), None) # a string: == is ===
251
+ if profile is None or closed_user_content_route(profile, value.get("catalogue")) is not None:
252
+ return findings
253
+ closed = closed_user_content_route(dict(profile, models=outcome["models"]), value.get("catalogue"))
254
+ if closed is None:
255
+ return findings
256
+ policy = value.get("policy")
257
+ meta = policy.get("meta") if isinstance(policy, dict) and isinstance(policy.get("meta"), dict) else {}
258
+ findings.append(_finding("FAC-SEM-112", "/resolved/models", "the policy %s leaves profile %s's lane on closed vendors alone: of the "
259
+ "models it admits, %s open-vendor in the catalogue" % (_estate._show(meta["id"]) if "id" in meta else "undefined",
260
+ _show_id(profile), _closed_route(profile.get("fallback"), closed))))
261
+ return findings
262
+
263
+
264
+
265
+ def _show_id(profile: Dict[str, Any]) -> str:
266
+ """show(profile.id), where an absent id is JavaScript's undefined."""
267
+ return _estate._show(profile["id"]) if "id" in profile else "undefined"
268
+
269
+
172
270
  def profile_problems(kind: str, value: Dict[str, Any]) -> Optional[List[Dict[str, str]]]:
173
271
  """The contract's profileRules: findings for a `model-catalogue`, `model-profiles`, `runner-profile` or
174
272
  `profile-resolution` document; None for another kind."""
175
273
  return {"model-catalogue": _model_catalogue, "model-profiles": _model_profiles, "runner-profile": _runner_profile,
176
- "profile-resolution": _profile_resolution}.get(kind, lambda v: None)(value)
274
+ "profile-resolution": lambda v: _profile_resolution(v) + _open_vendor_routes(v)}.get(kind, lambda v: None)(value)
177
275
 
178
276
 
179
277
  # --- the doctor's checks ----------------------------------------------------------------------------------------
@@ -253,9 +351,12 @@ def model_usable(model: str, catalogue: Any, policy: Any = None) -> List[str]:
253
351
  return ["model %s is not in the model catalogue" % model]
254
352
  if isinstance(policy, dict) and isinstance(policy.get("modelClasses"), list):
255
353
  allowed = set(_strings(policy["modelClasses"]))
256
- outside = [c for c in _strings(rows[model].get("classes")) if c not in allowed]
354
+ meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
355
+ admission = [c for c in _strings(rows[model].get("classes")) if c != OPEN_VENDOR] # DEC-0083: descriptive
356
+ if not admission:
357
+ return ["model %s has no class but %s, which admits nothing, so policy %s does not allow it" % (model, OPEN_VENDOR, _js_string(meta.get("id")))]
358
+ outside = [c for c in admission if c not in allowed]
257
359
  if outside:
258
- meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
259
360
  return ["model %s has class %s, which policy %s does not allow" % (model, ", ".join(outside), _js_string(meta.get("id")))]
260
361
  return []
261
362
 
@@ -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.33.0"
8
+ version: "0.34.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "82e163665e156a08aa3b92e4711015582c06162c"
10
+ contract-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
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 `82e163665e156a08aa3b92e4711015582c06162c` and
126
+ Pin exactly contract `0.1.0` at commit `d8146cfb0a1c74fc6432284c4356bb0c864b37b5` 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
@@ -152,7 +152,11 @@ absent, apply the `fabric-service/0.1` rules from the pinned contract's
152
152
  `docs/specification/service.md` by hand and mark its probe `NOT_RUN`.
153
153
 
154
154
  Build the capability behind the chosen surface. Keep model choice and internal reasoning
155
- outside the contract; expose typed outcomes, evidence, and protocol-visible state. Every
155
+ outside the contract; expose typed outcomes, evidence, and protocol-visible state. One fact
156
+ about each model lane is the contract's (DEC-0083): what it reads. Ask in the grill which
157
+ lanes read what members, clients or the public send, declare it as the profile's `reads`,
158
+ and give such a lane an open-vendor route (FAC-SEM-112; the `building-fabric-services`
159
+ profiles reference). Every
156
160
  provider output is untrusted until schemas and semantic assertions pass. The step-2
157
161
  observables are the definition of done; an observable attached after the code exists
158
162
  lets the output decide what counts as success.
@@ -19,7 +19,7 @@ compatibility: >-
19
19
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
20
20
  metadata:
21
21
  author: PassionCode.ai
22
- version: "0.33.0"
22
+ version: "0.34.0"
23
23
  ---
24
24
 
25
25
  # 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.33.0"
63
+ VERSION = "0.34.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