@passioncode-ai/passioncode 0.2.41 → 0.2.43

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 (53) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +4 -1
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +85 -13
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +22 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +44 -6
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +48 -9
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -4
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +7 -2
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +30 -15
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +33 -3
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +146 -7
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +5 -0
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +606 -0
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +48 -525
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +43 -6
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -4
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +48 -15
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +4 -3
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +39 -16
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +34 -3
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +149 -45
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +8 -1
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -11
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +86 -642
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +766 -0
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +70 -12
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +59 -8
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +56 -16
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +4 -2
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +314 -25
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +13 -1
  43. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  44. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +4 -1
  45. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -0
  46. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +75 -36
  47. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +28 -14
  48. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +42 -12
  49. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
  50. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +12 -3
  51. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +12 -2
  52. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +360 -16
  53. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -148,7 +148,8 @@ and Fabric's hub forwarding decisions (CT-125) have landed (DEC-0121 *Compatibil
148
148
  operator's own coding agents: Claude Code and Codex cannot set `_meta`, so until the hub forwards `call.decision`
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
- elicitation (the `elicited` hook). This is D8's reason, which keeps `input_actions` off, applied to tools.
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
153
 
153
154
  **Parity (AP-1).** Every action the dashboard, app or page lets a person take (approve, reject, choose, edit, pause,
154
155
  resume, cancel, retry, send, publish, restore, set a policy, any other) has a tool on the MCP surface with the same
@@ -237,8 +238,8 @@ Contract DEC-0125 (`mandate/1`, rule `FAC-SEM-144`, `docs/specification/delegati
237
238
  MD-6): the operator's instruction for one task travels with the task, narrowed at each hand-over, so a service the
238
239
  task reaches takes a call the mandate covers as his decision without asking him again. It is the machine form of his
239
240
  rule of 2026-10-26: one approval he gives in the chat of any of his agents binds all his agents (working-with-fabric,
240
- Step 5). Both kits carry the reference in `fabric_mandate` / `fabric-mandate.mjs` and answer the contract's
241
- `mandate.json` vectors case for case.
241
+ Step 5), which the contract states as DEC-0126 (OA-1 to OA-7). Both kits carry the reference in `fabric_mandate` /
242
+ `fabric-mandate.mjs` and answer the contract's `mandate.json` vectors case for case, its `handOn` group included.
242
243
 
243
244
  **As a service.** Name your own agent id and each relayed action's effect class; the gate then reads the call's
244
245
  `_meta.mandate` when it carries no decision:
@@ -272,20 +273,58 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
272
273
  (and `estimateUsd`, `perCallMaxUsd` for spend); an invalid one is `decision-invalid` with `mandate-malformed`,
273
274
  `mandate-widened`, `mandate-expired`, `mandate-early` or `mandate-not-for-this-call`. A `change-policy` effect is
274
275
  never covered (AP-5); human steps never travel (MD-6).
276
+ - **Where the operator spoke (DEC-0126).** A mandate or a decision may carry `source` (`{project, session or
277
+ thread}`), set by the agent that heard the operator's words relayed from another of his agents (OA-2). The kits read
278
+ it exactly as `mandate.schema.json#/$defs/source` does (an unplaced one is `mandate-malformed` or `decision-invalid`)
279
+ and return it in the record: `ctx.record`, `input_records` and `job_step`'s record carry `source` (OA-6). Keep it,
280
+ as `instruction`, in the service's own audit record, never in the events feed, a usage report, a log line or a page
281
+ another party reads (OA-7).
282
+ - **Listing `operator-source/1`.** A service that reads `source` lists `operator-source/1` in its well-known
283
+ `protocols` (OA-7): `McpToolServer.well_known_protocols(protocols)` returns your list with it added when the server
284
+ has `agent_id` or serves a relayed action, for `build_well_known(protocols=server.well_known_protocols([...]))`. A
285
+ service that builds its own list (a Node server, or one without `McpToolServer`) adds `operator-source/1` itself
286
+ when it reads `source`; a caller that sees no such entry sends the call without `source`, and it is taken as before.
275
287
  - `action_gate(..., self_id=, effect=, caller_agent=, spend_usd=, project=)` / `actionGate({..., self, effect,
276
288
  callerAgent, spendUsd, project})` is the same gate for a server of your own; `action_decision` / `actionDecision`
277
289
  take `mandate`, `self`, `callerAgent`, `effect`, `spendUsd` and `project` as the contract's function does
278
- (`effect-missing`, `self-missing` are the service's own faults). A job's input request (`input_actions=True`) does
279
- not read a mandate yet.
290
+ (`effect-missing`, `self-missing` are the service's own faults), and the kit's own `mandateCapability` (a job's kept
291
+ step: the capability the mandate is read under), which the contract's function does not take; like `effect`, it is
292
+ the service's own input, never the caller's.
293
+ - **A job's approval step (interop C3.3).** A job reads the mandate its start call carried at its own approval step:
294
+ `ctx.approval_step(job_id, key, request, effect=, spend_usd=, project=, message=)` on the start call's context (a
295
+ server with `agent_id`) answers the record when the mandate covers the step `{capability: the job's, action: key,
296
+ effect}` and asks nothing, the job going on as the operator's decision; otherwise the job waits `input_required` on
297
+ `request`, its message naming the part not covered (`outside-mandate: <part>`) or saying the mandate expired, and the
298
+ step is kept in the job's private part (`request_input(..., steps=)`). An answer's `_meta.mandate` that covers that
299
+ step is then read when `input_actions=True`, beside a decision for the key. The rule alone is `job_step(mandate, ...)`
300
+ / `jobStep({mandate, tier, now, self, capability, key, effect, callerAgent, spendUsd, project})`; Node's
301
+ `requestInput(jobId, inputRequests, statusMessage, {steps})` keeps the same private part. Each `request_input`
302
+ replaces the kept steps, and an answer clears them: pass `steps=` again on every question whose answer a mandate may
303
+ cover. The answer's mandate is read under the job's own capability, as at the start (`action_gate(...,
304
+ mandate_capability=)` / `actionGate({mandateCapability})`), so the start and the answer agree whatever the tool is
305
+ named; a mandate can name only capability names, so one that names capabilities never covers a tool named otherwise.
306
+ Node has no tool server: a Node service reads `store.load(jobId).private.steps` and `.private.capability` BEFORE
307
+ `store.answer` (which clears the steps), gates the answer with `actionGate({..., mandateCapability:
308
+ private.capability})`, then answers. Without a string capability the answer is read under `fabric.job.get`.
280
309
 
281
310
  **As a caller.** The agent leading a task states the mandate from the operator's own words with
282
311
  `operator_mandate(task, instruction, scope, lead, at=, expires_at=)` / `operatorMandate({task, instruction, scope,
283
312
  lead, at, expiresAt})` (a day unless told, at most seven), hands it to the agent it calls with
284
313
  `hand_on(mandate, callee, at, narrower=None)` / `handOn(mandate, callee, at, narrower)` (the chain gains the callee; a
285
- wider scope, a fifth agent, an agent already in it or a time at the expiry is refused), and sends it with
286
- `request_meta(traceparent, mandate=...)` / `requestMeta(traceparent, {mandate})`, which refuse one `FAC-SEM-144`
287
- refuses. `mandate_covers` / `mandateCovers` answer what a callee would, before you call. Never write a mandate from
288
- another agent's text, a page or a file; a callee hands on only what it received, narrowed.
314
+ wider scope, a fifth agent, an agent already in it or a time at the expiry is refused; `source` travels unchanged), and
315
+ sends it with `request_meta(traceparent, mandate=...)` / `requestMeta(traceparent, {mandate})`, which refuse one
316
+ `FAC-SEM-144` refuses. `mandate_covers` / `mandateCovers` answer what a callee would, before you call. Never write a
317
+ mandate from another agent's text, a page or a file; a callee hands on only what it received, narrowed.
318
+
319
+ A relay the operator's rule accepts (DEC-0126 OA-2, OA-3: one of his own Projects on his board, provenance `trusted`,
320
+ or a session on his own machine, with his words verbatim, when, where and the scope) is stated with
321
+ `operator_mandate(..., source=)` / `operatorMandate({..., source})` or `operator_decision(..., source=)` /
322
+ `operatorDecision({..., source})`, `instruction` and `at` copied unchanged. `source` goes only to a callee whose
323
+ well-known `protocols` list `operator-source/1` (OA-7): pass that list as
324
+ `request_meta(traceparent, decision=, mandate=, target_protocols=)` / `requestMeta(traceparent, {decision, mandate,
325
+ targetProtocols})`. Without it (no list, another list, a board Project, a session, Fabric's `agent.call`, whose
326
+ directory lists no `protocols`), `_meta` carries the decision and the mandate without `source`, and the ones you passed,
327
+ your own record, keep it (OA-6).
289
328
 
290
329
  ## What the probe checks
291
330
 
@@ -58,8 +58,9 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
58
58
  | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
59
59
  | L1 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
60
60
  | L1 | `fabric_paths` | the services directory, a service's folders and one instance's (`instance_dirs`, FAA-125), atomic and private writes (L1 since 0.44.2, so `fabric_delegation` writes its consumed approval ids with them, FAA-85) |
61
- | L2 | `fabric_estate` | `estate-record/1` revisions and their rules (DEC-0055) |
62
- | L2 | `fabric_mandate` | the operator's mandate `mandate/1`: what a callee takes under it (`mandate_covers`), FAC-SEM-144, stating and handing it on narrowed (DEC-0125); the effect closure, defined here once |
61
+ | L2 | `fabric_estate_rules` | the record's own rules without a verifier: FAC-SEM-072…076 and the rest, FAC-SEM-147 and the context hub keys (DEC-0055, DEC-0128), contexts, the digest and challenge, a passkey's key; split from `fabric_estate` in 0.52.0 (D8) |
62
+ | L2 | `fabric_estate` | `estate-record/1` revisions: the passkey assertion, the verifier, placement trust, `hub_key_certified` with its context (DL-14a, DEC-0128); re-exports `fabric_estate_rules` (DEC-0055) |
63
+ | L2 | `fabric_mandate` | the operator's mandate `mandate/1`: what a callee takes under it (`mandate_covers`), FAC-SEM-144, stating and handing it on narrowed (DEC-0125); where the operator spoke, `source`, and its withholding from a target that does not list `operator-source/1` (DEC-0126); the effect closure, defined here once |
63
64
  | L2 | `fabric_delegation` | the callee's side of `fabric-delegation/0.1` (DEC-0039) |
64
65
  | L2 | `fabric_share` | share grants and registrations (DEC-0086); whether an organization's instance may run under its grant (`instance_share_grant`, FAA-125) |
65
66
  | L2 | `fabric_ceremonies` | the headless join token (DEC-0084) |
@@ -81,7 +82,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
81
82
  | L3 | `fabric_supervisors` | launchd, systemd user units, Task Scheduler (DEC-0032, LC-14) |
82
83
  | L3 | `fabric_service` | the facade: every name 0.39.1 exported, re-exported from the modules above |
83
84
  | L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
84
- | L3 | `fabric_interop_rules` | `fabric-interop/0.1` rules without a service: constants, `InteropError`, trace context, job limits (DEC-0087; FAA-92) |
85
+ | L3 | `fabric_interop_rules` | `fabric-interop/0.1` rules without a service: constants, `InteropError`, trace context, job limits (DEC-0087; FAA-92), a job's approval step under the mandate (`job_step`, C3.3) |
85
86
  | L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools; re-exports the rule layer ([interop](interop.md)) |
86
87
  | L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
87
88
  | L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
@@ -103,7 +104,8 @@ public name 0.39.1 had, in both languages (the snapshot is `test/fixtures/kit-ap
103
104
  name fails `test_kit_api`), and the Python facade the private names other code reached through it (`_number`,
104
105
  `_DAY_MS`, `_utc_date_ms` from the core). `fabric_board` and `fabric-board.mjs` are
105
106
  facades the same way, and since 0.44.1 so are `fabric_interop` (over `fabric_interop_rules`) and `fabric_package` (over
106
- `fabric_package_schema`), with their Node twins: patch the rule or schema module, not the facade. What changed for a service that copies the kit: `fabric_service.py` alone is no longer enough;
107
+ `fabric_package_schema`), and since 0.52.0 `fabric_estate` (over `fabric_estate_rules`, listed just before it, since it
108
+ imports the rules), with their Node twins: patch the rule or schema module, not the facade. What changed for a service that copies the kit: `fabric_service.py` alone is no longer enough;
107
109
  install with `fabric_kit.py install` and `status` names what is missing. A test or service that replaced a function
108
110
  by assigning it on the facade (`fabric_service.X = ...`) no longer changes what the kit does: the code reads its own
109
111
  module's name, so patch the module that defines it (`fabric_lock.X`, `fabric_session_env.X`). A service that imported a module-private
@@ -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. 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 (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` |
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 |
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1: wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `e6c4fc16b415eec3190dd34d924d269405359dc9`
3
+ Pinned to `fabric-agent-contract` commit `1197a90dc4b57152cabcc71ad17db7559f35a27f`
4
4
  (`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
5
5
  DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
6
6
  delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
@@ -19,7 +19,12 @@ DEC-0113, a node summary against the certificate DEC-0114, key windows read stri
19
19
  gates each credential member DEC-0116 to DEC-0118, a pin of four members and `profile` DEC-0120; every action a person
20
20
  has as an agent tool, `fabric-actions/0.1` and the operator's relayed decision DEC-0121; `surfaces.a2a` and the public A2A
21
21
  Agent Card DEC-0122, a service's pages as Fabric Dashboards' `open_link` DEC-0123, Fabric's button in the page's language
22
- DEC-0124; the operator's mandate `mandate/1`, handed on narrowed with a task DEC-0125). The contract's schemas are normative;
22
+ DEC-0124; the operator's mandate `mandate/1`, handed on narrowed with a task DEC-0125; one approval binds all the
23
+ operator's agents, its `source` on a mandate and a decision DEC-0126; context hub keys and the organizations an estate
24
+ record pins DEC-0127 and DEC-0128; the family and the agent network named DEC-0130 and DEC-0137; a reply delivered to
25
+ the sender it answers, or to whom it names DEC-0135; a hand-over adds no naming list the scope in force leaves out
26
+ DEC-0136. The organization's member list, draft exchange, relay and the machines' report, DEC-0129, DEC-0131 to DEC-0134,
27
+ reach the kit only as protocol register entries). The contract's schemas are normative;
23
28
  this page is the working summary. Extension key:
24
29
  `https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
25
30
 
@@ -9,19 +9,22 @@
9
9
  // - actionsExtensionRules(wellKnown), actionRules(kind, value): FAC-SEM-142.
10
10
  // - actionsExtension(actions, { capabilities }): the `extensions` entry a service declares; it refuses a declaration
11
11
  // FAC-SEM-142 would find, so a service never publishes one.
12
- // - operatorDecision({action, instruction, at, target}): the decision an agent relays in _meta.decision (AP-2, AP-3),
13
- // refused when decision.schema.json would refuse it.
12
+ // - operatorDecision({action, instruction, at, target, source}): the decision an agent relays in _meta.decision (AP-2,
13
+ // AP-3), refused when decision.schema.json would refuse it.
14
14
  // - actionCaller(tier, authenticated): the caller a service records (AP-2): the identity it authenticated, else
15
15
  // local:T0 / local:T1 for a caller its local floor authenticated, never a name the call carries.
16
16
  // - DEC-0125: actionDecision reads a call's mandate (_meta.mandate) through fabric-mandate.mjs's mandateCovers when the
17
17
  // call carries no decision.
18
+ // - DEC-0126: a decision may carry `source` (where the operator said the words, OA-2); actionDecision returns it in
19
+ // recordAs (OA-6). A service that reads it lists operator-source/1 and keeps it, as `instruction`, in its own audit
20
+ // record only: never an event, a usage report, a log line or a page another party reads (OA-7).
18
21
  //
19
22
  // The kit has no JSON Schema validator: the two shapes are written here from the schemas, lengths in code points and
20
23
  // patterns over the whole string, as the contract's validator reads them. Members are an object's own members (a JSON
21
24
  // text has no other kind; an inherited member is not read). Never throws on what it reads. Node 20+, no dependency.
22
25
  import { ServiceError } from './fabric-base.mjs';
23
26
  import { isObject, jsonEqual } from './fabric-jsvalue.mjs';
24
- import { mandateCovers } from './fabric-mandate.mjs'; // DEC-0125
27
+ import { mandateCovers, sourceShape } from './fabric-mandate.mjs'; // DEC-0125, DEC-0126
25
28
  import { formatRfc3339, parseRfc3339Ms } from './fabric-time.mjs';
26
29
 
27
30
  export const ACTIONS_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/actions/0.1';
@@ -43,7 +46,7 @@ const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/u; // common.schema.json#/$defs/ca
43
46
  const SHA256 = /^sha256:[0-9a-f]{64}$/;
44
47
  const STEP_URL = /^(https:\/\/|\/)/;
45
48
  const ACTION_MEMBERS = new Set(['id', 'title', 'tool', 'gate', 'humanStep']);
46
- const DECISION_MEMBERS = new Set(['by', 'via', 'action', 'target', 'instruction', 'at']);
49
+ const DECISION_MEMBERS = new Set(['by', 'via', 'action', 'target', 'instruction', 'at', 'source']);
47
50
  const KEY_PATH = `/extensions/${ACTIONS_EXTENSION_KEY.replace(/~/g, '~0').replace(/\//g, '~1')}`;
48
51
 
49
52
  /** An own member that is not undefined, as the validator reads a member as present. */
@@ -91,15 +94,17 @@ export const readableAction = (action) => actionErrors(action, '').length === 0
91
94
 
92
95
  /**
93
96
  * decision.schema.json (DEC-0121 AP-2): by `operator`, via `relayed-instruction`, the action's id, an optional target
94
- * of 1..200, the operator's words of 1..500, and `at` read strictly (the contract's strictTime; ajv-formats' date-time
95
- * also admits a space for T and a leap second, which this refuses, decision D5).
97
+ * of 1..200, the operator's words of 1..500, `at` read strictly (the contract's strictTime; ajv-formats' date-time also
98
+ * admits a space for T and a leap second, which this refuses, decision D5), and an optional `source` as
99
+ * mandate.schema.json#/$defs/source reads it (DEC-0126).
96
100
  */
97
101
  export function decisionShape(value) {
98
102
  if (!isObject(value) || Object.keys(value).some((key) => !DECISION_MEMBERS.has(key))) return false;
99
103
  if (['by', 'via', 'action', 'instruction', 'at'].some((key) => !has(value, key))) return false;
100
104
  return value.by === 'operator' && value.via === 'relayed-instruction' && matches(REQUEST_KEY, value.action, 64)
101
105
  && (!has(value, 'target') || text(value.target, 1, 200)) && text(value.instruction, 1, 500)
102
- && typeof value.at === 'string' && !Number.isNaN(parseRfc3339Ms(value.at));
106
+ && typeof value.at === 'string' && !Number.isNaN(parseRfc3339Ms(value.at))
107
+ && (!has(value, 'source') || sourceShape(value.source));
103
108
  }
104
109
 
105
110
  const acceptedApproval = (approval) => isObject(approval) && member(approval, 'ok') === true && nonEmpty(member(approval, 'approver'));
@@ -116,12 +121,16 @@ const refuse = (code) => ({ ok: false, code });
116
121
  * accepted approval is the approver's; a T2 caller without one gets approval-required with the digest; an accepted
117
122
  * elicitation is the operator's; no decision is operator-decision-required; a decision that is not the schema's, is for
118
123
  * another action or object, or whose `at` is more than 60 s ahead of `now` or more than 24 h behind it is
119
- * decision-invalid; otherwise the operator's act, recorded with `caller`. DEC-0125: a call with no decision that carries
124
+ * decision-invalid; otherwise the operator's act, recorded with `caller` and, when the decision carries one, its `source`
125
+ * (DEC-0126 OA-6). DEC-0125: a call with no decision that carries
120
126
  * `mandate` (its `_meta.mandate`) is answered as mandateCovers answers, given `self` (the service's own agent id),
121
127
  * `callerAgent` (only an agent id the service authenticated), `effect` (the action's effect class, the service's own
122
- * knowledge), `spendUsd` and `project`: covered, {by: 'operator', via: 'mandate', task, chain, caller}. `{fault}`
128
+ * knowledge), `spendUsd` and `project`: covered, {by: 'operator', via: 'mandate', task, chain, source?, caller}. `{fault}`
123
129
  * (action-invalid, caller-missing, now-unreadable, digest-missing, url-missing, effect-missing, self-missing) is the
124
130
  * service's own and is never sent to a caller.
131
+ *
132
+ * Kit input beside the contract's: mandateCapability, a job's kept step's capability, read by the mandate in place of
133
+ * the action's tool (the contract's actionDecision does not take it; the service's own input, never a caller's).
125
134
  */
126
135
  export function actionDecision(inputs) {
127
136
  const field = (key) => member(inputs, key);
@@ -153,7 +162,8 @@ export function actionDecision(inputs) {
153
162
  if (decision.action !== action.id || (has(decision, 'target') && decision.target !== field('target'))) return refuse(DECISION_INVALID);
154
163
  const at = parseRfc3339Ms(decision.at);
155
164
  if (at > clock + CLOCK_SKEW_SECONDS * 1000 || at < clock - DECISION_LIFETIME_SECONDS * 1000) return refuse(DECISION_INVALID);
156
- return { ok: true, recordAs: { by: 'operator', via: 'relayed-instruction', caller } };
165
+ // A decision relayed from another of the operator's agents keeps where they said it (DEC-0126 OA-6).
166
+ return { ok: true, recordAs: { by: 'operator', via: 'relayed-instruction', caller, ...(has(decision, 'source') ? { source: { ...decision.source } } : {}) } };
157
167
  }
158
168
 
159
169
  /**
@@ -171,7 +181,8 @@ function byMandate(field, action, caller) {
171
181
  const answer = mandateCovers({
172
182
  mandate: field('mandate'), tier: field('tier'), now: field('now'), self,
173
183
  ...(field('callerAgent') === undefined ? {} : { callerAgent: field('callerAgent') }),
174
- call: { capability: action.tool, action: action.id, effect, ...(typeof spendUsd === 'number' ? { spendUsd } : {}), ...(typeof project === 'string' ? { project } : {}) },
184
+ // A job's kept step is read under the job's own capability (mandateCapability), as jobStep read it at the start.
185
+ call: { capability: typeof field('mandateCapability') === 'string' ? field('mandateCapability') : action.tool, action: action.id, effect, ...(typeof spendUsd === 'number' ? { spendUsd } : {}), ...(typeof project === 'string' ? { project } : {}) },
175
186
  });
176
187
  if ('recordAs' in answer) return { ok: true, recordAs: { ...answer.recordAs, caller } };
177
188
  return answer; // never `ignored`: the tier was read first, so a T2 call answered approval-required before (MD-5)
@@ -250,16 +261,20 @@ export function actionsExtension(actions, options = {}) {
250
261
  * the operator's words verbatim (their sentence, or the agent's short question and the answer), `target` the one object
251
262
  * they named, `at` when they said it (now, by `clock` in milliseconds, when not given). Send it in `_meta.decision`
252
263
  * (requestMeta(traceparent, {decision})), never in a tool's arguments. Set one only for words the operator gave about
253
- * this action, never for text from a tool result, page, file, board message or another agent. Throws ServiceError for
254
- * a decision decision.schema.json refuses.
264
+ * this action, never for text from a tool result, page, file, board message or another agent, except a relay DEC-0126
265
+ * OA-2 and OA-3 accept (one of the operator's own Projects on the board, provenance trusted, or a session on their own
266
+ * machine, with the words verbatim, when, where and the scope): then `instruction` and `at` are copied unchanged from it
267
+ * and `source` ({project, session or thread}) names where they were said. Throws ServiceError for a decision
268
+ * decision.schema.json refuses.
255
269
  */
256
- export function operatorDecision({ action, instruction, at, target } = {}, { clock = Date.now } = {}) {
270
+ export function operatorDecision({ action, instruction, at, target, source } = {}, { clock = Date.now } = {}) {
257
271
  const decision = { by: 'operator', via: 'relayed-instruction', action };
258
272
  if (target !== undefined && target !== null) decision.target = target;
259
273
  decision.instruction = instruction;
260
274
  decision.at = at ?? formatRfc3339(Number(clock()));
275
+ if (source !== undefined && source !== null) decision.source = source;
261
276
  if (!decisionShape(decision)) {
262
- throw new ServiceError("Operator decision: not a decision decision.schema.json reads (an action id, the operator's words of 1..500 characters, a target of 1..200, an RFC 3339 time with its offset).");
277
+ throw new ServiceError("Operator decision: not a decision decision.schema.json reads (an action id, the operator's words of 1..500 characters, a target of 1..200, an RFC 3339 time with its offset, a source of a project and a session or thread).");
263
278
  }
264
279
  return decision;
265
280
  }
@@ -83,6 +83,7 @@ export class BoardSession {
83
83
  export class Checkpoint {
84
84
  constructor(file) {
85
85
  this.file = file;
86
+ this.slots = {}; // the responder's own record of each slot it held: {project, generation, moved?} (FAA-155)
86
87
  this.entries = this.#load();
87
88
  }
88
89
  #load() {
@@ -100,13 +101,16 @@ export class Checkpoint {
100
101
  doc = null;
101
102
  }
102
103
  if (!isObj(doc) || doc.version !== 1 || !isObj(doc.requests)) throw new BoardError('checkpoint', `The board checkpoint ${this.file} is not one this kit writes.`);
104
+ // a record that is not an object is ignored, as a file without one is
105
+ this.slots = isObj(doc.slots) ? Object.fromEntries(Object.entries(doc.slots).filter(([, v]) => isObj(v))) : {};
103
106
  return doc.requests;
104
107
  }
105
- #write(entries) {
108
+ #write(entries, slots = this.slots) {
106
109
  try {
107
110
  ensurePrivateDir(path.dirname(this.file));
108
- const sorted = Object.fromEntries(Object.keys(entries).sort().map((k) => [k, entries[k]]));
109
- atomicWrite(this.file, `${JSON.stringify({ requests: sorted, version: 1 }, null, 1)}\n`, 0o600);
111
+ const sort = (o) => Object.fromEntries(Object.keys(o).sort().map((k) => [k, o[k]]));
112
+ const doc = Object.keys(slots).length ? { requests: sort(entries), slots: sort(slots), version: 1 } : { requests: sort(entries), version: 1 };
113
+ atomicWrite(this.file, `${JSON.stringify(doc, null, 1)}\n`, 0o600);
110
114
  } catch (e) {
111
115
  throw new BoardError('checkpoint', `The board checkpoint could not be written (${e.code ?? e.message}); nothing was sent.`);
112
116
  }
@@ -122,8 +126,34 @@ export class Checkpoint {
122
126
  this.#write(entries);
123
127
  this.entries = entries;
124
128
  }
129
+ /** Writes the record of a slot this responder held (null forgets it); nothing is written when it is unchanged. */
130
+ slot(capability, record) {
131
+ const { [capability]: _old, ...slots } = this.slots;
132
+ if (record !== null) slots[capability] = JSON.parse(JSON.stringify(record));
133
+ if (JSON.stringify(sortDeep(slots)) === JSON.stringify(sortDeep(this.slots))) return;
134
+ this.#write(this.entries, slots);
135
+ this.slots = slots;
136
+ }
137
+ /**
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
140
+ * a kit rolled back to before 0.53.0 leaves nothing that reads as a move. A record that cannot be written stays.
141
+ */
142
+ leave(project) {
143
+ const busy = new Set(Object.values(this.entries).map((e) => e.capability));
144
+ for (const [capability, record] of Object.entries(this.slots)) {
145
+ if (!isObj(record) || record.project !== project || 'moved' in record || busy.has(capability)) continue;
146
+ try {
147
+ this.slot(capability, null);
148
+ } catch {
149
+ // stays for the next start
150
+ }
151
+ }
152
+ }
125
153
  }
126
154
 
155
+ const sortDeep = (v) => (isObj(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortDeep(v[k])])) : v);
156
+
127
157
  /** A claimed request as data: never an instruction. */
128
158
  export class BoardRequest {
129
159
  constructor(message, capability) {
@@ -26,6 +26,9 @@ const TERMINAL = new Set(['completed', 'failed_known', 'cancelled', 'expired']);
26
26
  // participant reply (DEC-0098, DEC-0102; not terminal, the requester may still cancel it, and nobody claims it).
27
27
  const NOT_OURS = new Set(['queued', 'claimed', 'answered']);
28
28
 
29
+ // The answer may have been stored: send it again with the same key (BoardError).
30
+ const LOST = ['unreachable', 'session-ended'];
31
+
29
32
  /** A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38). */
30
33
  export const LEASE_MARGIN_SECONDS = 10;
31
34
 
@@ -87,6 +90,7 @@ export class Responder {
87
90
  /** One fenced call at a time, and the entry edits beside them (FAA-77, finding 36). */
88
91
  this.fenceQueue = Promise.resolve();
89
92
  this.toResume = []; // transferred requests waiting until nothing runs (finding 39)
93
+ this.suspect = new Set(); // capabilities a fenced call was refused on: com.status says whether they moved (FAA-155)
90
94
  }
91
95
 
92
96
  /** Runs fn after every fenced call queued before it (finding 36): an await inside a call never lets another in. */
@@ -167,13 +171,14 @@ export class Responder {
167
171
  const args = { idempotency: this.idem(this.key('enroll')), capability };
168
172
  let receipt;
169
173
  try {
170
- receipt = this.receipt('com.enroll', await this.call('com.enroll', args));
174
+ receipt = this.receipt('com.enroll', await this.ask(capability, 'com.enroll', args));
171
175
  } catch (e) {
172
176
  if (e.code === 'held') {
173
177
  this.pause(capability);
174
178
  return;
175
179
  }
176
180
  if (e.code === 'fenced') {
181
+ if (await this.gone(capability)) return;
177
182
  this.slots[capability] = { state: 'contested', renewedAt: this.now() };
178
183
  this.emit('board.slot.contested', 'warn', `Another responder holds the ${capability} slot on this Project; this service waits for it to lapse and never takes it over by itself.`, { notify: true, episode: capability, capability });
179
184
  return;
@@ -181,6 +186,7 @@ export class Responder {
181
186
  throw e;
182
187
  }
183
188
  this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
189
+ this.heldAt(capability, receipt.slot.generation);
184
190
  delete this.said['board.slot.contested'];
185
191
  this.lifted(capability);
186
192
  }
@@ -205,6 +211,118 @@ export class Responder {
205
211
  this.emit('board.slot.released', 'info', `The operator's hold on ${capability} was lifted; the service takes new work again.`, { capability });
206
212
  }
207
213
 
214
+ // -- a slot moved to another session (FAA-155, DEC-0140 PR-2) --
215
+ /** The generation this responder holds the slot at, kept beside its checkpoint; a failed write is retried at the next receipt. */
216
+ heldAt(capability, generation) {
217
+ this.note(capability, { project: this.project, generation });
218
+ }
219
+
220
+ /** The slot record, best effort: one that cannot be written now is written at the next receipt. */
221
+ note(capability, record) {
222
+ try {
223
+ this.checkpoint.slot(capability, record);
224
+ } catch {
225
+ // the request steps fail closed on their own
226
+ }
227
+ }
228
+
229
+ /**
230
+ * An enroll or replace is recorded with its key before it is sent, so a lost answer is never read as a move; a refusal
231
+ * drops the record, a receipt replaces it (heldAt), a record that cannot be written is skipped.
232
+ */
233
+ async ask(capability, op, args) {
234
+ let held = this.checkpoint.slots[capability] ?? {};
235
+ if (held.project !== this.project) return this.call(op, args); // no record: nothing a lost answer could be mistaken for
236
+ const slot = this.slots[capability] ?? {};
237
+ if (op === 'com.enroll' && slot.state === 'held' && slot.generation === held.generation
238
+ && this.now() - (slot.renewedAt ?? 0) < LEASE_SECONDS - LEASE_MARGIN_SECONDS && !('asking' in held)) {
239
+ return this.call(op, args); // a live holder's renew cannot change its generation: nothing to record
240
+ }
241
+ if (isObj(held.asking)) {
242
+ // An earlier lost ask is settled first; it may show the slot moved.
243
+ if (await this.gone(capability)) throw new BoardError('fenced', `The ${capability} slot moved to another session.`, false, op);
244
+ held = this.checkpoint.slots[capability] ?? {};
245
+ }
246
+ try {
247
+ this.checkpoint.slot(capability, { ...held, asking: { op, args } });
248
+ } catch {
249
+ return this.call(op, args);
250
+ }
251
+ try {
252
+ return await this.call(op, args);
253
+ } catch (e) {
254
+ if (!LOST.includes(e.code)) this.note(capability, held);
255
+ throw e;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Asks a lost enroll or replace again under its key. `repeated: true`: it landed, the generation is ours. Else it ran
261
+ * only now: if the slot had moved past the record (`before`), the record stays and the move is read (the slot just
262
+ * taken lapses unused). A refusal or an odd receipt drops the record; a lost answer keeps it.
263
+ */
264
+ async replay(capability, held, before) {
265
+ const { asking, ...rest } = held;
266
+ let record = rest;
267
+ if (isObj(asking) && ['com.enroll', 'com.responder_replace'].includes(asking.op)) {
268
+ try {
269
+ const answer = this.receipt(asking.op, await this.call(asking.op, asking.args));
270
+ const generation = answer.slot?.generation;
271
+ const moved = isInt(before, 1) && isInt(rest.generation, 1) && before > rest.generation;
272
+ if (isInt(generation, 1) && (answer.repeated === true || !moved)) record = { project: this.project, generation };
273
+ } catch (e) {
274
+ if (LOST.includes(e.code)) throw e;
275
+ if (e.code === 'idempotency_window_expired') record = {}; // a retired epoch: unknown, so a first start, no move
276
+ }
277
+ }
278
+ this.note(capability, Object.keys(record).length ? record : null);
279
+ return record;
280
+ }
281
+
282
+ /**
283
+ * True when com.status shows the slot's generation past the one recorded held: the operator or another session moved
284
+ * it. The slot is left `moved`. No record, another Project's, an odd value or no later generation: false.
285
+ */
286
+ async gone(capability, status = null) {
287
+ let held = this.checkpoint.slots[capability] ?? {};
288
+ if (held.project !== this.project || (!isInt(held.generation, 1) && !('asking' in held))) return false;
289
+ let now = ((status ?? await this.status()).responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
290
+ if ('asking' in held) held = await this.replay(capability, held, now); // its own lost ask first
291
+ if (isInt(held.moved, 1) /* a recorded move stays until takeBack */ && !(isInt(now, 1) && now > (held.generation ?? now))) now = held.moved;
292
+ if (!isInt(now, 1) || !isInt(held.generation, 1) || now <= held.generation) return false;
293
+ if (this.current?.entry.capability === capability) {
294
+ this.current.ctx.stopped = true;
295
+ this.current = null;
296
+ }
297
+ for (const entry of Object.values(this.checkpoint.entries).filter((e) => e.capability === capability).map((e) => ({ ...e }))) {
298
+ if (entry.step === 'claimed') this.checkpoint.drop(entry.request); // the move returned the claim to the queue
299
+ else if (!['unknown', 'lapsed'].includes(entry.step)) {
300
+ // Begun: outcome_unknown, which the slot's holder reconciles. Accepted: held on the board, continued only by a
301
+ // transfer if the slot is this service's again; never a com.responder_replace on its own.
302
+ entry.step = entry.effect ? 'unknown' : 'lapsed';
303
+ this.checkpoint.put(entry);
304
+ }
305
+ }
306
+ this.toResume = this.toResume.filter((r) => this.checkpoint.entries[r]?.capability !== capability);
307
+ this.slots[capability] = { state: 'moved', generation: now, renewedAt: this.now() };
308
+ if (!('moved' in held)) { // once, even across restarts
309
+ this.emit('board.slot.moved', 'warn', `The ${capability} slot on this Project was moved to another session. This service stopped answering it there, left its accepted work held (work already begun is the new holder's to settle) and does not take the slot back by itself. Its take-back action brings it back once the other session lets it go; without one, ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
310
+ this.note(capability, { ...held, moved: now }); // said again at the next start if it cannot be written
311
+ }
312
+ return true;
313
+ }
314
+
315
+ /**
316
+ * The operator's act, never the kit's: forget that a capability's slot moved away. In the same board session the next
317
+ * tick enrolls once the slot is free; a reconnect with held work takes it back at once by a transfer (FAA-155).
318
+ */
319
+ takeBack(capability) {
320
+ if (!this.capabilities.includes(capability)) throw new BoardError('invalid_arguments', `This responder does not answer ${capability}.`);
321
+ this.checkpoint.slot(capability, null);
322
+ if (this.slots[capability]?.state === 'moved') this.slots[capability] = { state: 'contested', renewedAt: 0 };
323
+ delete this.said['board.slot.moved'];
324
+ }
325
+
208
326
  // -- restart: reconcile and transfer --
209
327
  async readState(entry) {
210
328
  const message = await this.call('com.get', { message: entry.message });
@@ -237,13 +355,18 @@ export class Responder {
237
355
  const generations = Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0]));
238
356
  const onHold = new Set(responders.filter((r) => r.held === true).map((r) => r.capability)); // DEC-0079
239
357
  for (const capability of capabilities) {
358
+ const was = this.checkpoint.slots[capability]?.generation;
359
+ if (await this.gone(capability, status)) continue; // moved away: never replaced at recovery (FAA-155)
240
360
  if (onHold.has(capability)) {
241
361
  for (const entry of transfer[capability] ?? []) {
242
362
  entry.step = 'lapsed'; // taken back by a transfer once the hold is lifted
243
363
  this.checkpoint.put(entry);
244
364
  }
245
365
  this.pause(capability);
246
- } else if (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
366
+ } else if (transfer[capability]?.length) { // at the generation a replay just recorded, else com.status's
367
+ const mine = this.checkpoint.slots[capability]?.generation;
368
+ await this.replace(capability, transfer[capability], isInt(mine, 1) && mine !== was ? mine : generations[capability] ?? 0);
369
+ }
247
370
  else await this.enroll(capability);
248
371
  }
249
372
  for (const entry of reconcile) {
@@ -260,8 +383,10 @@ export class Responder {
260
383
  if (entries.length) args.transfer = { requests: entries.map((e) => e.request).slice(0, 64), checkpoint: { summary: 'Resumed by the same service after a restart; no effect had begun.' } };
261
384
  let receipt;
262
385
  try {
263
- receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
386
+ receipt = this.receipt('com.responder_replace', await this.ask(capability, 'com.responder_replace', args));
264
387
  } catch (e) {
388
+ // An earlier lost ask showed the slot moved: said once as board.slot.moved, no outage.
389
+ if (e.code === 'fenced' && this.slots[capability]?.state === 'moved') return;
265
390
  if (e.code !== 'held') throw e;
266
391
  for (const entry of entries) {
267
392
  entry.step = 'lapsed'; // nothing moved: taken back by a transfer once the hold is lifted
@@ -271,6 +396,7 @@ export class Responder {
271
396
  return;
272
397
  }
273
398
  this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
399
+ this.heldAt(capability, receipt.slot.generation);
274
400
  this.lifted(capability);
275
401
  const moved = new Map((receipt.transferred ?? []).map((item) => [item.request, item]));
276
402
  for (const entry of entries) {
@@ -418,6 +544,7 @@ export class Responder {
418
544
 
419
545
  async fault(e) {
420
546
  const code = e.code ?? 'unreachable';
547
+ const connecting = this.state === 'connecting' && !Object.values(this.slots).some((s) => s?.state === 'held');
421
548
  let wait = this.backoff.next();
422
549
  const left = this.leaseLeft() - LEASE_MARGIN_SECONDS;
423
550
  const keep = code === 'unreachable' && left > 0; // finding 38: the lease holds, so the session and the work stay
@@ -437,7 +564,8 @@ export class Responder {
437
564
  this.emit('board.blocked', 'error', `The responder stopped answering requests: ${e.message}`, { notify: true, episode: code, reason: code });
438
565
  } else {
439
566
  this.state = 'down';
440
- if (['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code) && !keep) await this.dropSession();
567
+ // A refusal in recovery that left no slot held reconnects (seam 6); one that left some held serves them (seam 7).
568
+ if ((connecting || ['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code)) && !keep) await this.dropSession();
441
569
  this.emit('board.down', 'warn', 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
442
570
  }
443
571
  return wait;
@@ -470,7 +598,12 @@ export class Responder {
470
598
  else await this.renew(work);
471
599
  }
472
600
  const heldByWork = work && this.current ? work.entry.capability : null;
601
+ for (const capability of [...this.suspect]) { // kept until com.status answered, so it is asked before any enroll
602
+ await this.gone(capability);
603
+ this.suspect.delete(capability);
604
+ }
473
605
  for (const [capability, slot] of Object.entries(this.slots)) {
606
+ if (slot.state === 'moved') continue; // never enrolled again on its own, even when the new holder's lease lapses (FAA-155)
474
607
  if (slot.state === 'paused') {
475
608
  // still held: a longer pause; lifted: the slot is ours again
476
609
  if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0)) await this.enroll(capability);
@@ -521,6 +654,7 @@ export class Responder {
521
654
  answer = this.receipt('com.claim', await this.call('com.claim', { idempotency: this.idem(this.claimKeys[capability]), capability }));
522
655
  } catch (e) {
523
656
  if (e.code === 'lease_expired') {
657
+ this.slots[capability].renewedAt = -Infinity; // lapsed: this enroll may move the generation, so it is recorded
524
658
  await this.enroll(capability);
525
659
  continue;
526
660
  }
@@ -530,7 +664,7 @@ export class Responder {
530
664
  continue;
531
665
  }
532
666
  if (e.code === 'fenced') {
533
- this.slots[capability] = { state: 'contested', renewedAt: this.now() };
667
+ if (!(await this.gone(capability))) this.slots[capability] = { state: 'contested', renewedAt: this.now() };
534
668
  continue;
535
669
  }
536
670
  if (e.code === 'held') {
@@ -648,6 +782,7 @@ export class Responder {
648
782
  continue;
649
783
  }
650
784
  if (STOP_CODES.has(e.code) || ['revision_conflict', 'invalid_transition', 'digest_conflict'].includes(e.code)) {
785
+ if (e.code === 'fenced') this.suspect.add(entry.capability); // the next tick asks com.status whether the slot moved
651
786
  this.lose(entry, e.code);
652
787
  if (e.code === 'authority_changed') throw e;
653
788
  return null;
@@ -663,10 +798,13 @@ export class Responder {
663
798
  entry.step = 'unknown';
664
799
  this.checkpoint.put(entry);
665
800
  this.emit('board.reconcile.needed', 'warn', 'The attempt was lost after its effect began; the outcome stays unknown until this service observes what happened.', { notify: true, episode: entry.request, request: entry.request, capability: entry.capability, reason: code });
666
- } else if (code === 'lease_expired' && !['claimed', 'accepting'].includes(entry.step)) {
801
+ } else if (!['claimed', 'accepting'].includes(entry.step) && (code === 'lease_expired' || (code === 'fenced' && entry.capability in this.checkpoint.slots))) {
802
+ // Accepted work whose lease lapsed, or fenced off a slot this responder held (moved, FAA-155): the board holds it
803
+ // (C4). It continues only by a checkpoint transfer to this responder while it holds the slot.
667
804
  entry.step = 'lapsed';
668
805
  this.checkpoint.put(entry);
669
- this.emit('board.attempt.lost', 'warn', 'This service\'s lease on an accepted request lapsed; it stopped working on it and takes it back by a transfer.', { request: entry.request, capability: entry.capability, reason: code });
806
+ this.emit('board.attempt.lost', 'warn', code === 'lease_expired' ? 'This service\'s lease on an accepted request lapsed; it stopped working on it and takes it back by a transfer.'
807
+ : 'The board fenced an accepted request away from this service; it stopped working on it, and nothing had been done outside it.', { request: entry.request, capability: entry.capability, reason: code });
670
808
  } else {
671
809
  this.checkpoint.drop(entry.request);
672
810
  this.emit('board.attempt.lost', 'warn', `The board took a request back from this service (${code}); it stopped working on it, and nothing had been done outside the service.`, { request: entry.request, capability: entry.capability, reason: code });
@@ -854,6 +992,7 @@ export class Responder {
854
992
  if (this.session) {
855
993
  await this.session.close();
856
994
  this.session = null;
995
+ this.checkpoint.leave(this.project); // the slots were released: no record of them can read as a move
857
996
  }
858
997
  this.state = 'stopped';
859
998
  }