@passioncode-ai/fabric-agent-adapter 0.59.0 → 0.61.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 (35) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +2 -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 +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +93 -20
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +23 -6
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -2
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +117 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +33 -7
  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-board.mjs +1 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +10 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -8
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +24 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +3 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +6 -1
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +146 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +31 -8
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +80 -4
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +33 -25
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +19 -6
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +23 -3
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +17 -3
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +86 -7
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +14 -6
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +210 -22
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +2 -2
  32. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  33. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -27
  34. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -8
  35. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,200 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.61.0 (2026-11-04)
4
+
5
+ The board responder reads the operator's mandate from a request of his own Project (FAA-135), and working-with-fabric
6
+ says that his approval holds for the whole task, at every link (FAA-195).
7
+
8
+ ### Added
9
+
10
+ - `Responder(..., agent_id=, mandate_from=)`: with both set, a request whose sender the board verified
11
+ (`provenance: trusted`) and whose Project `mandate_from` names (a uuid in any case; or every such sender,
12
+ `mandate_from="personal-board"`, only on the personal board's responder) is read for one `mandate/1` block fenced
13
+ ` ```json `, each fence at the start of its own line with no indent, in its body (contract project-comms.md, DEC-0125 MD-3). The handler gets it as `ctx.mandate`, and
14
+ `ctx.mandate_covers(call)` answers `mandate_covers` with the sender placed as T1, the responder's wall clock (`wall=`,
15
+ `time.time` by default; Node `Date.now`, milliseconds) and the sender Project as the `caller` of a covered answer's
16
+ `recordAs`; None when no mandate travelled, and `{"fault": "now-unreadable"}` when the clock returns a value that is not a finite instant (one that raises passes its
17
+ error to the handler). A `source` in the block is dropped (OA-7). Two `mandate/1` blocks, or one whose text holds
18
+ `mandate/1` and is not JSON (an integer longer than 4300 digits included, on every Python), read as none, with a `board.request.mandate_unread` warning carrying the request id and its `reason`, never the block.
19
+ Off by default: a responder that names neither reads nothing, as before. Node's `WorkContext` serialises without the
20
+ mandate. Both kits (FAA-135). `ctx.mandate_covers` reads the call's `spendUsd` and `project` failing closed, as the
21
+ kit's MCP and job mandate readers do (FAA-139): a spend that is not a finite number is none, which a `perCallMaxUsd`
22
+ refuses, and a `project` that is not a string (in Python an exact `str`, so a subclass too: the core compares a
23
+ project by exact type, so every Python reader refuses one there; the board path marks it unreadable first), None (Node null) included, is refused by a mandate limited to
24
+ projects, as the contract's `mandateCovers` refuses a null project (the MCP and job readers read None as naming
25
+ none; leave `project` out on the board) (a draft of this release passed them as given; 0.61.0 seam reading 14). Only
26
+ the five members `mandate_covers` reads are read, each once, as the contract reads them (`call[name]` in Python; a
27
+ plain property read in Node, inherited, getter and Proxy members included), but for a Node spend, read as the call's
28
+ own member; the rest of a call is never touched (0.61.0 seam readings 16 and 17).
29
+
30
+ ### Fixed
31
+
32
+ - The Python mandate readers (McpToolServer and `action_gate`, the job step, and the core `mandate_covers` and
33
+ `action_decision` beneath them; a draft of this release's board path too) read a spend estimate that is an `int` or
34
+ `float` subclass through its own `float()` and comparisons, so a subclass whose `float()` answered 0.0 for 100, or
35
+ whose `>` lied about an integer past the double range, passed a `perCallMaxUsd` of 5. A number is now an `int` or
36
+ `float` by its real type (`fabric_jsvalue.is_number`, never `__class__`) and is read by the number it stores
37
+ (`real_number`: `float.__float__`, `int.__int__`), in `mandate_spend`, `js_to_number`, `double` and McpToolServer's
38
+ estimate: an honest subclass such as `numpy.float64` keeps its number, a lying one is read as what it holds. A value
39
+ that only claims a number through `__class__` (a mock with `spec=float`, a lazy proxy), a `Decimal` or a
40
+ `numpy.int64` is no estimate: the call is answered and a `perCallMaxUsd` refuses it, in every reader. The core
41
+ `mandate_covers` reads a spend only when it is a number or JSON data as `json.loads` gives it, by exact type all the
42
+ way down, so for its direct callers a tuple, bytes, a `str`, `list` or `dict` subclass, or a list holding a number
43
+ subclass is no estimate either, a stricter reading than 0.60.0's (checked only under a ceiling, without recursion in one
44
+ pass, then read as `Number()` reads it, in time linear in the structure (a list is never joined: two or more items are
45
+ NaN, one is read for itself); a shared or cyclic list or dict, which `json.loads` never gives, is no estimate, and a spend whose lists nest in
46
+ lists more than 100 deep (`MAX_SPEND_DEPTH`, a dict between them ends the count) is no estimate, so the spend a
47
+ refusal carries as `estimateUsd` is one a serialiser writes: that nesting is what `String()` of a list recurses
48
+ through, so 0.60.0 raised RecursionError a few hundred deep (about 500 on Python 3.14, 330 on 3.9) and Node's
49
+ `mandateCovers` and the contract throw RangeError a few thousand deep, and between 100 and those depths the core is
50
+ stricter than all three, a fail-closed twin split that no board, MCP or job reader reaches, since they take no list
51
+ or dict spend; under a dict there is no bound, since `Number()` never descends a dict, NaN at any depth in all of
52
+ them; a dict with an own `toString`, at the top or reached through lists alone, is no estimate too, where 0.60.0
53
+ read it as NaN and Node and the contract throw TypeError). A bool is none in `mandate_spend`, `action_decision` and McpToolServer as before, while
54
+ the core `mandate_covers` reads it as JavaScript's `Number()` does, as the contract's `mandateCovers` does (0.61.0
55
+ seam readings 17 to 22, 24 and 26 to 29, product readings 18 to 20 and 22 to 27, unit readings 19, 21, 24, 26 and 28 to 30). `js_to_number` tested a bool with `isinstance`, which asks
56
+ a number subclass's own `__class__`: one claiming `bool` was read as 1 under a ceiling, and one whose `__class__`
57
+ raised made the core raise (older than 0.61.0); it tests a bool by identity now (0.61.0 unit reading 30). A job's approval step kept a `str`-subclass project (a `(str, Enum)` member included) as given, which
58
+ the start refused by the core's exact-type compare but the job file then wrote as its plain string, so the answer
59
+ covered it, and the same held for a step a service keeps itself through `request_input(steps=)` or Node's
60
+ `requestInput({steps})` (a `String` object project, a `Number` object spend); both stores now keep a step as the gate
61
+ reads it (`kept_step` / `keptStep`: a spend through `mandate_spend`, a project as an exact `str` or a primitive
62
+ string, else `UNREADABLE_PROJECT`), and `job_step`, `action_gate` and McpToolServer read a project as an exact `str`
63
+ too, as the board path does, so the start and the answer agree, and a value that only claims `str` never reaches the
64
+ job file (older than 0.61.0; 0.61.0 seam reading 32, readings 33). Node's `JobStore.answer` matched an answer
65
+ keyed by an inherited name (`constructor`, `toString`) as an asked question and cleared the real one; it matches an
66
+ own key only, as Python does (older than 0.61.0; 0.61.0 seam reading 36). Both stores' `answer` match nothing when
67
+ the answer's `inputResponses` or the job's `inputRequests` is not a plain object (a hand-edited file, an array), and
68
+ McpToolServer answers a `fabric.job.get` whose `inputResponses` is present, not `null` (read as a poll, as before)
69
+ and not a JSON object with `-32602`:
70
+ 0.60.0 raised out of `handle()` for a non-empty one on a job waiting `input_required` (the transport sent no reply)
71
+ and read an empty list or string, `0`, `false`, or any non-object on another job or an unknown id, as no answer or
72
+ `unknown-job` (a client that writes an empty map as `[]` now gets `-32602`; for an unknown id on a server without
73
+ `authorize`, `-32602` comes before `unknown-job`, where a server with `authorize` answers `unknown-job` first). Both stores' `request_input` /
74
+ `requestInput` refuse requests that are not a JSON object (before they read a step), and a request that is
75
+ not a keyed object (under a string key, a dict in Python, in Node an object that is not null, an array or a function), with `InteropError` (0.60.0 Python raised `AttributeError` or `TypeError`, by the shape and whether
76
+ `steps=` was passed; Node kept an array, which an answer keyed by an index or `length` then matched), and McpToolServer's answer gate reads only an object of requests (older
77
+ than 0.61.0; 0.61.0 seam and unit readings 37 and 38). The same split held for an
78
+ effect: Python admitted a `str`-subclass effect (`isinstance`) that the core then refused at the start and the job
79
+ file wrote as a plain string the answer covered; `job_step` (when a mandate travelled), `request_input(steps=)` and
80
+ `add_tool` (with an action) now admit an effect as an exact `str` (an `InteropError` otherwise, as Node's `requestInput` already refused a `String` object),
81
+ and both stores read each member of a step once and keep the value they checked (0.61.0 seam and unit readings 34). `js_to_number`'s decimal
82
+ pattern backtracked over a long digit string that fails to match, quadratic in its length; it now costs the length
83
+ (0.61.0 seam reading 22).
84
+ - `InstanceLock.acquire` (Python, `fabric_lock.py`): when writing the pid failed after the lock was taken (a full disk, an
85
+ I/O error, an interrupt), the descriptor stayed held, and the lock name too, since the caller drops the object
86
+ (`release()` would have freed only the name, on Linux and Windows), so the same process could not start the service
87
+ again. The lock now lets both go and re-raises the first error, even when the
88
+ close itself fails; the Node twin
89
+ (`fabric-lock.mjs`), whose caller drops a lock that failed to start, does the same for its descriptor or its name,
90
+ the first error standing in both.
91
+ Found by a service that vendors the kit, on the board.
92
+
93
+ ### Changed
94
+
95
+ - working-with-fabric's mandate section: every hand-over within a task carries the four parts of the operator's
96
+ approval (`source` only where OA-7 lets it), and each receiver acts on them without asking him again, narrowing only
97
+ (human gates stay, MD-6); a relayed receiver's chain starts with it (OA-2), so the four-agent bound limits one
98
+ mandate, never the task; a mandate lapses within seven days of his words, and a task still open asks him once for a
99
+ new one, his same words with `at` when he confirms (MD-1), then weekly until the contract's CT-205 (its answer
100
+ `7f372925` on board thread `118f95f9`; CT-205 is a row the contract proposes after this kit's pin, on its branch
101
+ `agent/template-parity-20261103`); a link whose chain already holds four agents hands on no mandate and never restates its own: the four
102
+ parts go as a relay through OA-3 (his own Project on the board or his local session), whose receiver states its own (OA-2),
103
+ as the contract confirmed on board thread `118f95f9` (message `adf0c61a`); references/project-board.md and building-fabric-services
104
+ references/interop.md say the relay names the scope in force and the held `expiresAt`, which the receiver's mandate
105
+ does not pass (the kit's bound); and, as the contract proposes until CT-205,
106
+ no agent takes a relay for a task it already leads; references/interop.md says a service has no route past four agents until CT-205. `expiresAt` is counted from `at` and
107
+ covers the task's length (the kit's `operator_mandate` defaults to a day; references/interop.md says to pass it); the
108
+ source and spend sentences are reworded with no change in rule, and Step 5's lane-restart paragraph drops its reason
109
+ sentence ("The stop is usually stale by then.") for room, its rule unchanged; the paragraph for a client without `_meta`
110
+ leaves the mandate section for Step 5's routes (CT-125), and two sentences that restated rules kept elsewhere in the
111
+ section are dropped (FAA-196 (6)). A scenario eval `an-approval-holds-for-the-whole-task` (FAA-195).
112
+ - working-with-fabric references/project-board.md says what a kit service reads from a board request: one `mandate/1`
113
+ block fenced ` ```json ` at the start of its own lines, its chain ending in the service's registry agent id, never a
114
+ decision block; and what comes back: `mandate-not-for-this-call` is handed on again to the right id without asking
115
+ him, `mandate-expired` asks him once for a new one.
116
+
117
+ ### Upgrading
118
+
119
+ - `fabric_board_link` (and `fabric-board-link.mjs`) now imports `fabric_interop_rules`, so a board responder's install
120
+ brings it and its `fabric_delegation` too (`fabric_kit.py install --module fabric_board` copies 33 modules, 31 in
121
+ 0.60.0): reinstall with `fabric_kit.py install` and check with `status`, never by copying `fabric_board_link` alone.
122
+ - A Python service that passes a job or tool project as a `(str, Enum)` member or another `str` subclass, under a
123
+ mandate limited to projects, is now asked at a job's answer as at its start: pass the plain string (`member.value`).
124
+ An effect passed that way to `approval_step`, `job_step` (when the start call carried a mandate), `request_input(steps=)` or `add_tool` with an action now raises
125
+ `InteropError`: pass `member.value` there too. A Node service that keeps a step through `requestInput({steps})` with
126
+ a `String` object project (under a mandate limited to projects) or a `Number` object spend (under a `perCallMaxUsd`)
127
+ is likewise asked at the answer: pass the primitive value.
128
+ - A kept step holds only `effect`, `spendUsd` and `project`, read as the step's accessors answer them (Node property
129
+ access, Python `.get`), where 0.60.0 kept the step's JSON form (Node `JSON.stringify`, Python `json.dumps`, which reads a
130
+ `dict` subclass through its `items()`): a
131
+ service that put its own keys in `steps=` keeps them elsewhere. The two differ for a Node step that is a function
132
+ (0.60.0 kept no step), an array, an object whose members are inherited (a class's getters included) or not
133
+ enumerable, or one with its own `toJSON`, and for a Python `dict` subclass whose `.get` or `items()` answers otherwise
134
+ than its storage: such a step is now kept as its accessors answer, and an accessor that throws for the spend or the project,
135
+ which 0.60.0 never called, now throws from `requestInput` / `request_input` (the job stays `working`).
136
+ - A job waiting `input_required` whose approval step 0.60.0 kept keeps it as 0.60.0 wrote it: a `str`-subclass
137
+ project or effect, or a Node `String` or `Number` object, as its plain value, and the step kinds above in their JSON
138
+ form. A step so kept with its effect but no project is covered at a 0.61.0 answer by a mandate limited to projects;
139
+ one kept with no effect waits for the operator (`effect-missing` where a step is kept, `no-decision` where none is),
140
+ and a Node job whose requests 0.60.0 kept as an array can no longer be answered. Answer or cancel such jobs before
141
+ upgrading.
142
+ - Python McpToolServer answers a `fabric.job.get` whose `inputResponses` is not a JSON object (`[]` and `""`
143
+ included), on any job the caller may read, `-32602` (Node has no tool server): send an object, or leave it out (or `null`) to poll.
144
+
145
+ ## 0.60.0 (2026-11-03)
146
+
147
+ 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
148
+ reads a filed row's answer (FAA-169) and says when an accepted request is left behind by a move (FAA-172), `pack`
149
+ names why a provider manifest is refused (FAA-193), and Python's package kit refuses `NaN` and `Infinity` as Node's does.
150
+
151
+ ### Fixed
152
+
153
+ - A slot the operator moved to another session and then back to this one (contract DEC-0140 PR-2, once Fabric offers
154
+ 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
155
+ is moved, the responder reads `com.status` once a renewal (20 s) and, when the slot's generation is past the recorded
156
+ move, sends one `com.claim` under a fresh key, as PR-2's "Learning the slot is its own" says. A claim that lands
157
+ holds the slot again (the record loses `moved`, the request it handed out is worked, or its own enroll renews the
158
+ lease at once; a later move is said again); `lease_expired` enrolls again; `held` asks at the next renewal;
159
+ `idempotency_window_expired` or `capacity_exceeded` asks again at the next renewal under a fresh key, recording
160
+ nothing; `fenced` or another refusal that is not the board session's records the new generation, so the same move is not asked about
161
+ twice. A recorded move drops the capability's claim key, so a claim answer lost before the move is never replayed, and a
162
+ 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)
163
+ (a replay from an older generation is dropped); `not_authorized` counts as another session's. It never enrolls or replaces a
164
+ slot another session holds, and waits while other work runs. Both kits (`fabric_board_slots.py`,
165
+ `fabric_board_session.py` and their Node twins). The `board.slot.moved` notice and references/board.md say so (FAA-165).
166
+ - `pack`, `sign` and `verify` refused a provider manifest whose `costPresets` differ from the package's with only
167
+ `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
168
+ FAA-124 pilot rehearsal). Python no longer refuses an empty provider manifest (`{}`), as Node never did (FAA-194 (1)).
169
+ - Python's `pack`, `sign` and `verify` took `NaN`, `Infinity` and `-Infinity` in a plugin or provider manifest, a key file
170
+ or a trust file, which `JSON.parse` and the contract refuse, so a package the Node kit refused could pass Python's
171
+ `verify`. Python refuses them now too (older than 0.60.0, since 0.31.0; found by the 0.60.0 seam reading 11).
172
+
173
+ ### Changed
174
+
175
+ - 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
176
+ 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
177
+ it is linked (FAA-169). A scenario eval `a-backlog-row-filed-while-a-claim-is-held`.
178
+ - working-with-fabric references/project-board.md, "When nobody answers": an `active` responder does not prove a request
179
+ is worked; one the old holder accepted before its slot moved stays `accepted` with nobody on it, and a requester
180
+ cannot see that (`com.get` shows no attempt), so it asks on the thread; with no answer it cancels the request while
181
+ its `effect` is `not_started` and goes on only once the cancel's receipt shows `cancelled`. An accepted request is
182
+ never adapted by hand and never expires (FAA-172; question to Fabric and the contract on board thread `ab12642e`). A
183
+ scenario eval `an-accepted-request-left-behind-by-a-move`.
184
+ - building-fabric-services SKILL.md: a moved slot stays moved until `take_back` or a move back once Fabric offers one.
185
+ - The reference service's take-back tool now reads "Take back a board slot moved to another session."; a manifest
186
+ registered before 0.60.0 keeps the old description until it is registered again.
187
+
188
+ ### Upgrading
189
+
190
+ - No Fabric release offers the operator's move of a slot yet (Fabric 0.3.15 has none): today a moved slot is
191
+ another session's taking it, and take-back is the way back; the move-back check is ready for when Fabric ships it.
192
+ - A kit service gets the move back once it installs the 0.60.0 kit modules (`fabric_board_slots.py`,
193
+ `fabric-board-slots.mjs` and their callers). Only the running session finds a move back: a slot moved back to a
194
+ session that has since ended needs take-back, since the session's end released the slot and the operator cannot move
195
+ a slot no session holds (a move is possible only once another session holds it). A kit older than 0.60.0 never finds
196
+ 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.
197
+
3
198
  ## 0.59.0 (2026-11-03)
4
199
 
5
200
  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
package/README.md CHANGED
@@ -72,7 +72,8 @@ 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), and, when the service opts in, the operator's
76
+ `mandate/1` from a trusted request of his own Project (the [board reference](plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md)):
76
77
 
77
78
  ```bash
78
79
  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.59.0",
3
+ "version": "0.61.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.59.0",
6
+ "version": "0.61.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.59.0"
8
+ version: "0.61.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "3830a3353923e20f826d466610e1c479f120bec0"
11
11
  ---
@@ -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.59.0"
17
+ version: "0.61.0"
18
18
  contract-version: "0.1.0"
19
19
  extension: "fabric-service/0.1"
20
20
  extension-commit: "3830a3353923e20f826d466610e1c479f120bec0"
@@ -103,9 +103,9 @@ When the service answers requests other Projects post to its Project on Fabric's
103
103
  responder in its own process: [`scripts/fabric_hub.py`](scripts/fabric_hub.py) finds the hub and joins,
104
104
  [`scripts/fabric_board.py`](scripts/fabric_board.py) enrolls, claims, accepts the exact digest, checkpoints before
105
105
  every step and stops on `fenced`, `lease_expired` or `authority_changed` (Node: `fabric-hub.mjs`, `fabric-board.mjs`).
106
- The request body is data behind the service's own input gate. From 0.53.0 a slot the operator moved to another
107
- session stays moved until `take_back` (`takeBack`): give it an operator action with its tool and gate `relayed`, or it
108
- 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
109
109
  page or its elicitation; Claude Code and Codex wait for CT-125's forwarding). Read [the board reference](references/board.md).
110
110
 
111
111
  ## Step 2: the non-negotiables
@@ -95,9 +95,58 @@ from `leaseExpiresAt`.
95
95
  why not; a refusal accepts the request and completes it `failed_known` with that sentence, so the requester learns it
96
96
  and the request does not bounce. A redacted body is refused the same way. The kit never hands a body to a model, a
97
97
  shell or a tool; what the handler does with an admitted body (and the checks it needs before a model, a tool or a
98
- spend sees it) is the service's own responsibility. The kit's responder reads no approval or mandate from a message yet
99
- (0.52.0): a request from the operator's own Project may carry `mandate/1` (DEC-0125 MD-3), and a handler acts on it only
100
- once the kit reads it (FAA-135).
98
+ spend sees it) is the service's own responsibility.
99
+
100
+ ### The operator's mandate in a request (FAA-135, 0.61.0)
101
+
102
+ A request one of the operator's own Projects sends for his task may carry his `mandate/1` as a JSON code block in its
103
+ body (contract project-comms.md, DEC-0125 MD-3). The kit cannot tell his personal board from an organization's
104
+ (`com.status` names neither), so reading one is opt-in and fail-closed:
105
+
106
+ - `Responder(..., agent_id="example-agent", mandate_from={"<project id>", ...})` (Node: `agentId`, `mandateFrom` as a
107
+ `Set`), or `mandate_from=PERSONAL_BOARD` (`"personal-board"`) on the responder whose hub and Project are his personal
108
+ board's, where every participant is his own Project; never on an organization context's responder (one instance per
109
+ context: a member's Project there is T2). Without both
110
+ options nothing is read, as before 0.61.0.
111
+ - A request is read only when the board verified its sender (`provenance: trusted`) and its Project is one
112
+ `mandate_from` names. Its sender is then a T1 caller. Project ids match as Fabric compares them: a uuid in any case,
113
+ any other id as written. The mandate is a block fenced ` ```json `, the opening and the closing fence each at the start
114
+ of its own line, with no indent (lowercase; an indented fence, as inside a list item, is not read either; ` ````json `, ` ```JSON ` and `~~~` are not read), whose value has `kind: "mandate/1"`; any other JSON
115
+ block, a decision block included, is not read, with no warning. A fence quoted inside another fenced block is still
116
+ read: quote an example in prose, not in a fence. Two `mandate/1` blocks, whoever their chains end in, or one whose text holds `mandate/1` and is
117
+ not JSON (`NaN` included), read as none (a broken block without `mandate/1` is skipped, and a mandate beside it
118
+ is still read), and the responder emits `board.request.mandate_unread` (a warning) with the
119
+ request id and its `reason`, `mandate-blocks` or `mandate-unreadable`: the block holds his words, which reach no event, log or
120
+ checkpoint. A Project declares no `protocols`, so a board block carries no `source` (OA-7): any `source` in it is
121
+ dropped unread. Python reads two kinds of valid JSON as not JSON where Node reads them, both fail-closed: a block nested
122
+ about 1,000 levels deep on Python 3.9 to 3.11 and about 10,000 on 3.12 and 3.13 (none on 3.14), and an integer longer
123
+ than 4300 digits on every Python (the kit refuses it there, as `int_max_str_digits` does from 3.11 and
124
+ the 3.8.14, 3.9.14 and 3.10.7 security releases; a limit configured
125
+ lower, `PYTHONINTMAXSTRDIGITS` on a Python that has one, refuses an integer longer than it).
126
+ - The handler gets `ctx.mandate` (the one block without `source`, or None) and `ctx.mandate_covers(call)` (Node
127
+ `ctx.mandateCovers(call)`, `call` being `{capability, action?, effect, spendUsd?, project?}`: pass `project` when the work
128
+ acts in one, since a call naming none is not limited by the mandate's `projects`; both are read failing closed, as
129
+ the kit's other mandate readers read them, FAA-139: a `spendUsd` that is not a finite number is none, which a
130
+ `perCallMaxUsd` refuses, and a `project` that is not a string (in Python an exact `str`, so a subclass too), None
131
+ (Node null) included, is refused by a mandate
132
+ limited to projects, as the contract's `mandateCovers` refuses a null project: leave `project` out when the work
133
+ acts in none; the MCP and job readers read None as naming none; only these five members are read, each once, as the
134
+ contract reads them, `call[name]` in Python and a plain property read in Node, an inherited, getter or Proxy member
135
+ included, but for a Node spend, read as the call's own member, so an inherited spend is none; a number subclass is
136
+ read by the number it holds, never by its `float()`, in every Python reader, and a mock, a proxy or a `Decimal` is no
137
+ spend; all five are read up front, whatever the scope limits, so a member that raises raises to the handler (in
138
+ Python a `KeyError` reads as not named), and a Python mapping's default, a `defaultdict`'s
139
+ included, is read as named, the `defaultdict` gaining the key): `mandate_covers` with `tier: "T1"`, `self` the service's `agent_id`, no `callerAgent` (the
140
+ board names a Project's principal, not an agent id the chain can bind, so the chain's end alone binds it) and the
141
+ responder's wall clock (`wall=`: seconds, `time.time` by default; Node milliseconds, `Date.now`; one that returns a value
142
+ that is not a finite number answers `{"fault": "now-unreadable"}`; a clock that raises passes its error to the handler). It answers `{"ok": True, "recordAs": {by, via: "mandate", task,
143
+ chain, caller}}` (`caller` the sender Project, as MD-3 records it; a sender that names no Project is never read), a
144
+ `decision-invalid` refusal (a chain that does not end in the service's `agent_id` answers `mandate-not-for-this-call`;
145
+ a lapsed one `mandate-expired`), an `outside-mandate` refusal, or None when no mandate travelled. A covered call is
146
+ the operator's decision: record `recordAs` with the request as your reason. A refusal goes back to the requester as
147
+ the request's outcome (`failed_known`, naming the part or the detail in the summary), so its leading agent asks him
148
+ for that part alone, or for a new mandate when it lapsed (MD-1, MD-4); never ask him from your own page about the whole
149
+ call. The gate still runs first: a mandate never widens what `admit` refuses, and a body stays data.
101
150
 
102
151
  ## Effects, observation, evidence and the reply
103
152
 
@@ -120,7 +169,7 @@ once the kit reads it (FAA-135).
120
169
  |---|---|
121
170
  | `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
171
  | `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 |
172
+ | `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
173
  | `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
174
  | lost after an effect began | keep the entry as `unknown`; `observe(entry)` is asked to settle it with `com.reconcile` |
126
175
  | Fabric down, hub missing | bounded backoff (2 s doubling to 5 minutes, jittered), one `board.down` event, no spinning |
@@ -134,7 +183,7 @@ the target Project's participant reply answered it, DEC-0098 and DEC-0102; not s
134
183
  it, but no longer this responder's); `in_progress` and `outcome_unknown` are
135
184
  reconciled from what `observe(entry)` answers (observed only; with no observation they stay unknown and notify);
136
185
  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
186
+ 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
187
  `transferred` (DEC-0078: `{request, revision, attempt}`) carries the new fence. **A Fabric that does not answer
139
188
  `transferred` yet leaves the request held** (`board.request.held`, notify): the kit never claims it back, because the
140
189
  contract has no `com.claim` read-back and `com.claim` never moves an accepted request. A known outcome is sent after the
@@ -181,7 +230,7 @@ contract's matrix, held to its vectors (`test/fixtures/contract/comms-hold-vecto
181
230
  | Refused `held` | What the kit does |
182
231
  |---|---|
183
232
  | `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 |
233
+ | `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
234
  | `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
235
  | `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
236
  | 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 +243,7 @@ whose responder was gone. Read it as the owner's attestation, not the responder'
194
243
 
195
244
  ## When the operator moves a slot to another session (FAA-155)
196
245
 
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
246
+ The contract's DEC-0140 PR-2 (accepted 2026-10-30, after this kit's pin) lets the operator move a
198
247
  (Project, capability) slot to another live session of the Project: one transition, as `com.responder_replace` would make
199
248
  it for the receiving session (generation up, `claimed` back to `queued`, `in_progress` to `outcome_unknown`, `accepted`
200
249
  held and not transferred). The responder keeps that move instead of undoing it:
@@ -228,17 +277,18 @@ held and not transferred). The responder keeps that move instead of undoing it:
228
277
  this service left unanswered, is a move, whatever made it: the operator, or another session that took the slot while
229
278
  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
279
  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.
280
+ holder it waits for take-back or a move back to its session, not for the other's lapse.
232
281
  - A `fenced` refusal on accepted work while the slot did not move (the generation unchanged) keeps the entry
233
282
  `lapsed`; the next tick takes it back with a transfer and goes on (before 0.53.0 the entry was dropped and the
234
283
  request left accepted on the board).
235
284
  - Then the slot is `moved`: the running handler is told to stop (`ctx.stopped`); a `claimed` entry is dropped (the move
236
285
  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
286
+ holder, or the estate owner, reconciles it now). While the slot is moved nothing but the move-back check
287
+ (`com.status` once a renewal, then one `com.claim`, below) is sent for that capability: no `com.renew`,
288
+ `com.complete`, `com.enroll` or `com.responder_replace`, not at recovery after a restart, and not when the new
239
289
  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
290
+ take-back or a move back). Accepted requests stay `accepted` on the board, unanswered, until the slot is taken back
291
+ 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
292
  (the record keeps `moved`).
243
293
  - A responder that never held the slot (a first start: no record, or a record of another Project), or whose record
244
294
  equals the current generation, behaves as before: it enrolls, recovers and replaces its own lapsed work. A recorded or
@@ -248,7 +298,29 @@ held and not transferred). The responder keeps that move instead of undoing it:
248
298
  records its own generation. Only a replay the board refuses, or one that ran for the first time (`repeated: false`)
249
299
  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
300
  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
301
+ - The operator may move the slot back to this session in Fabric (DEC-0140 PR-2), which this client cannot hear. No
302
+ 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),
303
+ so today every move is another session taking the slot and take-back is the route back; the check below is ready
304
+ for the move. While
305
+ a slot is moved the responder reads `com.status` once a renewal (20 s) and, when the slot's generation is past the
306
+ recorded move, sends one `com.claim` under a fresh key, as PR-2's "Learning the slot is its own" says (FAA-165,
307
+ 0.60.0); the move itself drops the claim key of that capability, whose receipt the move made dead. A claim that
308
+ lands is the slot held again: the record loses `moved`, the request it handed out is worked (with none, its own
309
+ enroll renews the lease at once), accepted work left `lapsed` comes back with a transfer, and a later move is said
310
+ again. `lease_expired` (the move reached this session and its lease lapsed first) enrolls again. `held` asks again
311
+ at the next renewal. `idempotency_window_expired` or `capacity_exceeded` asks again at the next renewal under a
312
+ fresh key, recording nothing. `fenced`, or any other refusal that is not the board session's (`not_authorized` counted with them, since a
313
+ non-holder's claim may be refused so), is another session's slot:
314
+ 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
315
+ landed is not stranded; until then it counts as at most a lease from that key's first send (a fresh key starts
316
+ 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
317
+ the session (finding 38). A replayed receipt from a generation older than `com.status` shows (a move away and back meanwhile) is
318
+ 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
319
+ 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
320
+ 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
321
+ while other work runs: a move back during a job longer than the lease (60 s) may lapse and be lost to another
322
+ session, after which take-back, or another move back to this session, brings it back.
323
+ - Otherwise only the operator brings the slot back: `take_back(capability)` (`takeBack`) forgets the record, and wins over a move
252
324
  being read at the same time: the move is written under the responder's lock (Node: on the fence queue, after any fenced
253
325
  call in flight) and only while the record is unchanged (FAA-158). Never edit the
254
326
  checkpoint's `slots` record by hand to undo a move. In the same board
@@ -269,9 +341,8 @@ held and not transferred). The responder keeps that move instead of undoing it:
269
341
  tool](../SKILL.md), `fabric-actions/0.1`) with gate `relayed` (the operator's decision), never `none`, so a call
270
342
  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
343
  `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.
344
+ 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
345
+ hand-over of accepted requests to another session (contract CT-175) waits for Fabric's session surface.
275
346
 
276
347
  ## What a board session may call
277
348
 
@@ -304,7 +375,7 @@ checked by FAC-SEM-101 (no credential in data): `board.request.completed`, `boar
304
375
  `board.request.skipped`, `board.request.reconciled`, `board.request.held`, `board.attempt.lost`,
305
376
  `board.reconcile.needed`, `board.slot.contested`, `board.slot.moved`, `board.slot.refused`, `board.access.needed`, `board.blocked`,
306
377
  `board.down`, each notifying episode said once, and `board.outcome.invalid` (warning) when a handler's outcome fails
307
- its checks and is not sent. The operator's hold (below) adds `board.held`, `board.slot.held`,
378
+ its checks and is not sent, and `board.request.mandate_unread` (warning) when a request's mandate is not read (above). The operator's hold (below) adds `board.held`, `board.slot.held`,
308
379
  `board.slot.released` and `board.request.waiting`. The reference service adds `board.slot.returned` at a take-back.
309
380
 
310
381
  `check_service.py <id> [--board-kit <the service's fabric_board.py or fabric-board.mjs>]` rule **board.responder**:
@@ -328,8 +399,9 @@ credential_file)`, `join_board(project_id, credential_file) -> BoardGrant`, `cal
328
399
  message, retryable, data)`.
329
400
 
330
401
  Python (`fabric_board`): `Responder(*, hub, project_id, capabilities, credential_file, checkpoint_file, handler,
331
- admit, observe=None, emit=None, service, now, rand, spawn, session_factory)` with `step()`, `run(stop, drain)`,
332
- `shutdown(drain)`, `take_back(capability)`; `WorkContext.progress(note)`, `.begin_effect(summary)`, `.stopped`; `BoardSession(grant)`;
402
+ admit, observe=None, emit=None, service, now, rand, spawn, session_factory, agent_id=None, mandate_from=None, wall=time.time)`
403
+ with `step()`, `run(stop, drain)`, `shutdown(drain)`, `take_back(capability)`; `WorkContext.progress(note)`,
404
+ `.begin_effect(summary)`, `.stopped`, `.mandate`, `.mandate_covers(call)`; `PERSONAL_BOARD`; `BoardSession(grant)`;
333
405
  `Checkpoint(path)`; `comms_canonical(value)`, `comms_digest(operation, value)`, `receipt_problems(value, op)`,
334
406
  `refusal_problems(value)`, `directory_findings(page)`, `directory_problems(page)`, `find_project(page, project_id,
335
407
  capability)`, `artifact_ref(uri, content|path, label)`, `evidence_problems(list)`, `outcome_problems(outcome,
@@ -337,4 +409,5 @@ effect_began)`, `self_check()`; `BoardError`, `Stopped`.
337
409
 
338
410
  Node: the same names in camelCase (`locateHub(root, {alive, estate, now})`, `new HubClient(root, {alive, timeout,
339
411
  estate, post})`, `new Responder({...})` with async `step()`, `run({signal, drain})`, `shutdown(drain)`,
340
- `takeBack(capability)`; `await ctx.progress(note)`, `await ctx.beginEffect(summary)`; `selfCheck()`). Both kits read and write the same checkpoint file.
412
+ `takeBack(capability)`; `await ctx.progress(note)`, `await ctx.beginEffect(summary)`, `ctx.mandate`, `ctx.mandateCovers(call)`;
413
+ the options `agentId`, `mandateFrom` and `wall` (milliseconds); `selfCheck()`). Both kits read and write the same checkpoint file.
@@ -266,8 +266,13 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
266
266
  - A `spend` or `project` function that raises, or a spend that is not a finite number, fails closed: the call is
267
267
  answered, and a mandate with a `perCallMaxUsd` ceiling or a `projects` list refuses it (`outside-mandate: spend` or
268
268
  `outside-mandate: project`); the estimate is read as a double, so an integer past the float range is infinite and
269
- refused under a ceiling, and a project function that answers anything but a string or `None` names no project a
270
- mandate can list. A refusal outside a mandate
269
+ refused under a ceiling (a subclass of int or float is read by the number it holds, never by its `float()` or
270
+ comparisons; a bool, a `Decimal`, a `numpy.int64` or a value that only claims a number through `__class__`, a mock
271
+ or a proxy, is no estimate), and a project function that answers anything but a string or `None` names no project a
272
+ mandate can list (in Python an exact `str`: the core compares a project by exact type, so a `str` subclass, a
273
+ `StrEnum` member included, is refused by a mandate limited to projects, at a job's start and at its answer alike,
274
+ since the kept step holds it as unreadable; in Node only a primitive string is a project, so a `String` object is
275
+ refused the same way). A refusal outside a mandate
271
276
  tells the caller the MD-4 order: call again within its own mandate, else pass the refusal up; only the leading agent
272
277
  asks the operator.
273
278
  - `add_tool(tool, handler, action=, effect=, spend=, project=)`: on a server with `agent_id`, a `relayed` action names
@@ -310,7 +315,9 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
310
315
  server with `agent_id`) answers the record when the mandate covers the step `{capability: the job's, action: key,
311
316
  effect}` and asks nothing, the job going on as the operator's decision; otherwise the job waits `input_required` on
312
317
  `request`, its message naming the part not covered (`outside-mandate: <part>`) or saying the mandate expired, and the
313
- step is kept in the job's private part (`request_input(..., steps=)`). An answer's `_meta.mandate` that covers that
318
+ step is kept in the job's private part (`request_input(..., steps=)`), as the gate reads it (`kept_step` /
319
+ `keptStep`: an effect admitted as an exact `str` or a primitive string, a spend through `mandate_spend`, a project as
320
+ an exact `str` or a primitive string, else unreadable, each member read once; other keys are not kept). An answer's `_meta.mandate` that covers that
314
321
  step is then read when `input_actions=True`, beside a decision for the key. The rule alone is `job_step(mandate, ...)`
315
322
  / `jobStep({mandate, tier, now, self, capability, key, effect, callerAgent, spendUsd, project})`; Node's
316
323
  `requestInput(jobId, inputRequests, statusMessage, {steps})` keeps the same private part. Node has no
@@ -325,7 +332,11 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
325
332
  `capabilities` name `fabric.job.get` covers no step of another capability.
326
333
  Node has no tool server: a Node service reads `store.load(jobId).private.steps` and `.private.capability` BEFORE
327
334
  `store.answer` (which clears the steps), gates the answer with `actionGate({..., mandateCapability:
328
- private.capability})`, then answers. It logs the `fault` that `jobStep` and `actionGate` return beside their
335
+ private.capability})` for each answered key the job asked whose value's action is accept, decline or cancel, one
336
+ decision per call (when `job.inputRequests` and the answer's `inputResponses` are plain
337
+ objects, `Object.hasOwn(job.inputRequests, key)`, its step read with `Object.hasOwn(steps, key)`, as McpToolServer
338
+ gates only asked keys; McpToolServer answers an `inputResponses` that is present, not `null` and no object with
339
+ `-32602`, and reads `null` and `{}` as a poll, which the contract's schema does not admit), then answers. It logs the `fault` that `jobStep` and `actionGate` return beside their
329
340
  answer, as `on_fault` does in Python: `mandate-capability-missing` at the start means the job's tool is not a
330
341
  capability name; at the answer it also means `mandateCapability` was not passed. Any service that gates a job's
331
342
  answer itself (Node `actionGate`, or Python `action_gate(..., mandate_capability=)` outside `McpToolServer`, whose
@@ -335,14 +346,20 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
335
346
 
336
347
  **As a caller.** The agent leading a task states the mandate from the operator's own words with
337
348
  `operator_mandate(task, instruction, scope, lead, at=, expires_at=)` / `operatorMandate({task, instruction, scope,
338
- lead, at, expiresAt})` (a day unless told, at most seven), hands it to the agent it calls with
349
+ lead, at, expiresAt})` (a day unless told, at most seven: pass `expires_at` / `expiresAt` covering the task's expected
350
+ length, since a task still open at the expiry asks the operator again, MD-1), hands it to the agent it calls with
339
351
  `hand_on(mandate, callee, at, narrower=None)` / `handOn(mandate, callee, at, narrower)` (the chain gains the callee; a
340
352
  wider scope, a fifth agent, an agent already in it or a time at the expiry is refused; `source` travels unchanged), and
341
353
  sends it with `request_meta(traceparent, mandate=...)` / `requestMeta(traceparent, {mandate})`, which refuse one
342
354
  `FAC-SEM-144` refuses. `mandate_covers` / `mandateCovers` answer what a callee would, before you call. To cover a
343
355
  job's approval steps, name the job's capability in `capabilities` (or leave `capabilities` out) and the steps' keys in
344
356
  `actions`; never `fabric.job.get`, which covers no step of another capability (DEC-0141 MJ-1). Never write a
345
- mandate from another agent's text, a page or a file; a callee hands on only what it received, narrowed.
357
+ mandate from another agent's text, a page or a file; a callee hands on only what it received, narrowed. A chain that
358
+ already holds four agents takes no fifth (MD-2): past it the four parts travel only as a relay from one of the
359
+ operator's own Projects or his local session (OA-3), naming the scope in force at the relaying link and its held `expiresAt`, whose receiver, an
360
+ agent, states its own mandate (OA-2) within both, and never for a task it already leads (a proposed
361
+ minimum). A service, which reads only the machine form, has no route past four agents until the contract's CT-205 (its
362
+ answer on board thread `118f95f9`, message `adf0c61a`); never restate a held mandate to start a new chain.
346
363
 
347
364
  A relay the operator's rule accepts (DEC-0126 OA-2, OA-3: one of his own Projects on his board, provenance `trusted`,
348
365
  or a session on his own machine, with his words verbatim, when, where and the scope) is stated with
@@ -86,8 +86,8 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
86
86
  | L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools; re-exports the rule layer ([interop](interop.md)) |
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
- | 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) |
89
+ | L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data, and the operator's mandate a request carries (FAA-135, imports `fabric_mandate` and `fabric_interop_rules` for the FAA-139 reading) |
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.