@passioncode-ai/fabric-agent-adapter 0.59.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +2 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +93 -20
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +23 -6
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -2
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +117 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +33 -7
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +80 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +10 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -8
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +24 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +3 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +6 -1
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +146 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +31 -8
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +80 -4
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +33 -25
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +19 -6
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +23 -3
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +17 -3
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +86 -7
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +14 -6
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +210 -22
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +2 -2
  32. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  33. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -27
  34. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -8
  35. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -6,6 +6,9 @@ import path from 'node:path';
6
6
  import { HubError, MCP_PROTOCOL_VERSION, postMcp, toolAnswer } from './fabric-hub.mjs';
7
7
  import { atomicWrite, ensurePrivateDir } from './fabric-service.mjs';
8
8
  import { BoardError, RESPOND_TOOLS, isObj, refusalProblems } from './fabric-board-shapes.mjs';
9
+ import { UNREADABLE_PROJECT, mandateSpend } from './fabric-interop-rules.mjs'; // FAA-139
10
+ import { isAgentId, mandateCovers } from './fabric-mandate.mjs';
11
+ import { formatRfc3339 } from './fabric-time.mjs';
9
12
 
10
13
  /** MCP over HTTP with a board session's one-session bearer; calls are serialised; close() sends the session's DELETE. */
11
14
  export class BoardSession {
@@ -179,4 +182,118 @@ export class BoardRequest {
179
182
  toJSON() {
180
183
  return { requestId: this.requestId, capability: this.capability }; // never the body
181
184
  }
185
+ [Symbol.for('nodejs.util.inspect.custom')]() {
186
+ return this.toJSON(); // console.log(request) prints no body either
187
+ }
188
+ }
189
+
190
+ // -- the operator's mandate in a board request (FAA-135; contract project-comms.md, DEC-0125 MD-3) ---------------------
191
+
192
+ /** `mandateFrom` naming every trusted sender of the board the service is installed on: the operator's personal board. */
193
+ export const PERSONAL_BOARD = 'personal-board';
194
+ const OPENER = /^```json[ \t]*$/;
195
+ const CLOSER = /^```[ \t]*$/;
196
+
197
+ /**
198
+ * The bodies of the ```json blocks, each fence on its own line: one pass over the lines, linear in the body. Lines end
199
+ * at "\\n" alone, one "\\r" before it dropped, as in the Python twin (never U+2028 or a lone "\\r"); an unclosed
200
+ * opener ends the scan.
201
+ */
202
+ function jsonBlocks(body) {
203
+ const lines = body.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
204
+ const blocks = [];
205
+ for (let i = 0; i < lines.length; i += 1) {
206
+ if (!OPENER.test(lines[i])) continue;
207
+ let j = i + 1;
208
+ while (j < lines.length && !CLOSER.test(lines[j])) j += 1;
209
+ if (j === lines.length) break;
210
+ blocks.push(lines.slice(i + 1, j).join('\n'));
211
+ i = j;
212
+ }
213
+ return blocks;
214
+ }
215
+ const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
216
+ /** A Project id as Fabric's board compares it: a uuid lower-cased, any other id as written (normalProject). */
217
+ const fold = (project) => (UUID.test(project) ? project.toLowerCase() : project);
218
+
219
+ /**
220
+ * The mandate a request carries, read only when the service opted in (`agentId` and `mandateFrom` set), the board
221
+ * verified the sender (`provenance: trusted`) and its Project is one `mandateFrom` names (or `PERSONAL_BOARD`): a T1
222
+ * caller by project-comms. Answers `{ mandate, unread }`: `mandate` null when none is read, `unread` `mandate-blocks`
223
+ * (more than one) or `mandate-unreadable` (a block naming `mandate/1` that is not JSON). A `source` in the block is
224
+ * dropped: a board block carries none (OA-7).
225
+ */
226
+ export function readMandate(request, mandateFrom, agentId) {
227
+ const none = { mandate: null, unread: null };
228
+ if (agentId == null || mandateFrom == null || typeof request.body !== 'string') return none;
229
+ const sender = isObj(request.sender) ? request.sender : {};
230
+ const principal = isObj(sender.principal) ? sender.principal : {};
231
+ if (principal.provenance !== 'trusted') return none;
232
+ if (typeof sender.project !== 'string' || !sender.project) return none; // comms-message requires it; the caller
233
+ if (mandateFrom !== PERSONAL_BOARD && !mandateFrom.has(fold(sender.project))) return none;
234
+ const found = [];
235
+ for (const block of jsonBlocks(request.body)) {
236
+ let value;
237
+ try {
238
+ value = JSON.parse(block);
239
+ } catch {
240
+ if (block.includes('mandate/1')) return { mandate: null, unread: 'mandate-unreadable' };
241
+ continue;
242
+ }
243
+ if (isObj(value) && value.kind === 'mandate/1') {
244
+ delete value.source; // a Project declares no protocols: a board block carries no source (OA-7)
245
+ found.push(value);
246
+ }
247
+ }
248
+ if (found.length > 1) return { mandate: null, unread: 'mandate-blocks' };
249
+ return found.length ? { mandate: found[0], unread: null } : none;
182
250
  }
251
+
252
+ /**
253
+ * `mandateCovers` for a call under a board request's mandate, its sender placed as T1 and no caller agent id (the
254
+ * chain's end alone binds it, MD-3). A covered answer's `recordAs` names `caller`, the sender Project the board
255
+ * verified (MD-3 records the caller). `wall` answers milliseconds. Null when no mandate travelled. `spendUsd` and
256
+ * `project` are read failing closed, as the kit's other mandate readers read them (FAA-139): a spend that is not a
257
+ * finite number is none (a ceiling refuses it), a `project` that is not a string, null included (the contract's
258
+ * mandateCovers refuses a null project), unreadable; a call that names none leaves `project` out. Only the five
259
+ * members mandateCovers reads are read, each once: a spend as an own member (an inherited one is none, which a ceiling
260
+ * refuses), a capability, action, effect and project as the contract reads them, a plain [[Get]] whose undefined is
261
+ * absent (an inherited, non-enumerable, getter or Proxy member included); the rest of the call is never touched. A
262
+ * getter whose value changes is answered on its one read.
263
+ */
264
+ export function covers(mandate, call, agentId, wall, caller = null) {
265
+ if (mandate == null || agentId == null) return null;
266
+ if (call !== null && typeof call === 'object' && !Array.isArray(call)) {
267
+ const read = {};
268
+ for (const key of ['capability', 'action', 'effect']) {
269
+ const value = call[key]; // the contract's read: whatever [[Get]] answers, undefined being absent
270
+ if (value !== undefined) read[key] = value;
271
+ }
272
+ const spend = mandateSpend(Object.hasOwn(call, 'spendUsd') ? call.spendUsd : undefined);
273
+ if (spend !== undefined) read.spendUsd = spend;
274
+ const project = call.project;
275
+ if (project !== undefined) read.project = typeof project === 'string' ? project : UNREADABLE_PROJECT;
276
+ call = read;
277
+ }
278
+ const clock = wall();
279
+ let now = '';
280
+ try {
281
+ if (typeof clock === 'number' && Number.isFinite(clock)) now = formatRfc3339(clock);
282
+ } catch { // finite but outside the dates a clock can name
283
+ now = '';
284
+ }
285
+ const answer = mandateCovers({ mandate, tier: 'T1', now, self: agentId, call });
286
+ return answer.recordAs ? { ok: true, recordAs: { ...answer.recordAs, caller } } : answer;
287
+ }
288
+
289
+ /** The responder's opt-in, checked: answers `mandateFrom` as the responder keeps it (a Set or the string). */
290
+ export function mandateOptions(agentId, mandateFrom) {
291
+ if (agentId != null && !isAgentId(agentId)) throw new BoardError('invalid_arguments', 'agentId must be the service\'s registry agent id.');
292
+ if (mandateFrom == null || mandateFrom === PERSONAL_BOARD) return mandateFrom ?? null;
293
+ const projects = typeof mandateFrom === 'string' || mandateFrom instanceof String || typeof mandateFrom?.[Symbol.iterator] !== 'function' ? null : [...mandateFrom];
294
+ if (projects === null || !projects.every((p) => typeof p === 'string' && p)) {
295
+ throw new BoardError('invalid_arguments', `mandateFrom is '${PERSONAL_BOARD}' or a collection of Project ids.`);
296
+ }
297
+ return new Set(projects.map(fold));
298
+ }
299
+
@@ -5,10 +5,10 @@ import crypto from 'node:crypto';
5
5
  import { Backoff, HubError, normalProject } from './fabric-hub.mjs';
6
6
  import { acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
7
7
  import { BoardError, CAPABILITY, LEASE_SECONDS, REQUEST_STATES, SUMMARY_MAX, Stopped, isInt, isObj, outcomeProblems, receiptProblems } from './fabric-board-shapes.mjs';
8
- import { BoardRequest, BoardSession, Checkpoint } from './fabric-board-link.mjs';
8
+ import { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD, covers, mandateOptions, readMandate } from './fabric-board-link.mjs';
9
9
  import { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES, SlotKeeper } from './fabric-board-slots.mjs';
10
10
 
11
- export { BoardRequest, BoardSession, Checkpoint }; // re-exported: callers import them from here since 0.40.0
11
+ export { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD }; // re-exported: callers import them from here since 0.40.0
12
12
  export { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES }; // since 0.56.0 defined in fabric-board-slots.mjs
13
13
 
14
14
  export const RENEW_SECONDS = 20;
@@ -34,10 +34,23 @@ const NOT_OURS = new Set(['queued', 'claimed', 'answered']);
34
34
  export class WorkContext {
35
35
  #responder;
36
36
  #entry;
37
- constructor(responder, entry) {
37
+ #caller;
38
+ constructor(responder, entry, mandate = null, caller = null) {
38
39
  this.#responder = responder;
39
40
  this.#entry = entry;
41
+ this.#caller = caller;
40
42
  this.stopped = false;
43
+ this.mandate = mandate; // the operator's mandate the request carried, when the responder reads one (FAA-135)
44
+ }
45
+ /** Whether the request's mandate covers `call` (`mandateCovers`' answer); null when none travelled. */
46
+ mandateCovers(call) {
47
+ return covers(this.mandate, call, this.#responder.agentId, this.#responder.wall, this.#caller);
48
+ }
49
+ toJSON() {
50
+ return { stopped: this.stopped }; // never the mandate: it holds the operator's words
51
+ }
52
+ [Symbol.for('nodejs.util.inspect.custom')]() {
53
+ return this.toJSON(); // console.log(ctx) prints no mandate either
41
54
  }
42
55
  async progress(note) {
43
56
  if (this.stopped) throw new Stopped();
@@ -69,13 +82,15 @@ function noticeSubject(value, project, episode) {
69
82
  */
70
83
  export class Responder extends SlotKeeper {
71
84
  constructor({ hub, projectId, capabilities, credentialFile, checkpointFile, handler, admit, observe, emit, service = 'service',
72
- now = () => performance.now() / 1000, rand = Math.random, sessionFactory } = {}) {
85
+ now = () => performance.now() / 1000, rand = Math.random, sessionFactory, agentId = null, mandateFrom = null, wall = Date.now } = {}) {
73
86
  super();
74
87
  if (typeof admit !== 'function' || typeof handler !== 'function') throw new BoardError('invalid_arguments', 'A responder needs the service\'s own input gate (admit) and a handler.');
75
88
  const caps = [...(capabilities ?? [])];
76
89
  if (!caps.length || caps.some((c) => typeof c !== 'string' || !CAPABILITY.test(c)) || new Set(caps).size !== caps.length) throw new BoardError('invalid_arguments', 'Name each capability once, [a-z][a-z0-9._-]{1,127}.');
77
90
  Object.assign(this, { hub, capabilities: caps, credentialFile, handler, admit, observe, emitTo: emit, now, rand });
78
91
  this.project = normalProject(projectId);
92
+ // FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; null reads none)
93
+ Object.assign(this, { mandateFrom: mandateOptions(agentId, mandateFrom), agentId: agentId ?? null, wall });
79
94
  this.service = String(service).replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 40) || 'service';
80
95
  this.checkpoint = new Checkpoint(checkpointFile);
81
96
  this.sessionFactory = sessionFactory ?? ((grant) => new BoardSession(grant));
@@ -88,6 +103,7 @@ export class Responder extends SlotKeeper {
88
103
  this.waitUntil = 0;
89
104
  this.stopping = false;
90
105
  this.claimKeys = {};
106
+ this.probing = new Set(); // moved slots whose move-back claim lost its answer: asked again under the same key
91
107
  this.said = {};
92
108
  this.rr = 0;
93
109
  this.reconcileAt = 0;
@@ -172,6 +188,7 @@ export class Responder extends SlotKeeper {
172
188
  this.state = 'connecting';
173
189
  this.slots = {}; // the previous session's slots are not this one's: a refusal now reconnects (FAA-163)
174
190
  this.confirmed = {}; // nor its confirmations: a slot may have moved between the two sessions
191
+ this.probing = new Set(); // nor its lost move-back claims
175
192
  const grant = await this.hub.joinBoard(this.project, this.credentialFile);
176
193
  const missing = this.capabilities.filter((c) => !grant.capabilities.includes(c));
177
194
  const session = this.sessionFactory(grant);
@@ -462,7 +479,9 @@ export class Responder extends SlotKeeper {
462
479
  /** Seconds this session's leases still run, by its own clock: the running attempt's, else the oldest held slot's. */
463
480
  leaseLeft() {
464
481
  const renewed = this.current ? [this.current.renewedAt ?? 0]
465
- : Object.values(this.slots).filter((s) => s.state === 'held' && s.renewedAt !== -Infinity).map((s) => s.renewedAt ?? 0); // a lapsed slot holds no lease (FAA-163)
482
+ : [...Object.values(this.slots).filter((s) => s.state === 'held' && s.renewedAt !== -Infinity).map((s) => s.renewedAt ?? 0), // a lapsed slot holds no lease (FAA-163)
483
+ ...Object.entries(this.slots).filter(([c, s]) => this.probing.has(c) && 'probeAt' in s && this.now() - s.probeAt < LEASE_SECONDS - LEASE_MARGIN_SECONDS)
484
+ .map(([, s]) => s.probeAt)]; // a move-back claim whose answer was lost holds one from its send, while more than the 10 s margin of that lease is left (FAA-165)
466
485
  return renewed.length && this.session ? Math.min(...renewed) + LEASE_SECONDS - this.now() : 0;
467
486
  }
468
487
 
@@ -528,7 +547,10 @@ export class Responder extends SlotKeeper {
528
547
  this.suspect.delete(capability);
529
548
  }
530
549
  for (const [capability, slot] of Object.entries(this.slots)) {
531
- if (slot.state === 'moved') continue; // never enrolled again on its own, even when the new holder's lease lapses (FAA-155)
550
+ if (slot.state === 'moved') { // never enrolled again on its own, even when the new holder lapses (FAA-155)
551
+ if (this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.movedBack(capability); // unless com.status shows a later generation it may hold (FAA-165)
552
+ continue;
553
+ }
532
554
  if (slot.state === 'paused') {
533
555
  // still held: a longer pause; lifted: the slot is ours again
534
556
  if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0) && !(await this.gone(capability))) await this.enroll(capability); // a move is read first
@@ -672,7 +694,11 @@ export class Responder extends SlotKeeper {
672
694
  }
673
695
 
674
696
  async runWork(entry, request) {
675
- const ctx = new WorkContext(this, entry);
697
+ const { mandate, unread } = readMandate(request, this.mandateFrom, this.agentId);
698
+ if (unread) { // by its code alone: the block holds the operator's words
699
+ this.emit('board.request.mandate_unread', 'warning', `Request ${entry.request} carries a mandate the kit does not read (${unread}); it is worked without one.`, { request: entry.request, reason: unread });
700
+ }
701
+ const ctx = new WorkContext(this, entry, mandate, isObj(request.sender) ? request.sender.project ?? null : null);
676
702
  const work = { entry, request, ctx, done: false, outcome: null, failure: null, renewedAt: this.now() };
677
703
  this.current = work;
678
704
  work.promise = Promise.resolve().then(() => this.handler(request, ctx)).then((outcome) => { work.outcome = outcome; }, (e) => { work.failure = e; }).finally(() => { work.done = true; });
@@ -16,6 +16,8 @@ export const HOLD_RETRY_MAX_SECONDS = 900;
16
16
  export const SESSION_CODES = Object.freeze(new Set(['unreachable', 'session-ended', 'not_authorized', 'authority_changed', 'credential', 'no-epoch',
17
17
  'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available']));
18
18
 
19
+ const sameRecord = (a, b) => JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
20
+
19
21
  /** The slot methods of Responder, which owns every field they read (checkpoint, slots, project, locked). */
20
22
  export class SlotKeeper {
21
23
  // -- a slot moved to another session (FAA-155, DEC-0140 PR-2) --
@@ -127,13 +129,88 @@ export class SlotKeeper {
127
129
  this.toResume = this.toResume.filter((r) => this.checkpoint.entries[r]?.capability !== capability);
128
130
  this.slots[capability] = { state: 'moved', generation: now, renewedAt: this.now() };
129
131
  delete this.confirmed[capability];
132
+ delete this.claimKeys[capability]; // the move returned that claim to the queue: its receipt is dead
130
133
  if (!('moved' in held)) { // once, even across restarts
131
- this.emit('board.slot.moved', 'warning', `The ${capability} slot on this Project moved to another session. This service stopped answering it there and left its accepted work \`accepted\` (work begun is the new holder's to settle). Its take-back action brings the slot back once the other session lets it go, or at a later restart or reconnect while accepted work waits; a restart alone does not. Without it, ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
134
+ this.emit('board.slot.moved', 'warning', `The ${capability} slot on this Project moved to another session. This service stopped answering it; accepted work stays \`accepted\` (work begun is the new holder's). The operator brings it back by take-back (once the other session lets it go, or at a restart with accepted work waiting; a restart alone does not) or, where Fabric offers it, a move back here. Else ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
132
135
  this.note(capability, { ...held, moved: now }); // said again at the next start if it cannot be written
133
136
  }
134
137
  return true;
135
138
  }
136
139
 
140
+ /**
141
+ * A moved slot asked about once per later generation (FAA-165): the operator may move it back to this session
142
+ * (DEC-0140 PR-2), which this client cannot hear, so when com.status shows a generation past the recorded move, one
143
+ * com.claim asks whether the slot is its own. A claim that lands makes it held again and starts what it handed out; a
144
+ * lease_expired one (the move reached this session but its lease lapsed) enrolls again; a fenced one is another
145
+ * session's, recorded so the same generation is not asked about twice. Never an enroll or a replace on a slot another
146
+ * session holds: a slot moved off stays off unless it is handed back.
147
+ */
148
+ async movedBack(capability) {
149
+ if (this.current || this.stopping) return; // a claim now could hand out a second request, or start work while the responder stops
150
+ const held = this.checkpoint.slots[capability] ?? {};
151
+ if (held.project !== this.project || !isInt(held.moved, 1)) return;
152
+ if (this.slots[capability]) this.slots[capability].renewedAt = this.now();
153
+ const now = ((await this.status()).responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
154
+ if (!isInt(now, 1) || now <= held.moved) return;
155
+ if (!this.probing.has(capability) || !this.claimKeys[capability]) { // a fresh key: one from before the move may hold a dead receipt
156
+ this.claimKeys[capability] = this.key('claim');
157
+ if (this.slots[capability]) delete this.slots[capability].probeAt; // and a send time of its own
158
+ }
159
+ let answer;
160
+ const sent = this.now();
161
+ try {
162
+ answer = this.receipt('com.claim', await this.call('com.claim', { idempotency: this.idem(this.claimKeys[capability]), capability }));
163
+ } catch (e) {
164
+ if (!(e instanceof BoardError)) throw e; // not the board's answer
165
+ if (e.code === 'unreachable') { // the claim may have landed: asked again under this key, never left to the fault
166
+ this.probing.add(capability);
167
+ if (this.slots[capability] && !('probeAt' in this.slots[capability])) this.slots[capability].probeAt = sent; // its lease runs from the first send
168
+ return;
169
+ }
170
+ this.probing.delete(capability);
171
+ if (e.code === 'held') return; // the operator's hold: asked again at the next renewal
172
+ if (e.code === 'idempotency_window_expired' || e.code === 'capacity_exceeded') {
173
+ delete this.claimKeys[capability];
174
+ return;
175
+ }
176
+ if (e.code === 'lease_expired') {
177
+ delete this.claimKeys[capability]; // a dead attempt's receipt may sit under it
178
+ if (await this.back(capability, held, now)) {
179
+ this.slots[capability].renewedAt = -Infinity; // lapsed: the enroll below is recorded first
180
+ await this.enroll(capability);
181
+ }
182
+ return;
183
+ }
184
+ if (SESSION_CODES.has(e.code) && e.code !== 'not_authorized') throw e; // the board session's fault, not this slot's
185
+ // fenced, or another refusal of this claim: not this session's slot at this generation; a takeBack meanwhile wins (FAA-158)
186
+ await this.locked(() => { if (sameRecord(this.checkpoint.slots[capability], held)) this.note(capability, { ...held, moved: now }); });
187
+ return;
188
+ }
189
+ this.probing.delete(capability);
190
+ const generation = isObj(answer.attempt) && isInt(answer.attempt.generation, 1) ? answer.attempt.generation : now;
191
+ if (generation < now) { // a replay from before a later move: dead, asked again fresh
192
+ delete this.claimKeys[capability];
193
+ return;
194
+ }
195
+ if (!(await this.back(capability, held, generation))) return;
196
+ if ('request' in answer) {
197
+ delete this.claimKeys[capability];
198
+ this.slots[capability].renewedAt = this.now(); // the claim just landed: its lease runs (finding 38)
199
+ await this.start(capability, answer);
200
+ } else await this.enroll(capability); // renewed now: whether an empty com.claim renews the lease is not the contract's to say
201
+ }
202
+
203
+ /** The slot is this session's again at `generation`: held, its record rewritten without the move (FAA-165). */
204
+ back(capability, held, generation) {
205
+ return this.locked(() => {
206
+ if (!sameRecord(this.checkpoint.slots[capability], held)) return false; // a takeBack forgot it; its own next tick enrolls
207
+ this.slots[capability] = { state: 'held', generation, renewedAt: -Infinity }; // no lease yet
208
+ this.heldAt(capability, generation);
209
+ delete this.said['board.slot.moved']; // a later move is said again
210
+ return true;
211
+ });
212
+ }
213
+
137
214
  /**
138
215
  * The operator's act, never the kit's: forget that a capability's slot moved away. In the same board session the next
139
216
  * tick enrolls once the slot is free; a reconnect with held work takes it back at once by a transfer (FAA-155).
@@ -141,6 +218,8 @@ export class SlotKeeper {
141
218
  takeBack(capability) {
142
219
  if (!this.capabilities.includes(capability)) throw new BoardError('invalid_arguments', `This responder does not answer ${capability}.`);
143
220
  this.checkpoint.slot(capability, null);
221
+ delete this.claimKeys[capability]; // a receipt from before the take-back may be dead
222
+ this.probing.delete(capability);
144
223
  if (this.slots[capability]?.state === 'moved') this.slots[capability] = { state: 'contested', renewedAt: 0 };
145
224
  delete this.said['board.slot.moved'];
146
225
  }
@@ -37,6 +37,7 @@ export {
37
37
  BoardRequest,
38
38
  WorkContext,
39
39
  Responder,
40
+ PERSONAL_BOARD,
40
41
  } from './fabric-board-session.mjs';
41
42
  export {
42
43
  selfCheck,
@@ -282,6 +282,16 @@ export function mandateSpend(value) {
282
282
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
283
283
  }
284
284
 
285
+ /** A job's approval step {effect, spendUsd?, project?} as a job file keeps it: read as the gate reads it, so a job's
286
+ * start and its answer agree (0.61.0 seam reading 32, readings 33 and 34): a spend through mandateSpend (dropped when none), a project
287
+ * as a primitive string, else UNREADABLE_PROJECT (dropped when null or undefined). */
288
+ export function keptStep(step) {
289
+ const { effect, spendUsd, project } = step; // each member read once (seam reading 34)
290
+ const spend = mandateSpend(spendUsd);
291
+ return { effect, ...(spend !== undefined ? { spendUsd: spend } : {}),
292
+ ...(project !== undefined && project !== null ? { project: typeof project === 'string' ? project : UNREADABLE_PROJECT } : {}) };
293
+ }
294
+
285
295
  export function actionGate({ action, tier, caller, meta, target, elicited = false, approval, actionDigest, humanStepUrl, now, self, effect, callerAgent, spendUsd, project, mandateCapability } = {}) {
286
296
  const inputs = { action, tier, caller, elicited: elicited === true, now };
287
297
  if (isObject(meta) && Object.hasOwn(meta, 'decision')) inputs.decision = meta.decision;
@@ -41,6 +41,7 @@ export {
41
41
  jobStep,
42
42
  actionGate,
43
43
  mandateSpend,
44
+ keptStep,
44
45
  UNREADABLE_PROJECT,
45
46
  actionRefusalToolResult,
46
47
  usageFindings,
@@ -49,7 +50,7 @@ export {
49
50
  pauseMessage,
50
51
  repeatedKeyDecision,
51
52
  } from './fabric-interop-rules.mjs';
52
- import { PROTOCOL, EXTENSION_KEY, MCP_REVISION, TERMINAL, CONTRACT_VERSION, JOB_HANDLE_SCHEMA, InteropError, UnknownJob, parseTraceparent, childTraceparent, traceIds, QUOTA_EXHAUSTED, QUEUE_FULL, OUTCOME_UNKNOWN, LIMIT_REACHED, INTEROP_NULL_USAGE, PROTOCOLS_READ, utcTime, quotaDataOk, readJobPause, jobCallerAction, intentIdempotencyKey, readsNullUsage, requestMeta, usageFindings, usageToward, jobLimitRules, pauseMessage, repeatedKeyDecision, OUTCOMES, ENVELOPE_REQUIRED, JOB_ID, KEY, SECRET_WORDS } from './fabric-interop-rules.mjs'; // the rule layer (FAA-92)
53
+ import { PROTOCOL, EXTENSION_KEY, MCP_REVISION, TERMINAL, CONTRACT_VERSION, JOB_HANDLE_SCHEMA, InteropError, UnknownJob, parseTraceparent, childTraceparent, traceIds, QUOTA_EXHAUSTED, QUEUE_FULL, OUTCOME_UNKNOWN, LIMIT_REACHED, INTEROP_NULL_USAGE, PROTOCOLS_READ, utcTime, quotaDataOk, readJobPause, jobCallerAction, intentIdempotencyKey, readsNullUsage, requestMeta, usageFindings, usageToward, jobLimitRules, pauseMessage, repeatedKeyDecision, keptStep, OUTCOMES, ENVELOPE_REQUIRED, JOB_ID, KEY, SECRET_WORDS } from './fabric-interop-rules.mjs'; // the rule layer (FAA-92)
53
54
 
54
55
  // --- C3.1 capabilities as tools ----------------------------------------------------------
55
56
  export function expectedAnnotations(effect, idempotency) {
@@ -266,23 +267,28 @@ export class JobStore {
266
267
 
267
268
  // `steps` (DEC-0125) maps a request key to the approval step it asks for, {effect, spendUsd?, project?}, kept in the
268
269
  // job's private part (the format fabric_interop.py writes) so a service reads an answer's _meta.mandate for that step.
270
+ // A step is kept as the gate reads it (keptStep), so a job's start and its answer agree.
269
271
  requestInput(jobId, inputRequests, statusMessage, { steps } = {}) {
272
+ const entries = Object.entries(inputRequests ?? {});
273
+ if (!entries.length || Array.isArray(inputRequests) || typeof inputRequests !== 'object') throw new InteropError('input_required needs a JSON object of at least one input request.'); // a JSON object of requests, first (unit 38, 40)
274
+ const kept = Object.create(null); // the value checked is the value kept (seam 34); a key __proto__ stays a key (35)
270
275
  for (const [key, step] of Object.entries(steps ?? {})) {
271
- if (!Object.hasOwn(inputRequests ?? {}, key) || !EFFECT_CLASSES.includes(step?.effect)) throw new InteropError(`Step ${key}: an input request's key with its effect class (DEC-0125).`);
276
+ kept[key] = Object.hasOwn(inputRequests, key) && step !== null && step !== undefined ? keptStep(step) : {};
277
+ if (!EFFECT_CLASSES.includes(kept[key].effect)) throw new InteropError(`Step ${key}: an input request's key with its effect class (DEC-0125).`);
272
278
  }
273
- const entries = Object.entries(inputRequests ?? {});
274
- if (!entries.length) throw new InteropError('input_required needs at least one input request.');
275
- for (const [key, request] of entries) if (!KEY.test(key) || request?.method !== 'elicitation/create') throw new InteropError(`Input request ${key} must be a keyed elicitation/create request.`);
279
+ for (const [key, request] of entries) if (!KEY.test(key) || request === null || typeof request !== 'object' || Array.isArray(request) || request.method !== 'elicitation/create') throw new InteropError(`Input request ${key} must be a keyed elicitation/create request.`);
276
280
  const [finding] = inputRequestLinkFindings({ inputRequests }); // FAC-SEM-143 (D12)
277
281
  if (finding) throw new InteropError(`${finding.instancePath}: ${finding.message}`);
278
- return this.transition(jobId, 'input_required', { inputRequests, statusMessage }, { steps: { ...(steps ?? {}) } });
282
+ return this.transition(jobId, 'input_required', { inputRequests, statusMessage }, { steps: kept });
279
283
  }
280
284
 
281
285
  answer(jobId, inputResponses) {
282
286
  const job = this.get(jobId);
283
287
  if (job.status !== 'input_required') return {};
284
- const matched = Object.fromEntries(Object.entries(inputResponses ?? {}).filter(([key, value]) =>
285
- key in (job.inputRequests ?? {}) && ['accept', 'decline', 'cancel'].includes(value?.action)));
288
+ const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); // as Python's dict (seam r37)
289
+ const asked = plain(job.inputRequests) ? job.inputRequests : {};
290
+ const matched = Object.fromEntries(Object.entries(plain(inputResponses) ? inputResponses : {}).filter(([key, value]) =>
291
+ Object.hasOwn(asked, key) && ['accept', 'decline', 'cancel'].includes(value?.action))); // an asked key, never an inherited name (seam 36)
286
292
  // The answered question's kept steps go in the same write: none outlives its answer.
287
293
  if (Object.keys(matched).length) this.transition(jobId, 'working', {}, { steps: {} });
288
294
  return matched;
@@ -43,9 +43,19 @@ export class InstanceLock {
43
43
  if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') throw new AlreadyRunning(readPid(this.path), this.path);
44
44
  throw error;
45
45
  }
46
- fs.ftruncateSync(this.fd, 0);
47
- fs.writeSync(this.fd, String(process.pid));
48
- fs.fsyncSync(this.fd);
46
+ try {
47
+ fs.ftruncateSync(this.fd, 0);
48
+ fs.writeSync(this.fd, String(process.pid));
49
+ fs.fsyncSync(this.fd);
50
+ } catch (error) { // a full disk or an I/O error: let the lock go, or nobody can (the caller drops this object)
51
+ const fd = this.fd;
52
+ this.fd = null;
53
+ try {
54
+ fs.closeSync(fd);
55
+ } catch { // a deferred write error (NFS) at the close: the lock still goes, the first error stands
56
+ }
57
+ throw error;
58
+ }
49
59
  return this;
50
60
  }
51
61
  // Linux: an abstract unix socket; Windows: a named pipe. Each is exclusive, has no file, and dies
@@ -66,7 +76,17 @@ export class InstanceLock {
66
76
  this.server.listen(name, resolve);
67
77
  });
68
78
  this.server.unref();
69
- atomicWrite(this.path, String(process.pid));
79
+ try {
80
+ atomicWrite(this.path, String(process.pid));
81
+ } catch (error) { // as on macOS: the name is let go with the failed write, the first error standing
82
+ const server = this.server;
83
+ this.server = null;
84
+ try {
85
+ server.close();
86
+ } catch { // nothing to add to the first error
87
+ }
88
+ throw error;
89
+ }
70
90
  return this;
71
91
  }
72
92
 
@@ -45,6 +45,9 @@ export const OPERATOR_SOURCE_PROTOCOL = 'operator-source/1';
45
45
  const SCOPE_MEMBERS = new Set(['capabilities', 'actions', 'effects', 'perCallMaxUsd', 'projects']);
46
46
  const STEP_MEMBERS = new Set(['agent', 'at', 'scope']);
47
47
  const AGENT_ID = /^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?$/; // common.schema.json agentId
48
+
49
+ /** A registry agent id (common.schema.json agentId): a provider or service id, optionally `.instance`. */
50
+ export const isAgentId = (value) => typeof value === 'string' && AGENT_ID.test(value);
48
51
  const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/; // common.schema.json capabilityName
49
52
  const REQUEST_KEY = /^[A-Za-z0-9_.-]{1,64}$/; // interop-common.schema.json requestKey
50
53
  const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
@@ -504,12 +504,17 @@ export function compose(draft, files) {
504
504
  return { pkg, notes };
505
505
  }
506
506
 
507
+ /** What a folder problem means, where its code alone does not say what to change (FAA-193). */
508
+ const FOLDER_MESSAGES = {
509
+ 'provider-manifest': 'provider-manifest: the provider manifest must be a JSON object, and when it carries costPresets fabric.package.json must carry the same costPresets (copy them; pack does not)',
510
+ };
511
+
507
512
  function allProblems(pkg, files) {
508
513
  return [
509
514
  ...schemaProblems(pkg).map((p) => ({ ...p, rule: 'schema' })),
510
515
  ...pathProblems(pkg).map((p) => ({ ...p, rule: 'path' })),
511
516
  ...packageFindings(pkg).map((p) => ({ ...p, rule: p.code })),
512
- ...checkPackageFolder(pkg, files).map((p) => ({ rule: 'folder', problem: p.problem, instancePath: p.path, message: p.problem })),
517
+ ...checkPackageFolder(pkg, files).map((p) => ({ rule: 'folder', problem: p.problem, instancePath: p.path, message: FOLDER_MESSAGES[p.problem] ?? p.problem })),
513
518
  ];
514
519
  }
515
520
 
@@ -62,6 +62,7 @@ from fabric_board_session import ( # noqa: F401
62
62
  _Work,
63
63
  Handler,
64
64
  Responder,
65
+ PERSONAL_BOARD,
65
66
  )
66
67
  from fabric_board_fixture import ( # noqa: F401
67
68
  _FIXTURE_PROJECT,