@passioncode-ai/fabric-agent-adapter 0.26.0 → 0.28.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 (22) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +6 -2
  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 +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +22 -13
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +174 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +36 -1
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +60 -2
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1104 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +337 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +211 -2
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1274 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +397 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +294 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +43 -2
  19. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  20. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  21. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -7
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,95 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.28.0 - 2026-10-13
4
+
5
+ A service answers a Project's requests on Fabric's project board (`fabric-project-comms/0.1`; FAA-12b, contract
6
+ DEC-0022, DEC-0073, DEC-0078; Fabric ADR-0149), and checks a directory page before it looks a Project up (FAA-40,
7
+ DEC-0077, `FAC-SEM-107`). Repinned to contract `f00a768` (DEC-0078: a replacement's receipt carries the attempt of
8
+ each request it transferred); `a046f57` stays supported.
9
+
10
+ ### Added
11
+
12
+ - **The hub door client.**
13
+ - `fabric_hub.py` / `fabric-hub.mjs` re-read `hub.json` before every call, the way Fabric's `locateHub` does: a small
14
+ regular file, protocol `fabric-hub/0.1`, the origin exactly `http://127.0.0.1:<port>`, a door token inside the
15
+ registry root, and a live pid. A binding credential is sent only after that check.
16
+ - `request_board_access` asks the operator for the `board.respond` grant. `poll_access` writes the credential, shown
17
+ once, to a 0600 file and leaves it out of the answer. `join_board` answers a board session.
18
+ - Typed `HubError`s: a missing grant carries the arguments to ask with. `Backoff` waits 2 s, doubling up to 5 minutes,
19
+ with jitter, so a loop never spins.
20
+ - A `hubKey` in a later `hub.json` is checked with `hub_key_certified` (DEC-0060) when the caller passes an estate
21
+ record. `fabric-hub/0.1` publishes no key today.
22
+ - **The responder.** `fabric_board.Responder` / `Responder` runs inside the service, one request at a time:
23
+ - Each tick (15 s ± 5 s) enrolls, claims, reads the message and accepts it with the claim's exact fence. The digest
24
+ must match the claim's, and the request must name this Project and capability.
25
+ - `com.effect_begin` is sent only when the handler calls `ctx.begin_effect()`. Renewal is about every 20 s. Progress
26
+ notes, a fenced `com.reply`, and `com.complete` with an observed outcome and at most 8 sha256 artifacts follow.
27
+ - A checkpoint (0600, fsync) is written before each step. If it cannot be written, nothing is sent.
28
+ - It stops acting on `fenced`, `lease_expired` and `authority_changed`.
29
+ - On restart it reconciles unknown outcomes from what `observe` reads. It takes accepted work back only through
30
+ `com.responder_replace`'s `transferred` (DEC-0078). On a Fabric that does not answer `transferred`, the work stays
31
+ held and is never claimed back.
32
+ - A board session never sends `com.submit`, `com.cancel`, `com.read_ack` or `com.directory`.
33
+ - With no idempotency epoch in `com.status`, it fails closed.
34
+ - The body is data: `admit`, the service's own input gate, is required. A refusal completes the request
35
+ `failed_known` with the gate's sentence.
36
+ - **The directory page check (FAA-40).** `directory_findings`, `directory_problems` and `find_project` / `findProject`
37
+ apply `FAC-SEM-107`: each Project once, each capability once per Project, compared exactly as written. A page that
38
+ fails is refused rather than read.
39
+ - **Closed shapes:** `receipt_problems` (with `transferred` for `com.responder_replace` only, each request once),
40
+ `refusal_problems`, `evidence_problems` (one artifact never twice, DEC-0076), `outcome_problems`, and
41
+ `comms_digest`, which matches the contract's `commsDigest` vectors.
42
+ - **`check_service.py`** gains the rule `board.responder`:
43
+ - `NOT_RUN` when the well-known `protocols` lists no `fabric-project-comms` revision.
44
+ - Otherwise it runs the kit's fixture conversation: `self_check()` / `selfCheck()` covers the whole conversation, a
45
+ fenced accept and a session without an epoch. `--board-kit` names the service's own copy.
46
+ - **`sample_service.py serve --board-project <uuid>`** opts in to answering `sample.echo` on the board, with a gate
47
+ and a reply. It is off by default.
48
+ - `references/board.md`, linked from SKILL.md. working-with-fabric's `project-board.md`:
49
+ - The project directory is decided (DEC-0073).
50
+ - Answering as a responder needs `board.respond`.
51
+
52
+ ### Evidence
53
+
54
+ - 20 hub tests and 49 board tests in Python, and 16 Node tests, run against one fake Fabric hub
55
+ (`test/fake_fabric_hub.py`), which models migration 87's fence order. The Python and Node kits read each other's
56
+ checkpoint.
57
+ - The contract's 77 comms fixtures, 8 responder-argument digests and 12 `FAC-SEM-107` pages are vendored by
58
+ `vendor_vectors.mjs` at `f00a768`.
59
+ - 22 planted defects across both kits, all caught (`docs/reports/2026-10-13-faa-12b-board-responders/raw/plants.log`).
60
+ - Red before: the new tests at `origin/main` `65c1751` fail on the missing modules.
61
+ - The live Claude→Codex board smoke is `NOT_RUN`: no disposable Fabric fits this machine. The commands are in
62
+ `references/board.md`.
63
+
64
+ ## 0.27.0 - 2026-10-13
65
+
66
+ The usage report's window and spend by project, and OpenRouter attribution for a product's own traffic (contract
67
+ DEC-0065, FAA-33). Pin unchanged at `a046f57`.
68
+
69
+ ### Added
70
+
71
+ - **Spend by project.** `make_usage_receipt` / `makeUsageReceipt` take `project_id` / `projectId`, a URI kept in its
72
+ normal form (DEC-0076), and `task_run_id` / `taskRunId`. `usage_report` / `usageReport` then write `byProject`: each
73
+ project once, each task run once within it, with calls, unpriced calls and cost. It names no caller, session or
74
+ person.
75
+ - **FAC-SEM-025 whole:** `usage_findings` / `usageFindings` check the arithmetic, the window (every day exists, is not
76
+ after `generatedAt`'s UTC date and is at most 30 days before it) and the projects. The projects' calls, priced calls
77
+ and known costs are bounded by the days' (CT-40).
78
+ - **OpenRouter attribution, off by default:** `attribution_headers` / `attributionHeaders` and
79
+ `attribution_setting_findings` / `attributionSettingFindings` (FAC-SEM-098). The answer adds `HTTP-Referer`,
80
+ `X-OpenRouter-Title` and `X-OpenRouter-Categories` only with a valid setting, only to `https://openrouter.ai` (read as
81
+ a browser reads the URL), only for traffic the product originates, and never over a user's own attribution.
82
+ - **`check_service.py`** `usage.report` reads each row's route (`platform-metered` included), the window and
83
+ `byProject`.
84
+
85
+ ### Evidence
86
+
87
+ - Both kits pass the contract's 34 attribution cases.
88
+ - Both kits give the contract's `usageRules` answer on 21 reports: its six positive usage fixtures and 15 edits of the
89
+ by-project one.
90
+ - 23 planted defects, all caught.
91
+ - The scenario eval `usage-by-project-and-attribution` was written before the prose.
92
+
3
93
  ## 0.26.0 - 2026-10-13
4
94
 
5
95
  Feed events carry machine-readable `data` (contract DEC-0070, FAA-35), and a service says which protocol revisions it
package/README.md CHANGED
@@ -52,7 +52,11 @@ or dashboard — an `https` origin on a platform — a Fabric service (the remot
52
52
  four routes behind the token, a `__Host-` session cookie, and one descriptor on the operator's computer;
53
53
  `scripts/sample-remote-service.mjs` is the complete example. It ships Python and
54
54
  Node reference kits (`scripts/fabric_service.py`, `scripts/fabric-service.mjs`), a
55
- complete `scripts/sample_service.py`, and `scripts/check_service.py`, a live probe:
55
+ complete `scripts/sample_service.py`, and `scripts/check_service.py`, a live probe. A service that answers
56
+ requests other Projects post to its Project on Fabric's project board uses the board responder
57
+ (`scripts/fabric_hub.py` and `scripts/fabric_board.py`, Node `fabric-hub.mjs` and `fabric-board.mjs`): the
58
+ `board.respond` grant, enroll, claim, accept the exact digest, complete, a checkpoint before every step, and restarts
59
+ that reconcile or take accepted work back by a transfer (the [board reference](plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md)):
56
60
 
57
61
  ```bash
58
62
  python3 plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py example-agent
@@ -235,7 +239,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
235
239
  The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
236
240
 
237
241
  - version: `0.1.0`
238
- - commit: `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b`
242
+ - commit: `f00a7685e3861c27f7899c033fb850a145a1708f`
239
243
 
240
244
  Every other mention of the default contract revision — this section, the skills' metadata, the
241
245
  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.26.0",
3
+ "version": "0.28.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.26.0",
6
+ "version": "0.28.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.26.0"
8
+ version: "0.28.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
10
+ contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
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 `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b`.
73
+ - commit `f00a7685e3861c27f7899c033fb850a145a1708f`.
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 = "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
24
+ CONTRACT_COMMIT = "f00a7685e3861c27f7899c033fb850a145a1708f"
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, "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
27
+ SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "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"
@@ -2,24 +2,25 @@
2
2
  name: building-fabric-services
3
3
  description: >-
4
4
  Use when handing out or opening a Fabric service dashboard, building a local agent service on a
5
- macOS machine, or making an ONLINE agent or dashboard (https origin, the remote placement) a
6
- Fabric service — «сделай агенту дашборд», «локальный сервис агента», «онлайн-дашборд в Fabric»,
7
- «подключи агента к Fabric Dashboards», "make this agent a local service", "make an online
8
- dashboard a Fabric service", "fabric-service protocol", "add the well-known endpoint", «расходы агента
9
- на модели». Covers
10
- fabric-service/0.1: surfaces, token and one-time login, state, one copy, launchd, descriptor,
11
- well-known document, events, the usage report (spend per model); online: the https guard, the token-gated well-known document, the
12
- __Host- cookie, registering it on the operator's computer; Python, Node and dashboard
13
- kits, a TLS sample, live and layout probes. NOT for a one-off script or cron job, a hosted product with no agent
14
- behind it, the provider manifest (adapting-projects-to-fabric), or building Fabric Dashboards.
5
+ macOS machine, making an ONLINE agent or dashboard (https origin, the remote placement) a
6
+ Fabric service, or letting a service answer a Project's board requests — «сделай агенту дашборд»,
7
+ «локальный сервис агента», «подключи агента к Fabric Dashboards», «отвечать на запросы с доски проекта»,
8
+ "make this agent a local service", "make an online dashboard a Fabric service", "add the
9
+ well-known endpoint", «расходы агента на модели». Covers fabric-service/0.1:
10
+ surfaces, token and one-time login, state, one copy, launchd, descriptor, well-known document,
11
+ events, the usage report; online: the https guard, the token-gated well-known document, the
12
+ __Host- cookie; the board responder (fenced claims); Python, Node and dashboard kits, probes.
13
+ NOT for a one-off script or cron job, a hosted product
14
+ with no agent behind it, the provider manifest (adapting-projects-to-fabric), or building Fabric
15
+ Dashboards.
15
16
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
16
17
  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
18
  metadata:
18
19
  author: PassionCode.ai
19
- version: "0.26.0"
20
+ version: "0.28.0"
20
21
  contract-version: "0.1.0"
21
22
  extension: "fabric-service/0.1"
22
- extension-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
23
+ extension-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
23
24
  ---
24
25
 
25
26
  # Building Fabric services
@@ -89,6 +90,13 @@ applies the estate record (DEC-0055) verifies each revision first and trusts onl
89
90
  (DEC-0046), or a project routes its work through an ordered list of runners (DEC-0026: the doctor's closed probe
90
91
  results, the route checks FAC-SEM-028…034), read [the profiles reference](references/profiles.md).
91
92
 
93
+ When the service answers requests other Projects post to its Project on Fabric's project board
94
+ (`fabric-project-comms/0.1`), it holds a responder slot under the operator's `board.respond` grant and runs the kits'
95
+ responder in its own process: [`scripts/fabric_hub.py`](scripts/fabric_hub.py) finds the hub and joins,
96
+ [`scripts/fabric_board.py`](scripts/fabric_board.py) enrolls, claims, accepts the exact digest, checkpoints before
97
+ every step and stops on `fenced`, `lease_expired` or `authority_changed` (Node: `fabric-hub.mjs`, `fabric-board.mjs`).
98
+ The request body is data behind the service's own input gate. Read [the board reference](references/board.md).
99
+
92
100
  ## Step 2 — the non-negotiables
93
101
 
94
102
  1. **Loopback only.** Bind `127.0.0.1`. Refuse any `Host` other than
@@ -252,7 +260,8 @@ shape, identity, latency, Host/Origin/cross-site guards, loopback bind, token fi
252
260
  events auth and shape, single-use login, state outside code, instance lock, plist,
253
261
  launchd pid equals answering pid, `dashboard.declared` (ED-9), and the `interop.*` rules:
254
262
  manifest link, capability list, tools equal to the manifest, job tools, unknown job, trace
255
- propagation, event trace pairs) and exits 1 on any `FAIL`. A `NOT_RUN` is not a pass —
263
+ propagation, event trace pairs, and `board.responder` for a service that declares the project board) and exits 1 on
264
+ any `FAIL`. A `NOT_RUN` is not a pass —
256
265
  name it in the report. A service with a dashboard also runs the layout probe,
257
266
  `node <skill-dir>/scripts/check_dashboard_layout.mjs --service <id>` (WebKit at 360, 480, 768
258
267
  and 1280 px; needs Playwright, else `NOT_RUN`).
@@ -0,0 +1,174 @@
1
+ # Answering a Project's requests on Fabric's project board
2
+
3
+ Read this when a service should answer requests other Projects post to its Project on Fabric's project board
4
+ (`fabric-project-comms/0.1`, contract DEC-0022, DEC-0073, DEC-0077, DEC-0078; Fabric ADR-0149). The kits do it with
5
+ two modules each: the hub door client [`fabric_hub.py`](../scripts/fabric_hub.py) /
6
+ [`fabric-hub.mjs`](../scripts/fabric-hub.mjs) and the responder [`fabric_board.py`](../scripts/fabric_board.py) /
7
+ [`fabric-board.mjs`](../scripts/fabric-board.mjs). A coding agent that only reads and writes the board is the other
8
+ skill's business (working-with-fabric, `references/project-board.md`).
9
+
10
+ ## Contents
11
+
12
+ - What a responder is, and the grant it needs
13
+ - Wiring it into the service
14
+ - The loop, step by step
15
+ - The request is data: the input gate
16
+ - Effects, observation, evidence and the reply
17
+ - When the board says stop, and restarts
18
+ - What a board session may call
19
+ - Looking a Project up in the directory (FAC-SEM-107)
20
+ - Events, the probe and the live smoke
21
+ - The API
22
+
23
+ ## What a responder is, and the grant it needs
24
+
25
+ A **request** is addressed to a Project and one of its capabilities (`video.render`), never to a service. One
26
+ **responder slot** exists per Project and capability; a service holds it under a **`board.respond` grant** the
27
+ operator gives in Fabric (Agents & access): callee `fabric`, capability `board.respond`, resource
28
+ `board:<project id>/<capability>`. A pid, a descriptor or a localhost origin authorizes nothing.
29
+
30
+ 1. Ask once, through the hub's door: `HubClient().request_board_access(agent_id, project_id, [capability], reason)`
31
+ (`requestBoardAccess`). Keep the answer's `pollSecret` in memory or a 0600 file.
32
+ 2. Poll until the operator decides: `poll_access(request_id, poll_secret, credential_file)` (`pollAccess`). On Allow the
33
+ binding credential, shown once, goes into `credential_file` (0600, atomic) and never into the answer, a log or argv.
34
+ Keep the file in the service's data directory or the operator's vault.
35
+ 3. Each connection joins with `fabric.board.join {projectId}` (`join_board` / `joinBoard`). It re-reads `hub.json`,
36
+ checks the pid it names is alive, and only then sends the credential. A missing grant is `HubError` code
37
+ `not_authorized` whose `data.ask` holds the arguments to ask with.
38
+
39
+ ## Wiring it into the service
40
+
41
+ The responder runs inside the service process, started after the instance lock like every other piece of state
42
+ (SKILL.md, Step 3). [`sample_service.py`](../scripts/sample_service.py) does it behind `serve --board-project <uuid>`,
43
+ off by default:
44
+
45
+ ```python
46
+ import fabric_board as fbd, fabric_hub as fhb
47
+
48
+ responder = fbd.Responder(
49
+ hub=fhb.HubClient(), project_id=PROJECT, capabilities=["video.render"],
50
+ credential_file=dirs["data"] / "board.credential", checkpoint_file=dirs["data"] / "board-checkpoint.json",
51
+ handler=render, admit=gate, observe=look_on_disk, emit=log.append, service="example-agent")
52
+ threading.Thread(target=responder.run, args=(stop_event, 5.0), daemon=True).start() # SIGTERM: set stop_event
53
+ ```
54
+
55
+ Node: `new Responder({...same names in camelCase})`, then `responder.run({ signal, drain })`. A service that lists
56
+ `fabric-project-comms/0.1` in its well-known `protocols` (DEC-0067: only once its hosts are pinned at or after it)
57
+ declares that it answers board requests; the probe checks that declaration.
58
+
59
+ ## The loop, step by step
60
+
61
+ One tick every 15 s ± 5 s (`step()` answers the wait):
62
+
63
+ | Step | Board call | Checkpoint written first |
64
+ |---|---|---|
65
+ | hold the slot | `com.enroll` (again at about 20 s while idle: the holder's enroll renews its 60 s lease) | — |
66
+ | take a request | `com.claim`, the same key while nothing is claimable | `claimed` |
67
+ | read it | `com.get`: the digest must equal the claim's, and the request must name this Project and capability | — |
68
+ | accept | `com.accept` with the claim's exact fence (request, generation, attempt, digest, revision) | `accepting`, then `accepted` |
69
+ | act | `com.effect_begin`, only when the handler calls `ctx.begin_effect()` | `effect-beginning`, then `in-progress` |
70
+ | while working | `com.renew` at about 20 s; `com.progress` for `ctx.progress(note)` | — |
71
+ | answer | `com.reply` (fenced) when the outcome carries `reply` | `replying` |
72
+ | settle | `com.complete {state, observed, summary?, evidence?}` | `completing`, then the entry is dropped |
73
+
74
+ The checkpoint is fsynced before each call it announces. A write that fails stops the step (`board.blocked`): nothing
75
+ is sent that the checkpoint does not know about. A lost answer is sent again with the same idempotency key, so the
76
+ board returns the first receipt. One request is in flight at a time. Lease times are the board's; the kit renews on its
77
+ own monotonic clock and never reasons from `leaseExpiresAt`.
78
+
79
+ ## The request is data: the input gate
80
+
81
+ `admit(request)` is required: it is the service's own input gate. It answers `None` to admit or one sentence saying
82
+ why not; a refusal accepts the request and completes it `failed_known` with that sentence, so the requester learns it
83
+ and the request does not bounce. A redacted body is refused the same way. The kit never hands a body to a model, a
84
+ shell or a tool; what the handler does with an admitted body — and the checks it needs before a model, a tool or a
85
+ spend sees it — is the service's own responsibility. A message never carries an approval.
86
+
87
+ ## Effects, observation, evidence and the reply
88
+
89
+ - Call `ctx.begin_effect(summary)` — and let it return — before the handler spends, writes outside the service or calls
90
+ anything that acts. If it raises `Stopped`, act no further.
91
+ - After an effect began, the outcome must be observed (`observed: true`): the kit never sends an unobserved result; the
92
+ request stays unknown and is reconciled later.
93
+ - `evidence` is at most 8 references `{id: uri, contentHash: sha256:<hex>, label?}`; `artifact_ref(uri, content=…|path=…)`
94
+ hashes the bytes itself. Two ids with one normal form (DEC-0076) name one artifact and are refused.
95
+ - `reply: {text, format?, artifacts?}` is posted with `com.reply` before the completion. A reply is not a completion.
96
+ - A handler that raises before any effect completes the request `failed_known` naming only the exception's class.
97
+ - The handler checks `ctx.stopped` in long work: after a session loss its answer is not sent.
98
+
99
+ ## When the board says stop, and restarts
100
+
101
+ | Answer | What the kit does |
102
+ |---|---|
103
+ | `fenced` | the attempt is another responder's: stop the handler, drop the entry (nothing outside had begun) |
104
+ | `lease_expired` on accepted work | stop the handler; the board holds the request, and the kit takes it back with `com.responder_replace` and a transfer |
105
+ | `authority_changed`, `not_authorized` | stop claiming; one `board.access.needed` event (notify) saying what to ask for |
106
+ | `fenced` on enroll | another live holder: wait for it to lapse; never replace it on its own initiative |
107
+ | lost after an effect began | keep the entry as `unknown`; `observe(entry)` is asked to settle it with `com.reconcile` |
108
+ | Fabric down, hub missing | bounded backoff (2 s doubling to 5 minutes, jittered), one `board.down` event, no spinning |
109
+
110
+ On restart the checkpoint is read back: settled or queued requests are dropped; `in_progress` and `outcome_unknown` are
111
+ reconciled from what `observe(entry)` answers (observed only; with no observation they stay unknown and notify);
112
+ accepted ones go back to this responder with `com.responder_replace {expectedGeneration, transfer}`, whose receipt's
113
+ `transferred` (DEC-0078: `{request, revision, attempt}`) carries the new fence. **A Fabric that does not answer
114
+ `transferred` yet leaves the request held** (`board.request.held`, notify): the kit never claims it back, because the
115
+ contract has no `com.claim` read-back and `com.claim` never moves an accepted request. A known outcome is sent after the
116
+ transfer without running the handler again. `shutdown()` drains, then leaves the session (its DELETE releases the slot).
117
+
118
+ ## What a board session may call
119
+
120
+ A board session (`fabric.board.join`) is served the respond scope only: `com.status`, `com.list`, `com.get`,
121
+ `com.reply` (the fenced form), `com.enroll`, `com.claim`, `com.renew`, `com.accept`, `com.progress`,
122
+ `com.effect_begin`, `com.complete`, `com.reconcile`, `com.responder_replace`. `BoardSession.call` refuses anything else
123
+ locally (`not-in-scope`) — never `com.submit`, `com.cancel`, `com.read_ack` or `com.directory`.
124
+
125
+ The idempotency epoch comes from `com.status`. A Fabric built before its board-session fix tells a board session none:
126
+ the kit fails closed (`no-epoch`, `board.blocked`) instead of guessing one.
127
+
128
+ ## Looking a Project up in the directory (FAC-SEM-107)
129
+
130
+ The directory belongs to a Project session (a coding agent's, or a launched one). Before looking a Project up in a
131
+ `com.directory` page, `find_project(page, project_id, capability=None)` / `findProject` checks the page: closed shapes
132
+ (no count, holder or generation) and FAC-SEM-107 — each Project once, each capability once per Project. Project ids and
133
+ capability names compare **exactly as written** (DEC-0077; they are not URIs, so not by normal form). A page that fails
134
+ raises `directory-invalid` rather than answering; an unlisted Project may still be addressed by an id already held.
135
+
136
+ ## Events, the probe and the live smoke
137
+
138
+ Events go to `emit(kind, level, text, notify=…, data=…)` — `JsonlEventLog.append` fits — with `data {schema, value}`
139
+ checked by FAC-SEM-101 (no credential in data): `board.request.completed`, `board.request.refused`,
140
+ `board.request.skipped`, `board.request.reconciled`, `board.request.held`, `board.attempt.lost`,
141
+ `board.reconcile.needed`, `board.slot.contested`, `board.access.needed`, `board.blocked`, `board.down`, each
142
+ notifying episode said once.
143
+
144
+ `check_service.py <id> [--board-kit <the service's fabric_board.py or fabric-board.mjs>]` rule **board.responder**:
145
+ `NOT_RUN` when the well-known `protocols` names no `fabric-project-comms` revision; `FAIL` for a revision this kit does
146
+ not read or a fixture conversation that goes wrong; `PASS` when the kit's `self_check()` / `selfCheck()` passes (the
147
+ whole conversation, a fenced accept, a session without an epoch — no Fabric touched).
148
+
149
+ The live smoke runs only against a disposable Fabric (built app and its own stack), never an operator's estate: Fabric's
150
+ COM-03 packet (`docs/evidence/plans/2026-10-04-project-communications.md`) has the exact commands — register
151
+ `fabric-mcp` for Claude Code and Codex, the operator allows `session.enroll` and `board.respond`
152
+ (`board:<example-agent project>/<capability>`), Claude submits a request, the responder enrolls, claims, accepts and
153
+ completes, then is killed mid-claim and replaced. Without that Fabric, report it `NOT_RUN`.
154
+
155
+ ## The API
156
+
157
+ Python (`fabric_hub`): `fabric_root(platform, env)`, `locate_hub(root=None, alive=pid_alive, estate=None, now=None)`,
158
+ `read_door_token(location)`, `read_credential(path)`, `HubClient(root=None, *, alive, timeout, estate, post)` with
159
+ `request_board_access(agent_id, project_id, capabilities, reason)`, `poll_access(request_id, poll_secret,
160
+ credential_file)`, `join_board(project_id, credential_file) -> BoardGrant`, `call_door(tool, args)`,
161
+ `call_binding(tool, args, credential_file)`; `Backoff(first=2, cap=300, jitter=0.2, floor=1)`; `HubError(code,
162
+ message, retryable, data)`.
163
+
164
+ Python (`fabric_board`): `Responder(*, hub, project_id, capabilities, credential_file, checkpoint_file, handler,
165
+ admit, observe=None, emit=None, service, now, rand, spawn, session_factory)` with `step()`, `run(stop, drain)`,
166
+ `shutdown(drain)`; `WorkContext.progress(note)`, `.begin_effect(summary)`, `.stopped`; `BoardSession(grant)`;
167
+ `Checkpoint(path)`; `comms_canonical(value)`, `comms_digest(operation, value)`, `receipt_problems(value, op)`,
168
+ `refusal_problems(value)`, `directory_findings(page)`, `directory_problems(page)`, `find_project(page, project_id,
169
+ capability)`, `artifact_ref(uri, content|path, label)`, `evidence_problems(list)`, `outcome_problems(outcome,
170
+ effect_began)`, `self_check()`; `BoardError`, `Stopped`.
171
+
172
+ Node: the same names in camelCase (`locateHub(root, {alive, estate, now})`, `new HubClient(root, {alive, timeout,
173
+ estate, post})`, `new Responder({...})` with async `step()`, `run({signal, drain})`, `shutdown(drain)`; `await
174
+ ctx.progress(note)`, `await ctx.beginEffect(summary)`; `selfCheck()`). Both kits read and write the same checkpoint file.
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1 — wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b`
3
+ Pinned to `fabric-agent-contract` commit `f00a7685e3861c27f7899c033fb850a145a1708f`
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;
@@ -8,7 +8,7 @@ canonical forms DEC-0043; `degraded[].setup` and `run.mcp.stdio.scope` DEC-0044;
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
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
+ by normal form DEC-0076; the project board's directory page DEC-0077 and a replacement's transferred attempts DEC-0078). The contract's schemas are normative;
12
12
  this page is the working summary. Extension key:
13
13
  `https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
14
14
 
@@ -5,6 +5,14 @@ Fabric Agent Contract DEC-0021, `docs/specification/service.md` → *Usage repor
5
5
  offer it, so Fabric Dashboards (Spend, MCP `spend`) and Fabric can show each agent's spend beside
6
6
  its health without knowing any provider.
7
7
 
8
+ ## Contents
9
+
10
+ - What to record
11
+ - What to answer
12
+ - By project (DEC-0065)
13
+ - OpenRouter attribution (DEC-0065)
14
+ - Verify
15
+
8
16
  ## What to record
9
17
 
10
18
  One **receipt per model call**, built from the numbers the provider returned:
@@ -78,9 +86,36 @@ Call `ledger.prune()` at start (or daily) to keep the file at the reported windo
78
86
  `JsonlUsageLedger` appends 0600 JSON lines. A line torn by a killed writer is skipped, and the
79
87
  next receipt never glues onto it.
80
88
 
89
+ ## By project (DEC-0065)
90
+
91
+ Pass `project_id` / `projectId` (the URI of the project the service ran the call for) and, optionally, `task_run_id` /
92
+ `taskRunId` (the service's own run id) to `make_usage_receipt` / `makeUsageReceipt`. The report then carries `byProject`:
93
+ each project once, by the normal form of its id (DEC-0076, so `URN:PROJECT:a` and `urn:project:a` are one row), with
94
+ its calls, unpriced calls, cost and `taskRuns`. A call for no project is in no row. It is the service's own run
95
+ attribution and names no caller, session or person. `usage_findings` / `usageFindings` port FAC-SEM-025 whole:
96
+ - the arithmetic;
97
+ - the window: every day exists, is not after `generatedAt`'s UTC date, and is at most 30 days before it;
98
+ - the projects: each project and task run once, and their calls, priced calls and known costs within the days' (CT-40).
99
+
100
+ A row's `route` may be `platform-metered`: the hosting platform's own metered models under the deployment's account.
101
+
102
+ ## OpenRouter attribution (DEC-0065)
103
+
104
+ A product credits its own OpenRouter traffic with `attribution_headers(setting, request)` / `attributionHeaders`. The
105
+ setting is `{product, title, categories?}`: a lower-case slug (the app is `https://passioncode.ai/<product>/`), a printable
106
+ title, and at most two of OpenRouter's recognized categories (FAC-SEM-098, `attribution_setting_findings`). Attribution
107
+ is off by default: without a setting nothing is sent. The answer adds `HTTP-Referer`, `X-OpenRouter-Title` and
108
+ `X-OpenRouter-Categories` only when all of these hold:
109
+ - the request goes to `https://openrouter.ai` (read as a browser reads a URL);
110
+ - the product itself originated the traffic;
111
+ - the request carries no attribution header of the user's own.
112
+
113
+ Otherwise it answers why not. It never reads, moves or returns a key.
114
+
81
115
  ## Verify
82
116
 
83
117
  `check_service.py <id>` reads a declared report: `usage.requires-token` (401 without the token)
84
- and `usage.report` (identity, USD, dates forward, null for all-unpriced rows, sums). The
118
+ and `usage.report` (identity, USD, dates forward, null for all-unpriced rows, sums, each row's route, the window and
119
+ `byProject`). The
85
120
  `sample_service.py` serves one. Fabric Dashboards → **Spend** shows it within a minute of opening
86
121
  the page.
@@ -5,6 +5,7 @@
5
5
  check_service.py --descriptor PATH
6
6
  options: --services-dir DIR --json --skip-login
7
7
  --ca-file PEM --connect HOST:PORT (remote placement: trust a test CA; dial another address)
8
+ --board-kit PATH (board.responder: the service's own fabric_board.py or fabric-board.mjs)
8
9
 
9
10
  A remote placement (DEC-0019 — an online agent or dashboard at an https origin) is probed over
10
11
  TLS with the certificate verified: the well-known document must refuse a request without the
@@ -19,6 +20,12 @@ that it redeems one login code it asked for itself (that creates one session).
19
20
  The interop.* rules (fabric-interop/0.1) read the manifest the descriptor names and
20
21
  the service's MCP surface: tools/list, and fabric.job.get for an id that does not
21
22
  exist. They never call a capability.
23
+
24
+ board.responder (fabric-project-comms/0.1, FAA-12b): a service that lists a fabric-project-comms revision in its
25
+ well-known `protocols` declares that it answers board requests. The rule checks that this kit reads that revision and
26
+ runs the kit's fixture conversation (`fabric_board.self_check`, or `selfCheck` of a Node copy given with --board-kit):
27
+ the whole conversation, a fenced accept and a board session without an epoch, with no Fabric. A live board is never
28
+ touched; that smoke needs a disposable Fabric and is reported separately.
22
29
  """
23
30
 
24
31
  from __future__ import annotations
@@ -112,7 +119,8 @@ class _PinnedHTTPS(http.client.HTTPSConnection):
112
119
 
113
120
  class Probe:
114
121
  def __init__(self, descriptor_path: Path, descriptor: Dict[str, Any], services_dir: Path, skip_login: bool,
115
- ca_file: Optional[str] = None, connect: Optional[str] = None):
122
+ ca_file: Optional[str] = None, connect: Optional[str] = None, board_kit: Optional[str] = None):
123
+ self.board_kit = board_kit
116
124
  self.remote = fs.placement_of(descriptor) == "remote"
117
125
  self.ca_file = ca_file
118
126
  self.connect = connect
@@ -799,10 +807,49 @@ class Probe:
799
807
  self.dashboard_rules()
800
808
  self.login_rules()
801
809
  self.interop_rules()
810
+ verdict, evidence = board_responder_verdict(self.wk, self.board_kit)
811
+ self.add("board.responder", verdict, evidence)
802
812
  self.lifecycle_rules()
803
813
  return self.results
804
814
 
805
815
 
816
+ def board_responder_verdict(wk: Any, kit: Optional[str] = None) -> Tuple[str, str]:
817
+ """board.responder: NOT_RUN when the well-known document declares no fabric-project-comms revision; FAIL when it
818
+ declares one this kit does not read or the kit's fixture conversation finds a problem; PASS otherwise. `kit` is the
819
+ service's own copy (a .py or .mjs path); by default the kit beside this probe."""
820
+ protocols = wk.get("protocols") if isinstance(wk, dict) else None
821
+ declared = [p for p in protocols if isinstance(p, str) and p.startswith("fabric-project-comms/")] if isinstance(protocols, list) else []
822
+ if not declared:
823
+ return "NOT_RUN", "the service declares no board participation (no fabric-project-comms revision in protocols)"
824
+ accepted = fs.accepted_revisions("fabric-project-comms")
825
+ unread = [p for p in declared if "error" in fs.negotiate_revision(p, accepted)]
826
+ if unread:
827
+ return "FAIL", "declares %s, which this kit does not read (it reads %s)" % (", ".join(unread), ", ".join(accepted))
828
+ try:
829
+ if kit and kit.endswith(".mjs"):
830
+ if not shutil.which("node"):
831
+ return "NOT_RUN", "node is not installed to run %s's fixture conversation" % kit
832
+ url = Path(kit).resolve().as_uri()
833
+ out = subprocess.run(["node", "--input-type=module", "-e",
834
+ "import(process.env.FABRIC_BOARD_KIT).then(async (m) => console.log(JSON.stringify(await m.selfCheck())))"],
835
+ env=dict(os.environ, FABRIC_BOARD_KIT=url), capture_output=True, text=True, timeout=60)
836
+ problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the Node kit did not run: %s" % out.stderr.strip()[:200]]
837
+ else:
838
+ import importlib.util
839
+ path = Path(kit) if kit else Path(__file__).resolve().parent / "fabric_board.py"
840
+ spec = importlib.util.spec_from_file_location("fabric_board_under_test", str(path))
841
+ module = importlib.util.module_from_spec(spec)
842
+ sys.path.insert(0, str(path.resolve().parent))
843
+ spec.loader.exec_module(module) # type: ignore[union-attr]
844
+ problems = module.self_check()
845
+ except Exception as exc: # noqa: BLE001 - a kit that cannot run its conversation fails the rule
846
+ return "FAIL", "the kit's fixture conversation did not run: %s" % exc.__class__.__name__
847
+ if problems:
848
+ return "FAIL", "; ".join(problems)
849
+ return "PASS", "declares %s; the kit%s answers the fixture conversation (whole, fenced, no epoch)" % (
850
+ ", ".join(declared), " at %s" % kit if kit else " beside this probe")
851
+
852
+
806
853
  def session_lifetime_problem(set_cookie: str, now: Optional[float] = None) -> Optional[str]:
807
854
  """DEC-0039: a session cookie set at sign-in lives at most 7 days. Max-Age wins over Expires (RFC 6265
808
855
  §5.3); a cookie with neither ends with the browser session. None when it is within the bound."""
@@ -880,6 +927,16 @@ def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
880
927
  known = sum(m["costUsd"] for m in models if isinstance(m.get("costUsd"), (int, float)))
881
928
  if isinstance(day.get("costUsd"), (int, float)) and abs(day["costUsd"] - known) > 1e-6:
882
929
  out.append("%s: costUsd is not the sum of its models' known costs" % at)
930
+ for j, m in enumerate(models):
931
+ # DEC-0046, DEC-0061: a row's route is a login in its vendor's runner, an issued key, or the platform's
932
+ # own metered models (a listing in the cloud).
933
+ if "route" in m and m["route"] not in fs.USAGE_ROUTES:
934
+ out.append("%s.byModel[%d]: route %r is not subscription, key or platform-metered" % (at, j, m["route"]))
935
+ # DEC-0065: the window and the project attribution, as the contract's usageRules reads them (FAC-SEM-025).
936
+ for finding in fs.usage_findings(report):
937
+ path, message = finding["instancePath"], finding["message"]
938
+ if path.startswith(("/byProject", "/generatedAt")) or "generatedAt" in message or "exists in its month" in message:
939
+ out.append("%s: %s" % (path or "/", message))
883
940
  return out
884
941
 
885
942
 
@@ -912,6 +969,7 @@ def main(argv: Optional[List[str]] = None) -> int:
912
969
  parser.add_argument("--skip-login", action="store_true")
913
970
  parser.add_argument("--ca-file", help="remote placement: trust this CA bundle instead of the system store (tests)")
914
971
  parser.add_argument("--connect", help="remote placement: dial HOST:PORT while speaking TLS to the origin's name (tests)")
972
+ parser.add_argument("--board-kit", help="board.responder: the service's own fabric_board.py or fabric-board.mjs")
915
973
  args = parser.parse_args(argv)
916
974
  if not args.target and not args.descriptor:
917
975
  parser.print_usage(sys.stderr)
@@ -923,7 +981,7 @@ def main(argv: Optional[List[str]] = None) -> int:
923
981
  except (OSError, ValueError) as exc:
924
982
  print("No readable descriptor at %s: %s" % (path, exc), file=sys.stderr)
925
983
  return 1
926
- results = Probe(path, descriptor, services_dir, args.skip_login, args.ca_file, args.connect).run()
984
+ results = Probe(path, descriptor, services_dir, args.skip_login, args.ca_file, args.connect, args.board_kit).run()
927
985
  failed = sum(r["verdict"] == "FAIL" for r in results)
928
986
  if args.json:
929
987
  print(json.dumps({"descriptor": str(path), "results": results, "failed": failed}, indent=2))