@passioncode-ai/passioncode 0.2.45 → 0.2.47

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 (63) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/SECURITY.md +2 -1
  4. package/family.json +1 -1
  5. package/lib/doctor.js +25 -3
  6. package/lib/launcher.js +34 -5
  7. package/lib/releases.js +3 -2
  8. package/lib/setup.js +2 -1
  9. package/lib/windows-apps.js +74 -0
  10. package/package.json +1 -1
  11. package/payload/.claude-plugin/marketplace.json +2 -2
  12. package/payload/manifest.json +8 -8
  13. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  14. package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  15. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  16. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -1
  17. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +73 -18
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -5
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +70 -20
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +7 -1
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +9 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -5
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -1
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +16 -9
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +87 -156
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +180 -0
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +4 -2
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +2 -0
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +15 -3
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +12 -1
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +12 -1
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +32 -13
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +115 -147
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +173 -0
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +9 -42
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -4
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +7 -3
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -2
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +87 -11
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +19 -8
  46. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +30 -6
  47. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +9 -1
  48. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +15 -1
  49. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +73 -18
  50. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -1
  51. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +134 -24
  52. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +155 -21
  53. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  54. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -10
  55. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
  56. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +39 -13
  57. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -2
  58. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -3
  59. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -6
  60. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +10 -39
  61. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +190 -0
  62. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -1
  63. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -42,7 +42,9 @@ operator gives in Fabric (Agents & access): callee `fabric`, capability `board.r
42
42
 
43
43
  The responder runs inside the service process, started after the instance lock like every other piece of state
44
44
  (SKILL.md, Step 3). [`sample_service.py`](../scripts/sample_service.py) does it behind `serve --board-project <uuid>`,
45
- off by default:
45
+ off by default (register it with the same `--board-project`, so its manifest lists the take-back tool below; a serve
46
+ whose board mode differs from the registered manifest at its start logs `service.manifest.mismatch` and leaves the
47
+ manifest as it is):
46
48
 
47
49
  ```python
48
50
  import fabric_board as fbd, fabric_hub as fhb
@@ -53,12 +55,13 @@ responder = fbd.Responder(
53
55
  handler=render, admit=gate, observe=look_on_disk, emit=log.append, service="example-agent")
54
56
  board = threading.Thread(target=responder.run, args=(stop_event, 5.0), daemon=True) # SIGTERM: set stop_event
55
57
  board.start()
56
- # ... at stop, after stop_event is set: wait for it to leave its board session (a clean leave forgets its slot
57
- # records, FAA-155), bounded inside the kit's hard exit, as scripts/sample_service.py does
58
+ # ... at stop, after stop_event is set: wait for it to leave its board session (a clean leave forgets the slot
59
+ # records it confirmed, FAA-155), bounded inside the kit's hard exit, as scripts/sample_service.py does
58
60
  board.join(timeout)
59
61
  ```
60
62
 
61
- Node: `new Responder({...same names in camelCase})`, then `responder.run({ signal, drain })`. A service that lists
63
+ Node: `new Responder({...same names in camelCase})`, then `const running = responder.run({ signal, drain })`; at stop,
64
+ abort the signal and `await running`, bounded inside the hard exit (the run leaves its board session before it settles). A service that lists
62
65
  `fabric-project-comms/0.1` in its well-known `protocols` (DEC-0067: only once its hosts are pinned at or after it)
63
66
  declares that it answers board requests; the probe checks that declaration.
64
67
 
@@ -144,6 +147,28 @@ answers `not_available` or `capacity_exceeded`, which do not say whether the rep
144
147
  in line and the next tick asks again, and a restart asks with the same old fence. A request leaves the resume queue only
145
148
  once it has resumed: when the `com.complete` that sends a known outcome loses its answer, the next tick sends the same
146
149
  key again in the same session (FAA-86). `shutdown()` drains, then leaves the session (its DELETE releases the slot).
150
+ An idle responder waits 3 s at most for that DELETE (`IDLE_LEAVE_SECONDS`), so an idle stop ends within the org's 5 s
151
+ (LC-01, FAA-173); one with work waits up to the session's 15 s client timeout. A board call already in flight when the
152
+ stop comes (a poll against a slow hub) runs first; when the service's bounded join ends before the DELETE is sent, the
153
+ slot lapses on the board's clock as before. The slot records are forgotten only after
154
+ a DELETE Fabric answered with a 2xx (a 404, the session already gone, keeps them: a move made before it ended is read at the next `com.status`), also when a session is dropped on a live board (FAA-163): an unanswered one keeps
155
+ them, and the slot lapses in 60 s.
156
+
157
+ **One capability at a time (FAA-163).** At connect (and every reconnect) the responder starts with no slot. Each
158
+ capability's enroll or replace is settled on its own: a refusal of that capability (`capacity_exceeded`,
159
+ `invalid_arguments`, any code that is not about the board session) leaves it `retry`, asked again alone after 15 s,
160
+ 30 s, doubling to 15 minutes, with one `board.slot.refused` event (warning; said again if it is refused again after
161
+ the slot was held); its accepted work waits `lapsed` and goes back
162
+ by a transfer once the slot is held. The capabilities after it are recovered as before. A refusal of the board session
163
+ (`unreachable`, `session-ended`, `not_available`, `not_authorized` and the other codes in `SESSION_CODES`) leaves the
164
+ rest `retry` too, says no `board.slot.refused`, and goes to the responder's fault, which says it by code:
165
+ `board.access.needed` for `not_authorized`, `authority_changed` and `credential` (the session is left),
166
+ `board.blocked` for a defect (`no-epoch`, `schema-unsupported`, `checkpoint`, `receipt-shape`,
167
+ `idempotency_conflict`), and `board.down` for the rest: `unreachable` keeps the session while a held slot's lease runs
168
+ (so a lost enroll of one capability is asked again at its next retry), `not_available` keeps it except at connect or recovery with no slot held,
169
+ and `session-ended`, `not-running` and the other hub codes drop it (as a fault during connect or recovery does) and
170
+ reconnect. A slot whose lease lapsed (`lease_expired`) holds
171
+ no lease for that decision.
147
172
 
148
173
  ## When the operator holds work (DEC-0079)
149
174
 
@@ -178,9 +203,16 @@ held and not transferred). The responder keeps that move instead of undoing it:
178
203
  {project, generation, moved?, asking?}}`). The record is written when a receipt's generation changes and before an
179
204
  enroll or a replace that can change it; a live holder's renew (about every 20 s) cannot, and writes nothing. At a
180
205
  clean leave (the service stopped; the session's DELETE released its slots) the record of a slot with no request left
181
- in the checkpoint and not moved is forgotten (a moved slot's record stays, so a clean stop never undoes a move): the slot is free, and whoever takes it next is no move. A file without the record
182
- reads as before; a kit before 0.53.0 reads the file, ignores the record and drops it at its first checkpoint write. So a service stopped cleanly with no request left in its checkpoint, rolled back to 0.52 and up again starts as on a first
183
- start. After a crash the record stays: if 0.52 then ran, enrolled the slot and
206
+ in the checkpoint and not moved is forgotten when this session confirmed its generation (held it, paused under the
207
+ operator's hold included, or read it unmoved in `com.status`) and no fenced call has put in doubt since: the slot is
208
+ free, and whoever takes it next is no move. An unconfirmed record stays, since the slot may have moved while the
209
+ service was down, and a move this session saw keeps its record, so a clean stop never undoes it. A paused or
210
+ retrying slot reads `com.status` before each new ask. A move made after the slot's last renew or check and before
211
+ the stop, with no call since, is not seen at the leave: its record is forgotten, and the next start reads the slot
212
+ as free and may enroll it once the other holder lets it go (FAA-187). A file without the record
213
+ reads as before; a kit before 0.53.0 reads the file, ignores the record and drops it at its first checkpoint write. So a service stopped cleanly with no request left in its checkpoint, its DELETE answered 2xx and every slot confirmed
214
+ in that session, rolled back to 0.52 and up again starts as on a first start. Otherwise (a 404, the idle wait running
215
+ out, a slot this session never confirmed, one left retrying before `com.status` answered for it included) its records stay, and the case is the crash case below. After a crash the record stays: if 0.52 then ran, enrolled the slot and
184
216
  wrote nothing to the checkpoint, the next 0.53 start reads that enroll as a move; bring it back with the service's take-back action (below).
185
217
  - An enroll or a replace is written into that record with its key before it is sent (`asking`). When its answer is
186
218
  lost, the next connect (or the next enroll) sends the same call again under the same key; the board answers the
@@ -205,8 +237,8 @@ held and not transferred). The responder keeps that move instead of undoing it:
205
237
  holder, or the estate owner, reconciles it now). No `com.claim`, `com.renew`, `com.complete`, `com.enroll` or
206
238
  `com.responder_replace` is sent for that capability again: not at recovery after a restart, and not when the new
207
239
  holder's lease lapses, nor when com.status later names no generation for the slot (a recorded move stays until
208
- take-back). Accepted requests stay held on the board, unanswered, until the slot is taken back (CT-175
209
- will hand them to the new holder). One `board.slot.moved` event (warn, notify) says so, once, also across restarts
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
210
242
  (the record keeps `moved`).
211
243
  - A responder that never held the slot (a first start: no record, or a record of another Project), or whose record
212
244
  equals the current generation, behaves as before: it enrolls, recovers and replaces its own lapsed work. A recorded or
@@ -216,18 +248,28 @@ held and not transferred). The responder keeps that move instead of undoing it:
216
248
  records its own generation. Only a replay the board refuses, or one that ran for the first time (`repeated: false`)
217
249
  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
218
250
  unused. A generation written `2.0` is `2`.
219
- - Only the operator brings the slot back: `take_back(capability)` (`takeBack`) forgets the record; never edit the
251
+ - Only the operator brings the slot back: `take_back(capability)` (`takeBack`) forgets the record, and wins over a move
252
+ being read at the same time: the move is written under the responder's lock (Node: on the fence queue, after any fenced
253
+ call in flight) and only while the record is unchanged (FAA-158). Never edit the
220
254
  checkpoint's `slots` record by hand to undo a move. In the same board
221
255
  session the next tick enrolls once the other session lets the slot go (a live holder is waited for) and takes the work
222
- left `lapsed` back with a transfer. A restart or reconnect before that, with accepted work held for the slot, takes it
256
+ left `lapsed` back with a transfer. A restart or reconnect before that, with accepted work waiting for the slot, takes it
223
257
  back at once by `com.responder_replace`, as a restart did before 0.53.0: the operator asked for the slot back, so the
224
258
  other session is replaced. In recovery, a refusal that leaves no slot held reconnects; one that leaves some held keeps
225
- the session and serves them. The kit gives it no route of its own (the reference service has none yet,
226
- FAA-157): a service built on the kit declares it as an operator action, a button with its tool ([Every button has a
227
- tool](../SKILL.md), `fabric-actions/0.1`) with gate `relayed` (the operator's decision), never `none`, so no other
228
- agent can call it on its own; on a server that reads mandates (`agent_id`) it also names its effect class, for example
229
- `change-policy` (DEC-0125); until Fabric's hub forwards decisions (CT-125) the operator reaches it through the
230
- service's page or its elicitation, and names it in its docs; otherwise a moved slot stays moved. A move back to this service's own session is not
259
+ the session and serves them. The kit gives it no route of its own. The reference service (`sample_service.py`) offers
260
+ it on its dashboard (FAA-157): a moved slot shows a Take back button that posts `POST /api/board/take-back` from the
261
+ operator's signed-in session with the `X-Fabric-Request` header; the service token alone is refused. Since 0.56.0 the
262
+ button has its tool, `board.take-back` with `{capability}`, declared under `extensions/actions/0.1` with gate
263
+ `relayed` and target `capability`, and a capability of the manifest like every tool the well-known lists
264
+ (`interop.well-known-capabilities`): an agent whose MCP client sets `_meta` calls it over `/mcp` with the operator's
265
+ decision in `_meta.decision` (a call without one is `operator-decision-required`); Claude Code and Codex send
266
+ arguments only, so until CT-125 they hand the operator the page as Fabric Dashboards' `open_link`. Either door records
267
+ the take-back as a `board.slot.returned` event (info, the capability as its subject) whose sentence says the route in
268
+ words and whose `data` holds `by`, `via` and the `caller` (none when the feed refuses the caller), never the decision's words; a repeat after a lost answer is answered `{capability, moved: false}`. A service built on the kit declares it as an operator action, a button with its tool ([Every button has a
269
+ tool](../SKILL.md), `fabric-actions/0.1`) with gate `relayed` (the operator's decision), never `none`, so a call
270
+ 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
+ `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
231
273
  detected yet: the receiving side and the hand-over of accepted requests (contract CT-175) wait for Fabric's session
232
274
  surface.
233
275
 
@@ -251,11 +293,19 @@ raises `directory-invalid` rather than answering; an unlisted Project may still
251
293
 
252
294
  ## Events, the probe and the live smoke
253
295
 
254
- Events go to `emit(kind, level, text, notify=…, data=…)` (`JsonlEventLog.append` fits) with `data {schema, value}`
296
+ Events go to `emit(kind, level, text, notify=…, subject=…, data=…)` (`JsonlEventLog.append` fits: every level is one
297
+ of its `info`, `notice`, `warning`, `error`; `subject` is `{type, id}`, the notice's request, else its capability, else
298
+ its episode (such as a block's cause), else the Project; in Python it goes only to an `emit` whose signature names a `subject`
299
+ parameter with no value bound, as `JsonlEventLog.append` does, and any other `emit`, one with only `**kwargs`, one with
300
+ a subject bound by `functools.partial` (which then stands) or one whose signature cannot be read, is called once, without
301
+ one, as before 0.56.0; the Node responder always passes it in its options, which an `emit` that does not read it ignores) with
302
+ `data {schema, value}`
255
303
  checked by FAC-SEM-101 (no credential in data): `board.request.completed`, `board.request.refused`,
256
304
  `board.request.skipped`, `board.request.reconciled`, `board.request.held`, `board.attempt.lost`,
257
- `board.reconcile.needed`, `board.slot.contested`, `board.slot.moved`, `board.access.needed`, `board.blocked`,
258
- `board.down`, each notifying episode said once.
305
+ `board.reconcile.needed`, `board.slot.contested`, `board.slot.moved`, `board.slot.refused`, `board.access.needed`, `board.blocked`,
306
+ `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`,
308
+ `board.slot.released` and `board.request.waiting`. The reference service adds `board.slot.returned` at a take-back.
259
309
 
260
310
  `check_service.py <id> [--board-kit <the service's fabric_board.py or fabric-board.mjs>]` rule **board.responder**:
261
311
  `NOT_RUN` when the well-known `protocols` names no `fabric-project-comms` revision; `FAIL` for a revision this kit does
@@ -45,7 +45,12 @@ const answer = await request('GET', 'https://api.example/v1/items', { headers, d
45
45
 
46
46
  Every answer comes back as a `Response`, whatever its status; `HttpError` means no usable answer came, with `kind`
47
47
  `connection`, `timeout`, `too-large` or `refused`, the status when there was one, and the URL redacted to scheme,
48
- host, port and path (`redact_url`). An error never carries a request header or body.
48
+ host, port and path (`redact_url`); a URL that is not http or https keeps only its scheme (`htps:<redacted>`), and
49
+ one that does not parse as a URL is `<unparseable url>`. The path is not redacted (Node writes it as `new URL`
50
+ normalises it, Python as given), so a key never belongs in it. Python's `check_url` refuses a host
51
+ with a backslash, space, control character (C0, DEL or C1) or `%`, a URL with a space or ASCII control character before its
52
+ first `#` (the default transport sends a URL up to there, as Node's does; a transport you pass receives the URL as given), or a character outside ASCII in its path or query (ASCII whitespace at its ends is trimmed, as urllib and `new URL` trim it; a Unicode space ending the part before the first `#` is refused, where Node keeps it percent-encoded), a bracketed host that is not an IPv6 address with no zone, and a port that is not
53
+ ASCII digits, all as kind `refused` before sending; it checks a host's shape, not its name. `redact_url` on Python reads a host holding `%` as `<unparseable url>`; Node writes it decoded. An error never carries a request header or body.
49
54
 
50
55
  ## fabric_openrouter
51
56
 
@@ -77,6 +82,7 @@ const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
77
82
  | The fallback | required, of another vendor (the part before `/` differs) |
78
83
  | Content | text only: a string, null, or parts that are all `{"type": "text"}`; an image, audio or file part is refused (`config`), because what it is billed cannot be bounded from its bytes |
79
84
  | What the ceiling does not cover | the `provider` routing param can send a call to a provider priced above the catalog; two `Client` objects sharing one ledger each hold only their own calls in flight, so give an agent one client per key |
85
+ | The base URL | `base_url` (`baseUrl`), by default `https://openrouter.ai/api/v1`: checked by `check_url` when the client is built; ASCII whitespace and trailing `/` at its ends are trimmed (Node also trims every C0 control at the ends and removes a tab or line break anywhere, as `new URL` reads them); a base that is not a string is refused as `check_url` refuses it (`HttpError` kind `refused`, both twins); a query or a fragment is refused (kind `config`, both twins, before `check_url`). Python also refuses a space, a control or a non-ASCII character in the path (`check_url`'s `HttpError` kind `refused`); Node sends it percent-encoded, as `new URL` writes it (FAA-187 (18)) |
80
86
  | Attribution | off unless an `attribution` setting is given (FAC-SEM-098, `fabric_usage.attribution_headers`) |
81
87
  | Request body | `model`, `messages`, `max_tokens`, `usage: {include: true}` and your params (temperature, reasoning, response_format, tools); `stream`, `model`, `messages`, `max_tokens` and `usage` as params are refused |
82
88
 
@@ -124,6 +124,15 @@ page as Fabric Dashboards' `open_link`; both kits carry the same sentence. The g
124
124
  `JwksCache` holds the estate's key set at most 10 minutes and, on a `kid` it does not hold, re-reads at most once
125
125
  a minute (DL-15), and waits 10 s after a failed read before trying again; after 10 minutes without a successful read it answers nothing, and the verifier says
126
126
  `issuer-unavailable`. It reads https, http on loopback only, or a file; a URL with credentials is refused. The
127
+ Python kit also refuses, https included, a URL holding a space or ASCII control character before its first `#` (it
128
+ reads the URL up to there, as Node does), or a character outside ASCII in its path or query (urllib would send it
129
+ and every read fail; a trailing ASCII space or line break is trimmed as urllib reads it; a Unicode space ending the
130
+ part before the first `#` is refused, as `check_url` refuses it, because Node keeps it percent-encoded (urllib would
131
+ trim it and read the keys when it ends the URL); a leading space is refused as not a URL), a host holding `%`, a backslash, a Unicode space or a control character (urllib
132
+ would decode or dial it as another host), a bracketed host that is no IPv6 address, a port that is not ASCII digits or is above
133
+ 65535, no host, and an empty userinfo. The Node kit accepts several of these as `new URL` reads them, among them a `%`
134
+ host decoded, a backslash as `/`, a tab or line break removed, an empty userinfo dropped and an extra slash before the
135
+ host skipped. The
127
136
  Python kit verifies Ed25519 itself (RFC 8032, standard library only, strict as OpenSSL: `S` below the group order,
128
137
  canonical points, no small-order key); it follows no redirect when it reads the key set; the Node kit uses `node:crypto` and refuses small-order keys
129
138
  itself (`isSmallOrderKey`), because Node before 26 lets OpenSSL accept a signature under one.
@@ -149,7 +149,8 @@ operator's own coding agents: Claude Code and Codex cannot set `_meta`, so until
149
149
  a `relayed` gate turns "approve it" in chat into `operator-decision-required` with the page as the only route.
150
150
  Until then a service keeps its tools as they answer today, or gates a `relayed` tool only where it offers its own
151
151
  elicitation (the `elicited` hook). The one exception is the board responder's take-back action: it is declared
152
- `relayed` now and reached through the page or elicitation until CT-125 (`board.md`). This is D8's reason, which keeps `input_actions` off, applied to tools.
152
+ `relayed` now (the reference service's `board.take-back`); an agent whose MCP client sets `_meta` calls it with the
153
+ operator's decision, and Claude Code and Codex reach it through the page until CT-125 (`board.md`). This is D8's reason, which keeps `input_actions` off, applied to tools.
153
154
 
154
155
  **Parity (AP-1).** Every action the dashboard, app or page lets a person take (approve, reject, choose, edit, pause,
155
156
  resume, cancel, retry, send, publish, restore, set a policy, any other) has a tool on the MCP surface with the same
@@ -201,8 +202,11 @@ well_known = fs.build_well_known(..., surfaces=surfaces, actions=server.action_t
201
202
  `caller-missing`: an action-gated server must pass `tier`, `identify` or `authorize`,
202
203
  since `handle` defaults to T2 and a T2 call names nobody without them). One fault reaches the caller by name:
203
204
  `mandate-capability-missing` (DEC-0141 MJ-1), read as `operator-decision-required` with that `data.detail`.
204
- `on_fault` hears it from `ctx.approval_step` at a job's start under the job's tool, and at the answer under
205
- `fabric.job.get`; at the start it hears any fault `job_step` answers (also `now-unreadable` from the server's clock).
205
+ `on_fault` hears it under the job's capability (the name the job was created under) both from `ctx.approval_step`
206
+ at a job's start, where the caller sees no refusal: the job waits `input_required`, its message naming the fault,
207
+ and at an answer through `fabric.job.get`, where the call is refused; at the start it hears any fault `job_step`
208
+ answers (also `now-unreadable` from the server's clock). A capability name is what `^[a-z][a-z0-9._-]{1,127}$`
209
+ matches (`reviews.approve`, never `reviewsApprove`); a job created under another name is read under that name.
206
210
  - **Arguments with no canonical form** (a lone surrogate, a NaN or an infinity that Python's `json.loads` lets in,
207
211
  nesting past the recursion limit) have no digest to bind: a relayed tool answers JSON-RPC `-32602`, never an exception.
208
212
  - **The handler** reads `ctx.record` (`"caller"`, or `{by, via, caller}`) and writes it into the service's audit
@@ -305,7 +309,10 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
305
309
  step is kept in the job's private part (`request_input(..., steps=)`). An answer's `_meta.mandate` that covers that
306
310
  step is then read when `input_actions=True`, beside a decision for the key. The rule alone is `job_step(mandate, ...)`
307
311
  / `jobStep({mandate, tier, now, self, capability, key, effect, callerAgent, spendUsd, project})`; Node's
308
- `requestInput(jobId, inputRequests, statusMessage, {steps})` keeps the same private part. Each `request_input`
312
+ `requestInput(jobId, inputRequests, statusMessage, {steps})` keeps the same private part. Node has no
313
+ `approvalStep`, so a Node service calls `jobStep` itself and passes `{}` when the start call's `_meta` carried
314
+ `mandate: null` (`Object.hasOwn(meta, 'mandate') && meta.mandate === null ? {} : meta.mandate`): `null` is no mandate
315
+ to `jobStep`, while the answer reads it as an unreadable one (FAA-178). Each `request_input`
309
316
  replaces the kept steps, and an answer clears them: pass `steps=` again on every question whose answer a mandate may
310
317
  cover. The answer's mandate is read under the job's own capability, as at the start (`action_gate(...,
311
318
  mandate_capability=)` / `actionGate({mandateCapability})`), so the start and the answer agree whatever the tool is
@@ -328,7 +335,9 @@ lead, at, expiresAt})` (a day unless told, at most seven), hands it to the agent
328
335
  `hand_on(mandate, callee, at, narrower=None)` / `handOn(mandate, callee, at, narrower)` (the chain gains the callee; a
329
336
  wider scope, a fifth agent, an agent already in it or a time at the expiry is refused; `source` travels unchanged), and
330
337
  sends it with `request_meta(traceparent, mandate=...)` / `requestMeta(traceparent, {mandate})`, which refuse one
331
- `FAC-SEM-144` refuses. `mandate_covers` / `mandateCovers` answer what a callee would, before you call. Never write a
338
+ `FAC-SEM-144` refuses. `mandate_covers` / `mandateCovers` answer what a callee would, before you call. To cover a
339
+ job's approval steps, name the job's capability in `capabilities` (or leave `capabilities` out) and the steps' keys in
340
+ `actions`; never `fabric.job.get`, which covers no step of another capability (DEC-0141 MJ-1). Never write a
332
341
  mandate from another agent's text, a page or a file; a callee hands on only what it received, narrowed.
333
342
 
334
343
  A relay the operator's rule accepts (DEC-0126 OA-2, OA-3: one of his own Projects on his board, provenance `trusted`,
@@ -87,6 +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
91
  | L4 | `fabric_board_session` | the board responder: the work context, the fenced request, transfer and recovery |
91
92
  | L4 | `fabric_board_fixture` | the scripted board the probe runs a responder against |
92
93
  | L4 | `fabric_board` | the facade of the board connector ([board](board.md)) |
@@ -55,7 +55,7 @@ that document until `service.id` matches (at most 40 s) before it calls the serv
55
55
 
56
56
  | Id | Rule | Here | Kit (Python / Node) |
57
57
  |---|---|---|---|
58
- | LC-01 | **Quit reaches exit within a deadline.** Every trigger (`SIGTERM`, `SIGINT`, logout, the app's own quit) ends the process: within 5 s idle, within 10 s with work in flight, then a hard exit (a board responder leaving its session can take an idle stop up to that hard exit). Nothing starts after quit begins | [Shutdown](#shutdown) | `Drain` / `Drain` |
58
+ | LC-01 | **Quit reaches exit within a deadline.** Every trigger (`SIGTERM`, `SIGINT`, logout, the app's own quit) ends the process: within 5 s idle, within 10 s with work in flight, then a hard exit (an idle board responder waits 3 s at most for its session's DELETE, FAA-173; one with work can take up to that hard exit). Nothing starts after quit begins | [Shutdown](#shutdown) | `Drain` / `Drain` |
59
59
  | LC-02 | **Every child has an owner, a deadline and a process group.** No subprocess without a timeout; on timeout or the parent's exit the whole group goes; no orphan survives. Locks and shared resources are released in `finally` | your own code | none |
60
60
  | LC-03 | **A job is bounded and exclusive.** One copy holds `service.lock`; a scheduled job has a watchdog shorter than its interval, shares its lock with every manual entry point, bounds every network call and records each run's outcome. Long work is journalled before it starts | [One copy under a supervisor](#one-copy-under-a-supervisor) | `hold_single_instance` / `holdSingleInstance` |
61
61
  | LC-04 | **Credentials are silent after setup.** No background prompt; a credential read that is denied or absent is cached and backs off until a person acts | [Directories](#directories) (secret files 0600) | none |
@@ -162,7 +162,10 @@ path on the service origin, never an absolute URL. Optional SSE stream at
162
162
  **Machine-readable `data` (DEC-0070).** An event may carry `data {schema, value}` beside its sentence: `schema` is the
163
163
  absolute URI of a payload schema the service publishes; `value` is an object of at most 32 members, each a scalar (a
164
164
  string of at most 500 characters, a number, a boolean, null), a list of at most 32 scalars, or an object of at most 32
165
- scalar members. `text` stays the event: a host that ignores `data` loses nothing, and one that does not know the schema
165
+ scalar members: two levels at most, so `value.cited` may be a list of ids or an object of counts, never a list of
166
+ objects (FAA-122). Flatten a nested record into members of its own, `{"citedIds": ["a1", "b2"], "citedCoverage":
167
+ [0.8, 0.4]}` in place of `{"cited": [{"id": "a1", "coverage": 0.8}, ...]}`; the refusal names the first place that
168
+ nests too deep (`data.value.cited[0] is an object`). `text` stays the event: a host that ignores `data` loses nothing, and one that does not know the schema
166
169
  treats `value` as opaque. No string in `value` sits under a credential's name or has a credential's shape
167
170
  (`FAC-SEM-101`); personal data is the service's own duty. `make_event` / `makeEvent` (and `JsonlEventLog.append`) take
168
171
  `data=` and refuse one that breaks either rule, naming the place, never the value; `check_service.py` checks the feed it
@@ -10,7 +10,7 @@ import { BoardError, RESPOND_TOOLS, isObj, refusalProblems } from './fabric-boar
10
10
  /** MCP over HTTP with a board session's one-session bearer; calls are serialised; close() sends the session's DELETE. */
11
11
  export class BoardSession {
12
12
  constructor(grant, { timeout = 15000, post = postMcp } = {}) {
13
- Object.assign(this, { grant, timeout, post });
13
+ Object.assign(this, { grant, timeout: timeout > 0 ? timeout : 15000, post }); // not above 0 is the default
14
14
  this.mcpSession = null;
15
15
  this.protocolVersion = MCP_PROTOCOL_VERSION;
16
16
  this.rpc = 0;
@@ -23,9 +23,9 @@ export class BoardSession {
23
23
  return run;
24
24
  }
25
25
 
26
- async #send(body, method = 'POST') {
26
+ async #send(body, method = 'POST', timeout = null) {
27
27
  try {
28
- return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
28
+ return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: timeout > 0 ? timeout : this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
29
29
  } catch (e) {
30
30
  if (e instanceof HubError) throw new BoardError('unreachable', 'Fabric\'s hub did not answer; a write may have been stored, so send it again with the same idempotency key.', true);
31
31
  throw e;
@@ -66,15 +66,19 @@ export class BoardSession {
66
66
  });
67
67
  }
68
68
 
69
- close() {
69
+ /** Sends the DELETE (waiting `timeout` ms at most); true when Fabric answered it with a 2xx, so the slots are free now (FAA-163). A 404 (already gone) is false: a move made before it ended is read at the next com.status, so the slot records stay. */
70
+ close(timeout = null) {
70
71
  return this.#serial(async () => {
71
- if (!this.mcpSession) return;
72
+ if (!this.mcpSession) return false;
73
+ let status = 0;
72
74
  try {
73
- await this.#send({}, 'DELETE');
75
+ const answer = await this.#send({}, 'DELETE', timeout);
76
+ status = Number.isInteger(answer?.status) ? answer.status : 0; // an answer it cannot read is no release, as in Python
74
77
  } catch {
75
78
  // best effort: the lease lapses in 60 s anyway
76
79
  }
77
80
  this.mcpSession = null;
81
+ return status >= 200 && status < 300;
78
82
  });
79
83
  }
80
84
  }
@@ -135,14 +139,17 @@ export class Checkpoint {
135
139
  this.slots = slots;
136
140
  }
137
141
  /**
138
- * At a clean leave (the session's DELETE released the slots): forget the record of each slot this service held for
139
- * `project` with no request left and not moved away, so a later generation is another session taking a free slot, and
142
+ * At a clean leave (the session's DELETE released the slots): forget the record of each slot this session confirmed
143
+ * (`held`, required: capability to the generation it held, or com.status showed unmoved) at that generation or below, for `project`, with no request left and not moved away, so a later generation is another session taking a free slot, and
140
144
  * a kit rolled back to before 0.53.0 leaves nothing that reads as a move. A record that cannot be written stays.
141
145
  */
142
- leave(project) {
146
+ leave(project, held) {
143
147
  const busy = new Set(Object.values(this.entries).map((e) => e.capability));
144
148
  for (const [capability, record] of Object.entries(this.slots)) {
145
149
  if (!isObj(record) || record.project !== project || 'moved' in record || busy.has(capability)) continue;
150
+ // Only a slot this session confirmed, at or below that generation: an unconfirmed record may be a move (0.56.0 seam r2, r3).
151
+ const confirmed = Object.hasOwn(held, capability) ? held[capability] : null;
152
+ if (!Number.isInteger(record.generation) || record.generation < 1 || !Number.isInteger(confirmed) || record.generation > confirmed) continue;
146
153
  try {
147
154
  this.slot(capability, null);
148
155
  } catch {