@passioncode-ai/fabric-agent-adapter 0.27.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 (19) hide show
  1. package/CHANGELOG.md +61 -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/scripts/check_service.py +50 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1104 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +337 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1274 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +397 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +43 -2
  16. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  17. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  18. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -7
  19. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
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
+
3
64
  ## 0.27.0 - 2026-10-13
4
65
 
5
66
  The usage report's window and spend by project, and OpenRouter attribution for a product's own traffic (contract
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.27.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.27.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.27.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.27.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,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."""
@@ -922,6 +969,7 @@ def main(argv: Optional[List[str]] = None) -> int:
922
969
  parser.add_argument("--skip-login", action="store_true")
923
970
  parser.add_argument("--ca-file", help="remote placement: trust this CA bundle instead of the system store (tests)")
924
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")
925
973
  args = parser.parse_args(argv)
926
974
  if not args.target and not args.descriptor:
927
975
  parser.print_usage(sys.stderr)
@@ -933,7 +981,7 @@ def main(argv: Optional[List[str]] = None) -> int:
933
981
  except (OSError, ValueError) as exc:
934
982
  print("No readable descriptor at %s: %s" % (path, exc), file=sys.stderr)
935
983
  return 1
936
- 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()
937
985
  failed = sum(r["verdict"] == "FAIL" for r in results)
938
986
  if args.json:
939
987
  print(json.dumps({"descriptor": str(path), "results": results, "failed": failed}, indent=2))