@passioncode-ai/fabric-agent-adapter 0.58.0 → 0.60.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 (26) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -2
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +4 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +27 -2
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +16 -19
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +35 -13
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -1
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -2
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +9 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +80 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +6 -1
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +11 -3
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +80 -4
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +14 -6
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +187 -7
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +2 -2
  20. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +19 -7
  21. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +4 -1
  22. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +27 -2
  23. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +23 -1
  24. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +5 -5
  25. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +21 -7
  26. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,102 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.60.0 (2026-11-03)
4
+
5
+ The board responder is ready to find a slot the operator moves back to it, once Fabric offers the move (FAA-165), and four smaller fixes: working-with-fabric
6
+ reads a filed row's answer (FAA-169) and says when an accepted request is left behind by a move (FAA-172), `pack`
7
+ names why a provider manifest is refused (FAA-193), and Python's package kit refuses `NaN` and `Infinity` as Node's does.
8
+
9
+ ### Fixed
10
+
11
+ - A slot the operator moved to another session and then back to this one (contract DEC-0140 PR-2, once Fabric offers
12
+ the move) would be held but never claimed or renewed: the responder skipped a `moved` slot until `take_back`, so its lease lapsed. Now, while a slot
13
+ is moved, the responder reads `com.status` once a renewal (20 s) and, when the slot's generation is past the recorded
14
+ move, sends one `com.claim` under a fresh key, as PR-2's "Learning the slot is its own" says. A claim that lands
15
+ holds the slot again (the record loses `moved`, the request it handed out is worked, or its own enroll renews the
16
+ lease at once; a later move is said again); `lease_expired` enrolls again; `held` asks at the next renewal;
17
+ `idempotency_window_expired` or `capacity_exceeded` asks again at the next renewal under a fresh key, recording
18
+ nothing; `fenced` or another refusal that is not the board session's records the new generation, so the same move is not asked about
19
+ twice. A recorded move drops the capability's claim key, so a claim answer lost before the move is never replayed, and a
20
+ lost answer to the move-back claim itself is asked again under its key, counting as a lease from its send meanwhile (while more than the 10 s margin of that lease is left)
21
+ (a replay from an older generation is dropped); `not_authorized` counts as another session's. It never enrolls or replaces a
22
+ slot another session holds, and waits while other work runs. Both kits (`fabric_board_slots.py`,
23
+ `fabric_board_session.py` and their Node twins). The `board.slot.moved` notice and references/board.md say so (FAA-165).
24
+ - `pack`, `sign` and `verify` refused a provider manifest whose `costPresets` differ from the package's with only
25
+ `provider-manifest` as the message; the message now names both causes (the `problem` code is unchanged) (not a JSON object, or `costPresets` that differ). Both kits (FAA-193, found by the
26
+ FAA-124 pilot rehearsal). Python no longer refuses an empty provider manifest (`{}`), as Node never did (FAA-194 (1)).
27
+ - Python's `pack`, `sign` and `verify` took `NaN`, `Infinity` and `-Infinity` in a plugin or provider manifest, a key file
28
+ or a trust file, which `JSON.parse` and the contract refuse, so a package the Node kit refused could pass Python's
29
+ `verify`. Python refuses them now too (older than 0.60.0, since 0.31.0; found by the 0.60.0 seam reading 11).
30
+
31
+ ### Changed
32
+
33
+ - working-with-fabric Step 6 reads `fabric_task_create`'s answer as Step 4 item 2 does: while your single live claim is held, Fabric
34
+ 0.3.14 links what is filed as spawned from it, so an unrelated row is filed after the release, or the person is told
35
+ it is linked (FAA-169). A scenario eval `a-backlog-row-filed-while-a-claim-is-held`.
36
+ - working-with-fabric references/project-board.md, "When nobody answers": an `active` responder does not prove a request
37
+ is worked; one the old holder accepted before its slot moved stays `accepted` with nobody on it, and a requester
38
+ cannot see that (`com.get` shows no attempt), so it asks on the thread; with no answer it cancels the request while
39
+ its `effect` is `not_started` and goes on only once the cancel's receipt shows `cancelled`. An accepted request is
40
+ never adapted by hand and never expires (FAA-172; question to Fabric and the contract on board thread `ab12642e`). A
41
+ scenario eval `an-accepted-request-left-behind-by-a-move`.
42
+ - building-fabric-services SKILL.md: a moved slot stays moved until `take_back` or a move back once Fabric offers one.
43
+ - The reference service's take-back tool now reads "Take back a board slot moved to another session."; a manifest
44
+ registered before 0.60.0 keeps the old description until it is registered again.
45
+
46
+ ### Upgrading
47
+
48
+ - No Fabric release offers the operator's move of a slot yet (Fabric 0.3.15 has none): today a moved slot is
49
+ another session's taking it, and take-back is the way back; the move-back check is ready for when Fabric ships it.
50
+ - A kit service gets the move back once it installs the 0.60.0 kit modules (`fabric_board_slots.py`,
51
+ `fabric-board-slots.mjs` and their callers). Only the running session finds a move back: a slot moved back to a
52
+ session that has since ended needs take-back, since the session's end released the slot and the operator cannot move
53
+ a slot no session holds (a move is possible only once another session holds it). A kit older than 0.60.0 never finds
54
+ a move back: it needs take-back (any kit from 0.53.0), or, once Fabric offers the move, the 0.60.0 modules and then a move to the new session.
55
+
56
+ ## 0.59.0 (2026-11-03)
57
+
58
+ Portable from day 0 (FAA-191, RM-36): on top of 0.58.0's source and instance, every new agent gets a data home, a
59
+ data kind and an uptime category, and a new private agent a `LICENSE` naming its copyright holder, from its first commit, and runs the transfer
60
+ audit at every release until the contract's export (CT-194) replaces it.
61
+
62
+ ### Added
63
+
64
+ - creating-fabric-agents' intake grill asks three more rows: **Does it run around the clock?** (uptime category A, a
65
+ cloud host from the first release, Heroku unless a reason names another, from the cloud Fabric-server template once
66
+ its repository exists; or B, a local service only), **Whose data does it keep?** (kind U, data of people outside the
67
+ owner's team, never in the source, a copy or its tests; or O; in doubt U) and **Who holds the copyright?**
68
+ - Step 2 gives the data a home of its own, `<agent>-data` (a folder beside the checkout or a private repository of
69
+ that name), and the README names the data kind and the uptime category; an agent someone builds for themselves gets
70
+ a `LICENSE` naming the holder (all rights reserved unless the owner gives other terms).
71
+ - Step 5: every release runs `fabric_source_check.py check` as the transfer audit, in Git mode on the checkout and
72
+ with `--every-file` on a clean copy of what Git would commit (building-fabric-services' `references/package.md`).
73
+ references/source-and-instance.md adds why never the checkout (its ignored configuration and dependency folders
74
+ `--every-file` reads too), and that a `LICENSE` whose holder is on the owner's list is named in
75
+ `.fabric-source-ignore`, since authorship travels with the source.
76
+ - references/source-and-instance.md (both skills): *The data home and its kind*; references/where-agents-run.md:
77
+ *Uptime: A or B* and a checklist line. building-fabric-services' Step 0 answers carry the same (data kind, data
78
+ home, uptime, holder, the audit per release).
79
+ - Scenario evals `portable-from-day-zero` (creating-fabric-agents) and `new-service-names-data-and-uptime`
80
+ (building-fabric-services), a trigger, and `test/test_portable_from_day_zero_prose.py` pinning the prose to them.
81
+
82
+ ### Changed
83
+
84
+ - The eval `own-agent-licence-is-the-owners-choice` expects a `LICENSE` naming the holder; still no AGPL or
85
+ commercial licence files unasked. The shared operator-channel reference and building-fabric-services say the same
86
+ for an agent built new, and that the kit files the adapter copies in keep their own licence
87
+ (`AGPL-3.0-only OR LicenseRef-PassionCode-Commercial`); adapting-projects-to-fabric leaves an existing agent's
88
+ licence as it is.
89
+ - where-agents-run.md: Heroku is the default host of an A agent unless it runs in the person's or an organization's
90
+ PassionCode cloud or is sold; an A agent's data lives in a durable store, never the host's own folder (source-and-instance.md's
91
+ data home says the same for a host that does not keep its disk), and on a host with no Fabric hub it answers only
92
+ what reaches its own origin.
93
+ - building-fabric-services' gotchas are shorter (the same rules); `test_fabric_kit` runs its by-path load with `-B`.
94
+
95
+ ### Upgrading
96
+
97
+ - Nothing to change for an existing agent: an adapted project keeps its licence, data and release checks as they
98
+ are. A new one answers the three grill rows before its first commit.
99
+
3
100
  ## 0.58.0 (2026-11-03)
4
101
 
5
102
  Portable agents, part 1 (FAA-188, RM-36): an agent keeps its source apart from its instance from its first commit, and
package/README.md CHANGED
@@ -72,7 +72,7 @@ requests other Projects post to its Project on Fabric's project board uses the b
72
72
  (`scripts/fabric_hub.py` and `scripts/fabric_board.py`, Node `fabric-hub.mjs` and `fabric-board.mjs`): the
73
73
  `board.respond` grant, enroll, claim, accept the exact digest, complete, a checkpoint before every step, and restarts
74
74
  that reconcile or take accepted work back by a transfer, unless the slot was moved to another session (until the
75
- operator's take-back) (the [board reference](plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md)):
75
+ operator's take-back, or a move back once Fabric offers one) (the [board reference](plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md)):
76
76
 
77
77
  ```bash
78
78
  python3 plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py example-agent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.58.0",
3
+ "version": "0.60.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.58.0",
6
+ "version": "0.60.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.58.0"
8
+ version: "0.60.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "3830a3353923e20f826d466610e1c479f120bec0"
11
11
  ---
@@ -242,7 +242,8 @@ relayed, DEC-0121). An agent with neither stops nor notify events skips this ste
242
242
  repository standard (`knowledge/repository-standard.md`) from its first commit: the licence files,
243
243
  the README quick start verified with a real client, `AGENTS.md` and `CLAUDE.md`, listed in the
244
244
  shared reference above. An agent someone builds for themselves is not a PassionCode.ai repository:
245
- its licence is its owner's choice and none of those files is required of it.
245
+ its licence is its owner's choice and none of those files is required of it (an agent built new with
246
+ creating-fabric-agents carries a `LICENSE` naming its holder; an existing one keeps what it has).
246
247
 
247
248
  ## Completion format
248
249
 
@@ -63,4 +63,7 @@ the project).
63
63
  org-index `scripts/check_format.py` reports 0 findings for it before it is called done. An agent
64
64
  someone builds for themselves is not a PassionCode.ai repository: its licence is its owner's
65
65
  choice, nothing about it is published or listed by the organization, and none of these files is
66
- required of it, though the verified MCP quick start is still how anyone learns to drive it.
66
+ required of it, except that an agent built new carries a `LICENSE` naming the copyright holder (all rights reserved
67
+ unless the owner gives other terms), as does a new private agent built for a client; files the adapter copies in (the service kit, the dashboard kit) keep their own
68
+ licence, `AGPL-3.0-only OR LicenseRef-PassionCode-Commercial` ([COMMERCIAL-LICENSE.md](https://github.com/passioncode-ai/fabric-agent-adapter/blob/main/COMMERCIAL-LICENSE.md)).
69
+ The verified MCP quick start is still how anyone learns to drive it.
@@ -13,6 +13,7 @@ on it.
13
13
  ## Contents
14
14
 
15
15
  - [The layout](#the-layout)
16
+ - [The data home and its kind](#the-data-home-and-its-kind)
16
17
  - [The check](#the-check)
17
18
 
18
19
  ## The layout
@@ -31,7 +32,7 @@ on it.
31
32
  (an organization's) is a sibling instance, `<id>.<instance>`, never a folder inside the personal one
32
33
  (`references/estate-record.md` of that skill).
33
34
  - **Ignore the instance in the source**: `.gitignore` names the real configuration (`config/*.json` with
34
- `!config/*.example.json`), an instance folder that sits inside the checkout during development, and what running
35
+ `!config/*.example.json`), an instance folder an existing project still keeps inside the checkout, and what running
35
36
  the agent leaves (`__pycache__/`, `*.pyc`), which the check would otherwise report as binary in Git mode.
36
37
  - **State documents are instance data.** A handoff, a decision log or a backlog about this codebase is source
37
38
  documentation; a record of what this installation did (runs, conversations, the owner's notes, a journal) lives in
@@ -47,6 +48,27 @@ on it.
47
48
  keeps the scaffold's sentinel, and a person's name or account is never written there. The adapter moves both when the
48
49
  contract decides how an instance renames them (FAA-188 part 2).
49
50
 
51
+ ## The data home and its kind
52
+
53
+ - **The data has a home of its own from the first commit: `<agent>-data`.** For an agent that is not a kit service it
54
+ is a folder beside the checkout, never inside it, and `<AGENT>_DATA_DIR` names it (its default is
55
+ `../<agent>-data`); a kit service keeps the kit's folders above. When the owner keeps the data in Git, it goes to a
56
+ separate private repository named `<agent>-data`, never to the source's repository. On a cloud host that does not
57
+ keep its disk across a restart (Heroku and the like, an uptime A agent's default), the data home is a durable store
58
+ the same variable names (a database, an object store, that private repository); the folder and the kit's folders
59
+ serve a machine whose disk persists. A copy of the agent starts with an empty home of its own and never connects
60
+ to the original's.
61
+ - **The README names the kind of the data.** **U**: the agent keeps data of people outside the owner's team
62
+ (messages, support requests, reviews, customers' answers); it never enters the source, a copy or its tests, and the
63
+ source holds only the schema and synthetic fixtures (its real-trace corpus and replay samples live in the data
64
+ home). **O**: it keeps none (a tool or service for other agents, the
65
+ owner's reports, runs and media); its working data still goes to the data home. When in doubt, the kind is U.
66
+ - **Every release runs the transfer audit.** `fabric_source_check.py check <project-root>` in Git mode, and
67
+ `check <clean-copy> --every-file` on a clean copy of what Git would commit (`git ls-files --cached --others
68
+ --exclude-standard`, as building-fabric-services' `references/package.md` makes it; never the checkout itself, whose ignored
69
+ configuration and dependency folders `--every-file` reads too), each exit 0, with the owner's list or its absence recorded, until the contract's export (CT-194)
70
+ replaces it.
71
+
50
72
  ## The check
51
73
 
52
74
  `fabric_source_check.py check` reads the files the source would carry (what Git would commit: tracked files and
@@ -124,7 +146,10 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/fabric_source_ch
124
146
  repository is not read; `--every-file` and a folder outside Git read its files, its `.git` left out. An exception line (`!`) is refused, since what it would
125
147
  bring back would go unread. Name the files, or a folder that holds
126
148
  only synthetic vectors, never a whole source folder (a nested repository checked in its own tree is the one
127
- exception), and say why on the line above. A file `pack` refuses by its name cannot ship in a package: a public
149
+ exception), and say why on the line above. The `LICENSE` is the other: its copyright line names the holder, which is
150
+ authorship and travels with the source, so when the holder is on the owner's list the licence file is named in the ignore
151
+ file by its own path (`LICENSE`, `LICENSE.md`) with that reason (`# the copyright holder is authorship and travels with the source`); no other file of the
152
+ owner's is. A file `pack` refuses by its name cannot ship in a package: a public
128
153
  certificate is renamed `*.crt`; a project `.npmrc` that holds no credential may be named in the ignore file, with
129
154
  that reason, for the source check (the line then hides whatever is added to it later, so keep it credential-free),
130
155
  and `pack` still refuses it, so a package is made from a copy without it (its
@@ -14,7 +14,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
14
14
  compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
15
15
  metadata:
16
16
  author: PassionCode.ai
17
- version: "0.58.0"
17
+ version: "0.60.0"
18
18
  contract-version: "0.1.0"
19
19
  extension: "fabric-service/0.1"
20
20
  extension-commit: "3830a3353923e20f826d466610e1c479f120bec0"
@@ -63,6 +63,7 @@ Answer before writing code, and record the answers in the project README:
63
63
  | Port | one number; run the claim check below before choosing it |
64
64
  | Who calls it | the named agents or people, and through which surface (Step 1) |
65
65
  | What it stores | the store, where it lives (Step 2, rule 5), and what survives uninstall |
66
+ | Data and uptime | kind U or O, kit folders, a durable store or `<id>-data`, uptime A or B; a new agent's `LICENSE` holder, transfer audit per release (creating-fabric-agents) |
66
67
  | What it tells the operator | the events worth a notification (Step 5) |
67
68
 
68
69
  ## Step 1: choose the surfaces
@@ -102,9 +103,9 @@ When the service answers requests other Projects post to its Project on Fabric's
102
103
  responder in its own process: [`scripts/fabric_hub.py`](scripts/fabric_hub.py) finds the hub and joins,
103
104
  [`scripts/fabric_board.py`](scripts/fabric_board.py) enrolls, claims, accepts the exact digest, checkpoints before
104
105
  every step and stops on `fenced`, `lease_expired` or `authority_changed` (Node: `fabric-hub.mjs`, `fabric-board.mjs`).
105
- The request body is data behind the service's own input gate. From 0.53.0 a slot the operator moved to another
106
- session stays moved until `take_back` (`takeBack`): give it an operator action with its tool and gate `relayed`, or it
107
- stays moved for good (the operator reaches it through an agent that relays their decision in `_meta`, the service's
106
+ The request body is data behind the service's own input gate. A slot moved to another
107
+ session stays moved until `take_back` (`takeBack`) or a move back once Fabric offers one: give it an operator action with its tool and gate
108
+ `relayed` (the operator reaches it through an agent that relays their decision in `_meta`, the service's
108
109
  page or its elicitation; Claude Code and Codex wait for CT-125's forwarding). Read [the board reference](references/board.md).
109
110
 
110
111
  ## Step 2: the non-negotiables
@@ -353,9 +354,9 @@ first commit:
353
354
 
354
355
  Whoever owns the repository, an agent with a page gets the dashboard rule block in its `AGENTS.md`
355
356
  (below). org-index `scripts/check_format.py` reports 0 findings for it before it is called done. **An agent
356
- someone builds for themselves is not a PassionCode.ai repository:** its licence is its owner's
357
- choice, nothing about it is published or listed by the organization, and none of these files is
358
- required of it, though the verified MCP quick start is still how anyone learns to drive it.
357
+ someone builds for themselves is not a PassionCode.ai repository:** its licence is its owner's choice, the organization
358
+ publishes and lists nothing about it, of these files one built new needs only a `LICENSE` naming its copyright holder (copied kit files keep
359
+ theirs), and the verified MCP quick start teaches anyone to drive it.
359
360
 
360
361
  ## Online services: the remote placement
361
362
 
@@ -418,20 +419,16 @@ this estate needs.
418
419
  ## Gotchas
419
420
 
420
421
  - `lsof` shows `*:<port>` or `0.0.0.0` → the bind is wrong even if the Host check works.
421
- - A health probe that only checks HTTP 200 accepts any program on the port. Compare
422
- `service.id` and `service.instance`: two services here once defaulted to ports
423
- another service already held.
424
- - `launchctl bootstrap` right after `bootout` fails with I/O error 5 until the unload
425
- finishes; wait, then retry.
422
+ - A health probe that only checks HTTP 200 accepts any program on the port: compare
423
+ `service.id` and `service.instance`.
424
+ - `launchctl bootstrap` right after `bootout` fails with I/O error 5: wait for the unload, then retry.
426
425
  - Under launchd `PATH` is bare. Pin the interpreter by absolute path; set `PATH`
427
426
  explicitly in the plist; never copy the whole shell environment into it.
428
- - A plist pointing at a worktree or a checkout breaks the day it moves. Point it at a
429
- release.
430
- - `http.server.HTTPServer` asks the resolver for its FQDN between `bind()` and
431
- `listen()`. With a slow resolver (a macOS CI runner, a Mac offline) the port is bound
432
- but silent: connects time out instead of being refused. Use `fs.LoopbackHTTPServer`.
433
- - `logging.basicConfig` called twice: the second call is a no-op, so a server's own
434
- log file stays empty. Configure logging once, in the entry point.
427
+ - A plist pointing at a worktree or checkout breaks when it moves: point it at a release.
428
+ - `http.server.HTTPServer` resolves its FQDN between `bind()` and `listen()`: with a slow
429
+ resolver (a macOS CI runner, a Mac offline) the port is bound but silent. Use `fs.LoopbackHTTPServer`.
430
+ - `logging.basicConfig` called twice: the second is a no-op, the server's log file
431
+ stays empty. Configure logging once, in the entry point.
435
432
  - Hand-made `.plist.bak` files in `~/Library/LaunchAgents` are never cleaned up; keep
436
433
  backups elsewhere.
437
434
 
@@ -120,7 +120,7 @@ once the kit reads it (FAA-135).
120
120
  |---|---|
121
121
  | `fenced` | the attempt is another responder's: stop the handler, drop the entry (nothing outside had begun); accepted work on a slot this responder recorded holding stays as `lapsed`, and the next tick asks `com.status` whether the slot [moved](#when-the-operator-moves-a-slot-to-another-session-faa-155) |
122
122
  | `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 |
123
- | `authority_changed`, `not_authorized` | stop claiming; one `board.access.needed` event (notify) saying what to ask for |
123
+ | `authority_changed`, `not_authorized` | stop claiming; one `board.access.needed` event (notify) saying what to ask for (except `not_authorized` on the move-back claim of a moved slot, read as another session's slot, [below](#when-the-operator-moves-a-slot-to-another-session-faa-155)) |
124
124
  | `fenced` on enroll or claim | another live holder: wait for it to lapse; never replace it on its own initiative. When this responder recorded holding the slot, it first reads `com.status`: a newer generation means the slot [moved](#when-the-operator-moves-a-slot-to-another-session-faa-155), and it is not taken again even after that holder lapses |
125
125
  | lost after an effect began | keep the entry as `unknown`; `observe(entry)` is asked to settle it with `com.reconcile` |
126
126
  | Fabric down, hub missing | bounded backoff (2 s doubling to 5 minutes, jittered), one `board.down` event, no spinning |
@@ -134,7 +134,7 @@ the target Project's participant reply answered it, DEC-0098 and DEC-0102; not s
134
134
  it, but no longer this responder's); `in_progress` and `outcome_unknown` are
135
135
  reconciled from what `observe(entry)` answers (observed only; with no observation they stay unknown and notify);
136
136
  accepted ones go back to this responder with `com.responder_replace {expectedGeneration, transfer}` (unless the slot
137
- moved to another session meanwhile: they stay `lapsed` until take-back, [below](#when-the-operator-moves-a-slot-to-another-session-faa-155)), whose receipt's
137
+ moved to another session meanwhile: they stay `lapsed` until take-back or a move back to this session, [below](#when-the-operator-moves-a-slot-to-another-session-faa-155)), whose receipt's
138
138
  `transferred` (DEC-0078: `{request, revision, attempt}`) carries the new fence. **A Fabric that does not answer
139
139
  `transferred` yet leaves the request held** (`board.request.held`, notify): the kit never claims it back, because the
140
140
  contract has no `com.claim` read-back and `com.claim` never moves an accepted request. A known outcome is sent after the
@@ -181,7 +181,7 @@ contract's matrix, held to its vectors (`test/fixtures/contract/comms-hold-vecto
181
181
  | Refused `held` | What the kit does |
182
182
  |---|---|
183
183
  | `com.status` names the slot `held: true` at connect | the slot is not enrolled; it pauses |
184
- | `com.enroll`, `com.claim`, `com.responder_replace` | the slot pauses: asked again after 15 s, then 30 s, doubling to 15 minutes; one `board.slot.held` event per capability, and `board.slot.released` when an enroll succeeds again |
184
+ | `com.enroll`, `com.claim`, `com.responder_replace` | the slot pauses: asked again after 15 s, then 30 s, doubling to 15 minutes; one `board.slot.held` event per capability, and `board.slot.released` when an enroll succeeds again (except the move-back claim of a moved slot, which asks again at the next renewal with no `board.slot.held`, [below](#when-the-operator-moves-a-slot-to-another-session-faa-155)) |
185
185
  | `com.accept` (the hold came after the claim) | nothing was done: the entry is dropped, the claim lapses and the request waits on the board (`board.request.waiting`); the slot pauses |
186
186
  | `com.effect_begin` | the effect has not begun: the handler waits (same idempotency key, the pause doubling) while the tick keeps renewing the lease, then begins once the hold is lifted; `ctx.stopped` ends the wait |
187
187
  | accepted work at restart under a hold | kept as `lapsed`, then taken back with `com.responder_replace` and a transfer once the hold is lifted, never claimed back |
@@ -194,7 +194,7 @@ whose responder was gone. Read it as the owner's attestation, not the responder'
194
194
 
195
195
  ## When the operator moves a slot to another session (FAA-155)
196
196
 
197
- The contract's DEC-0140 PR-2 (proposed, after this kit's pin; Fabric plans it for 0.3.15) lets the operator move a
197
+ The contract's DEC-0140 PR-2 (accepted 2026-10-30, after this kit's pin) lets the operator move a
198
198
  (Project, capability) slot to another live session of the Project: one transition, as `com.responder_replace` would make
199
199
  it for the receiving session (generation up, `claimed` back to `queued`, `in_progress` to `outcome_unknown`, `accepted`
200
200
  held and not transferred). The responder keeps that move instead of undoing it:
@@ -228,17 +228,18 @@ held and not transferred). The responder keeps that move instead of undoing it:
228
228
  this service left unanswered, is a move, whatever made it: the operator, or another session that took the slot while
229
229
  this one was away or after it crashed. Before 0.53.0 a restarted responder took such a slot back by itself. So a
230
230
  second responder of the same capability is no longer a standby once the slot changed hands: after any change of
231
- holder it waits for take-back, not for the other's lapse.
231
+ holder it waits for take-back or a move back to its session, not for the other's lapse.
232
232
  - A `fenced` refusal on accepted work while the slot did not move (the generation unchanged) keeps the entry
233
233
  `lapsed`; the next tick takes it back with a transfer and goes on (before 0.53.0 the entry was dropped and the
234
234
  request left accepted on the board).
235
235
  - Then the slot is `moved`: the running handler is told to stop (`ctx.stopped`); a `claimed` entry is dropped (the move
236
236
  returned it to the queue); accepted work stays as `lapsed` and work whose effect began as `unknown` (only the slot's
237
- holder, or the estate owner, reconciles it now). No `com.claim`, `com.renew`, `com.complete`, `com.enroll` or
238
- `com.responder_replace` is sent for that capability again: not at recovery after a restart, and not when the new
237
+ holder, or the estate owner, reconciles it now). While the slot is moved nothing but the move-back check
238
+ (`com.status` once a renewal, then one `com.claim`, below) is sent for that capability: no `com.renew`,
239
+ `com.complete`, `com.enroll` or `com.responder_replace`, not at recovery after a restart, and not when the new
239
240
  holder's lease lapses, nor when com.status later names no generation for the slot (a recorded move stays until
240
- take-back). Accepted requests stay `accepted` on the board, unanswered, until the slot is taken back (CT-175
241
- will hand them to the new holder). One `board.slot.moved` event (warning, notify) says so, once, also across restarts
241
+ take-back or a move back). Accepted requests stay `accepted` on the board, unanswered, until the slot is taken back
242
+ or moved back (CT-175 will hand them to the new holder). One `board.slot.moved` event (warning, notify) says so, once, also across restarts
242
243
  (the record keeps `moved`).
243
244
  - A responder that never held the slot (a first start: no record, or a record of another Project), or whose record
244
245
  equals the current generation, behaves as before: it enrolls, recovers and replaces its own lapsed work. A recorded or
@@ -248,7 +249,29 @@ held and not transferred). The responder keeps that move instead of undoing it:
248
249
  records its own generation. Only a replay the board refuses, or one that ran for the first time (`repeated: false`)
249
250
  after the slot had moved past the record, reads as a move: the safe side; a slot such a replay took is left to lapse
250
251
  unused. A generation written `2.0` is `2`.
251
- - Only the operator brings the slot back: `take_back(capability)` (`takeBack`) forgets the record, and wins over a move
252
+ - The operator may move the slot back to this session in Fabric (DEC-0140 PR-2), which this client cannot hear. No
253
+ Fabric release offers the operator's move yet (Fabric 0.3.15 has none: the control is CT-174's in Fabric, its agent twin CT-181 in the contract, then CT-125),
254
+ so today every move is another session taking the slot and take-back is the route back; the check below is ready
255
+ for the move. While
256
+ a slot is moved the responder reads `com.status` once a renewal (20 s) and, when the slot's generation is past the
257
+ recorded move, sends one `com.claim` under a fresh key, as PR-2's "Learning the slot is its own" says (FAA-165,
258
+ 0.60.0); the move itself drops the claim key of that capability, whose receipt the move made dead. A claim that
259
+ lands is the slot held again: the record loses `moved`, the request it handed out is worked (with none, its own
260
+ enroll renews the lease at once), accepted work left `lapsed` comes back with a transfer, and a later move is said
261
+ again. `lease_expired` (the move reached this session and its lease lapsed first) enrolls again. `held` asks again
262
+ at the next renewal. `idempotency_window_expired` or `capacity_exceeded` asks again at the next renewal under a
263
+ fresh key, recording nothing. `fenced`, or any other refusal that is not the board session's (`not_authorized` counted with them, since a
264
+ non-holder's claim may be refused so), is another session's slot:
265
+ recorded as `moved` at that generation, so it is not asked about twice. A claim whose answer was lost (`unreachable`) is asked again under the same key at the next renewal, so a claim that
266
+ landed is not stranded; until then it counts as at most a lease from that key's first send (a fresh key starts
267
+ its own, and it counts only while more than the 10 s margin of that lease is left, then no more, as a lapsed slot), so a second lost answer keeps
268
+ the session (finding 38). A replayed receipt from a generation older than `com.status` shows (a move away and back meanwhile) is
269
+ dead: its key is dropped and a fresh key asks at the next renewal. A take-back drops the capability's claim key too, so a request a move-back claim handed out while the take-back raced
270
+ it waits for its attempt's lease (60 s) before it is claimed again (a take-back that lands before the move-back check makes its claim key
271
+ leaves that key to the claim loop, which works the request at once). Nothing is asked while the responder stops. It is never an enroll or a replace on a slot another session holds, and it waits
272
+ while other work runs: a move back during a job longer than the lease (60 s) may lapse and be lost to another
273
+ session, after which take-back, or another move back to this session, brings it back.
274
+ - Otherwise only the operator brings the slot back: `take_back(capability)` (`takeBack`) forgets the record, and wins over a move
252
275
  being read at the same time: the move is written under the responder's lock (Node: on the fence queue, after any fenced
253
276
  call in flight) and only while the record is unchanged (FAA-158). Never edit the
254
277
  checkpoint's `slots` record by hand to undo a move. In the same board
@@ -269,9 +292,8 @@ held and not transferred). The responder keeps that move instead of undoing it:
269
292
  tool](../SKILL.md), `fabric-actions/0.1`) with gate `relayed` (the operator's decision), never `none`, so a call
270
293
  without the operator's relayed decision is refused; on a server that reads mandates (`agent_id`) it also names its effect class, for example
271
294
  `change-policy` (DEC-0125); the operator reaches it through an agent that relays their decision in `_meta`, the
272
- service's page or its elicitation (Claude Code and Codex, which cannot set `_meta`, wait for CT-125's forwarding), and the service names it in its docs; otherwise a moved slot stays moved. A move back to this service's own session is not
273
- detected yet: the receiving side and the hand-over of accepted requests (contract CT-175) wait for Fabric's session
274
- surface.
295
+ service's page or its elicitation (Claude Code and Codex, which cannot set `_meta`, wait for CT-125's forwarding), and the service names it in its docs; otherwise a moved slot stays moved for good today (no Fabric release offers the operator's move back, above). The
296
+ hand-over of accepted requests to another session (contract CT-175) waits for Fabric's session surface.
275
297
 
276
298
  ## What a board session may call
277
299
 
@@ -87,7 +87,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
87
87
  | L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
88
88
  | L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
89
89
  | L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data |
90
- | L4 | `fabric_board_slots` | the responder's slot records: the generation held, a move and its take-back, one capability's refusal retried alone (FAA-163) |
90
+ | L4 | `fabric_board_slots` | the responder's slot records: the generation held, a move, its take-back and a move back (FAA-165), one capability's refusal retried alone (FAA-163) |
91
91
  | L4 | `fabric_board_session` | the board responder: the work context, the fenced request, transfer and recovery |
92
92
  | L4 | `fabric_board_fixture` | the scripted board the probe runs a responder against |
93
93
  | L4 | `fabric_board` | the facade of the board connector ([board](board.md)) |
@@ -75,9 +75,14 @@ see a swap made and undone between two of those checks. Pack from a folder only
75
75
  Then every problem is listed, each with its `rule`: `schema` (the contract's schema), `FAC-SEM-064` (whole: its own
76
76
  digest, each path once, every named file listed with the same SHA-256), `FAC-SEM-065` (no secret), `FAC-SEM-066`
77
77
  (`agentRef` derived), `FAC-SEM-102` (one role, one server) and `folder` (`file-missing`, `file-digest`, `file-unlisted`,
78
- `plugin-manifest`, `provider-manifest`) and `path` (a listed or folder path holding a control character, U+2028 or
78
+ `plugin-manifest`, `provider-manifest`, whose message names its two possible causes: a provider manifest that is not a JSON object,
79
+ or `costPresets` that differ from the package's, FAA-193) and `path` (a listed or folder path holding a control character, U+2028 or
79
80
  U+2029, a `..` segment, a backslash, `//` or a colon, refused by the kit itself whatever the schema's pattern lets
80
- through, FAA-78). A JSON document is read as strict UTF-8 and nested at most 256 levels; a folder entry the kit cannot
81
+ through, FAA-78). `fabric.package.json`, the trust file and a JWK key file take no `NaN` or `Infinity` token (a number too large for a double still reads as an infinity, in both kits as in `JSON.parse`), and the first two are read as strict UTF-8 and nested at most 256 levels; the plugin and
82
+ provider manifests the folder check reads are strict UTF-8 too (the contract's `checkPackageFolder` decodes them with
83
+ replacement characters, so an invalid byte inside a string value the check does not compare is no manifest here and is accepted there, FAA-194 (29)), take no `NaN` or `Infinity` token either, as `JSON.parse` takes none, and have no depth
84
+ limit of their own, as the contract's reference has none (Python 3.9's own recursion limit reads one nested about 990
85
+ levels deep as no manifest, FAA-194 (27)); a folder entry the kit cannot
81
86
  read is refused as `Cannot read <path>`, and a link, FIFO or other non-regular file is never followed. With a problem,
82
87
  `--apply` writes nothing. Repacking drops old signatures and
83
88
  says so: a changed package is signed again.
@@ -88,6 +88,7 @@ export class Responder extends SlotKeeper {
88
88
  this.waitUntil = 0;
89
89
  this.stopping = false;
90
90
  this.claimKeys = {};
91
+ this.probing = new Set(); // moved slots whose move-back claim lost its answer: asked again under the same key
91
92
  this.said = {};
92
93
  this.rr = 0;
93
94
  this.reconcileAt = 0;
@@ -172,6 +173,7 @@ export class Responder extends SlotKeeper {
172
173
  this.state = 'connecting';
173
174
  this.slots = {}; // the previous session's slots are not this one's: a refusal now reconnects (FAA-163)
174
175
  this.confirmed = {}; // nor its confirmations: a slot may have moved between the two sessions
176
+ this.probing = new Set(); // nor its lost move-back claims
175
177
  const grant = await this.hub.joinBoard(this.project, this.credentialFile);
176
178
  const missing = this.capabilities.filter((c) => !grant.capabilities.includes(c));
177
179
  const session = this.sessionFactory(grant);
@@ -462,7 +464,9 @@ export class Responder extends SlotKeeper {
462
464
  /** Seconds this session's leases still run, by its own clock: the running attempt's, else the oldest held slot's. */
463
465
  leaseLeft() {
464
466
  const renewed = this.current ? [this.current.renewedAt ?? 0]
465
- : Object.values(this.slots).filter((s) => s.state === 'held' && s.renewedAt !== -Infinity).map((s) => s.renewedAt ?? 0); // a lapsed slot holds no lease (FAA-163)
467
+ : [...Object.values(this.slots).filter((s) => s.state === 'held' && s.renewedAt !== -Infinity).map((s) => s.renewedAt ?? 0), // a lapsed slot holds no lease (FAA-163)
468
+ ...Object.entries(this.slots).filter(([c, s]) => this.probing.has(c) && 'probeAt' in s && this.now() - s.probeAt < LEASE_SECONDS - LEASE_MARGIN_SECONDS)
469
+ .map(([, s]) => s.probeAt)]; // a move-back claim whose answer was lost holds one from its send, while more than the 10 s margin of that lease is left (FAA-165)
466
470
  return renewed.length && this.session ? Math.min(...renewed) + LEASE_SECONDS - this.now() : 0;
467
471
  }
468
472
 
@@ -528,7 +532,10 @@ export class Responder extends SlotKeeper {
528
532
  this.suspect.delete(capability);
529
533
  }
530
534
  for (const [capability, slot] of Object.entries(this.slots)) {
531
- if (slot.state === 'moved') continue; // never enrolled again on its own, even when the new holder's lease lapses (FAA-155)
535
+ if (slot.state === 'moved') { // never enrolled again on its own, even when the new holder lapses (FAA-155)
536
+ if (this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.movedBack(capability); // unless com.status shows a later generation it may hold (FAA-165)
537
+ continue;
538
+ }
532
539
  if (slot.state === 'paused') {
533
540
  // still held: a longer pause; lifted: the slot is ours again
534
541
  if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0) && !(await this.gone(capability))) await this.enroll(capability); // a move is read first
@@ -16,6 +16,8 @@ export const HOLD_RETRY_MAX_SECONDS = 900;
16
16
  export const SESSION_CODES = Object.freeze(new Set(['unreachable', 'session-ended', 'not_authorized', 'authority_changed', 'credential', 'no-epoch',
17
17
  'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available']));
18
18
 
19
+ const sameRecord = (a, b) => JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
20
+
19
21
  /** The slot methods of Responder, which owns every field they read (checkpoint, slots, project, locked). */
20
22
  export class SlotKeeper {
21
23
  // -- a slot moved to another session (FAA-155, DEC-0140 PR-2) --
@@ -127,13 +129,88 @@ export class SlotKeeper {
127
129
  this.toResume = this.toResume.filter((r) => this.checkpoint.entries[r]?.capability !== capability);
128
130
  this.slots[capability] = { state: 'moved', generation: now, renewedAt: this.now() };
129
131
  delete this.confirmed[capability];
132
+ delete this.claimKeys[capability]; // the move returned that claim to the queue: its receipt is dead
130
133
  if (!('moved' in held)) { // once, even across restarts
131
- this.emit('board.slot.moved', 'warning', `The ${capability} slot on this Project moved to another session. This service stopped answering it there and left its accepted work \`accepted\` (work begun is the new holder's to settle). Its take-back action brings the slot back once the other session lets it go, or at a later restart or reconnect while accepted work waits; a restart alone does not. Without it, ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
134
+ this.emit('board.slot.moved', 'warning', `The ${capability} slot on this Project moved to another session. This service stopped answering it; accepted work stays \`accepted\` (work begun is the new holder's). The operator brings it back by take-back (once the other session lets it go, or at a restart with accepted work waiting; a restart alone does not) or, where Fabric offers it, a move back here. Else ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
132
135
  this.note(capability, { ...held, moved: now }); // said again at the next start if it cannot be written
133
136
  }
134
137
  return true;
135
138
  }
136
139
 
140
+ /**
141
+ * A moved slot asked about once per later generation (FAA-165): the operator may move it back to this session
142
+ * (DEC-0140 PR-2), which this client cannot hear, so when com.status shows a generation past the recorded move, one
143
+ * com.claim asks whether the slot is its own. A claim that lands makes it held again and starts what it handed out; a
144
+ * lease_expired one (the move reached this session but its lease lapsed) enrolls again; a fenced one is another
145
+ * session's, recorded so the same generation is not asked about twice. Never an enroll or a replace on a slot another
146
+ * session holds: a slot moved off stays off unless it is handed back.
147
+ */
148
+ async movedBack(capability) {
149
+ if (this.current || this.stopping) return; // a claim now could hand out a second request, or start work while the responder stops
150
+ const held = this.checkpoint.slots[capability] ?? {};
151
+ if (held.project !== this.project || !isInt(held.moved, 1)) return;
152
+ if (this.slots[capability]) this.slots[capability].renewedAt = this.now();
153
+ const now = ((await this.status()).responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
154
+ if (!isInt(now, 1) || now <= held.moved) return;
155
+ if (!this.probing.has(capability) || !this.claimKeys[capability]) { // a fresh key: one from before the move may hold a dead receipt
156
+ this.claimKeys[capability] = this.key('claim');
157
+ if (this.slots[capability]) delete this.slots[capability].probeAt; // and a send time of its own
158
+ }
159
+ let answer;
160
+ const sent = this.now();
161
+ try {
162
+ answer = this.receipt('com.claim', await this.call('com.claim', { idempotency: this.idem(this.claimKeys[capability]), capability }));
163
+ } catch (e) {
164
+ if (!(e instanceof BoardError)) throw e; // not the board's answer
165
+ if (e.code === 'unreachable') { // the claim may have landed: asked again under this key, never left to the fault
166
+ this.probing.add(capability);
167
+ if (this.slots[capability] && !('probeAt' in this.slots[capability])) this.slots[capability].probeAt = sent; // its lease runs from the first send
168
+ return;
169
+ }
170
+ this.probing.delete(capability);
171
+ if (e.code === 'held') return; // the operator's hold: asked again at the next renewal
172
+ if (e.code === 'idempotency_window_expired' || e.code === 'capacity_exceeded') {
173
+ delete this.claimKeys[capability];
174
+ return;
175
+ }
176
+ if (e.code === 'lease_expired') {
177
+ delete this.claimKeys[capability]; // a dead attempt's receipt may sit under it
178
+ if (await this.back(capability, held, now)) {
179
+ this.slots[capability].renewedAt = -Infinity; // lapsed: the enroll below is recorded first
180
+ await this.enroll(capability);
181
+ }
182
+ return;
183
+ }
184
+ if (SESSION_CODES.has(e.code) && e.code !== 'not_authorized') throw e; // the board session's fault, not this slot's
185
+ // fenced, or another refusal of this claim: not this session's slot at this generation; a takeBack meanwhile wins (FAA-158)
186
+ await this.locked(() => { if (sameRecord(this.checkpoint.slots[capability], held)) this.note(capability, { ...held, moved: now }); });
187
+ return;
188
+ }
189
+ this.probing.delete(capability);
190
+ const generation = isObj(answer.attempt) && isInt(answer.attempt.generation, 1) ? answer.attempt.generation : now;
191
+ if (generation < now) { // a replay from before a later move: dead, asked again fresh
192
+ delete this.claimKeys[capability];
193
+ return;
194
+ }
195
+ if (!(await this.back(capability, held, generation))) return;
196
+ if ('request' in answer) {
197
+ delete this.claimKeys[capability];
198
+ this.slots[capability].renewedAt = this.now(); // the claim just landed: its lease runs (finding 38)
199
+ await this.start(capability, answer);
200
+ } else await this.enroll(capability); // renewed now: whether an empty com.claim renews the lease is not the contract's to say
201
+ }
202
+
203
+ /** The slot is this session's again at `generation`: held, its record rewritten without the move (FAA-165). */
204
+ back(capability, held, generation) {
205
+ return this.locked(() => {
206
+ if (!sameRecord(this.checkpoint.slots[capability], held)) return false; // a takeBack forgot it; its own next tick enrolls
207
+ this.slots[capability] = { state: 'held', generation, renewedAt: -Infinity }; // no lease yet
208
+ this.heldAt(capability, generation);
209
+ delete this.said['board.slot.moved']; // a later move is said again
210
+ return true;
211
+ });
212
+ }
213
+
137
214
  /**
138
215
  * The operator's act, never the kit's: forget that a capability's slot moved away. In the same board session the next
139
216
  * tick enrolls once the slot is free; a reconnect with held work takes it back at once by a transfer (FAA-155).
@@ -141,6 +218,8 @@ export class SlotKeeper {
141
218
  takeBack(capability) {
142
219
  if (!this.capabilities.includes(capability)) throw new BoardError('invalid_arguments', `This responder does not answer ${capability}.`);
143
220
  this.checkpoint.slot(capability, null);
221
+ delete this.claimKeys[capability]; // a receipt from before the take-back may be dead
222
+ this.probing.delete(capability);
144
223
  if (this.slots[capability]?.state === 'moved') this.slots[capability] = { state: 'contested', renewedAt: 0 };
145
224
  delete this.said['board.slot.moved'];
146
225
  }
@@ -504,12 +504,17 @@ export function compose(draft, files) {
504
504
  return { pkg, notes };
505
505
  }
506
506
 
507
+ /** What a folder problem means, where its code alone does not say what to change (FAA-193). */
508
+ const FOLDER_MESSAGES = {
509
+ 'provider-manifest': 'provider-manifest: the provider manifest must be a JSON object, and when it carries costPresets fabric.package.json must carry the same costPresets (copy them; pack does not)',
510
+ };
511
+
507
512
  function allProblems(pkg, files) {
508
513
  return [
509
514
  ...schemaProblems(pkg).map((p) => ({ ...p, rule: 'schema' })),
510
515
  ...pathProblems(pkg).map((p) => ({ ...p, rule: 'path' })),
511
516
  ...packageFindings(pkg).map((p) => ({ ...p, rule: p.code })),
512
- ...checkPackageFolder(pkg, files).map((p) => ({ rule: 'folder', problem: p.problem, instancePath: p.path, message: p.problem })),
517
+ ...checkPackageFolder(pkg, files).map((p) => ({ rule: 'folder', problem: p.problem, instancePath: p.path, message: FOLDER_MESSAGES[p.problem] ?? p.problem })),
513
518
  ];
514
519
  }
515
520