@passioncode-ai/fabric-agent-adapter 0.60.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 (29) hide show
  1. package/CHANGELOG.md +142 -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 +1 -1
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +58 -7
  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 +1 -1
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +117 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +24 -5
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +10 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -8
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +24 -4
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +3 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +146 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +20 -5
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +33 -25
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +19 -6
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +23 -3
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +17 -3
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +86 -7
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +116 -18
  26. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  27. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +23 -23
  28. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +16 -1
  29. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -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}$/;
@@ -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,
@@ -4,18 +4,26 @@ names. Standard library only, Python 3.9+."""
4
4
  from __future__ import annotations
5
5
 
6
6
  import json
7
+ import math
8
+ import re
9
+ from collections.abc import Mapping
7
10
  import threading
8
11
  from pathlib import Path
9
12
  from typing import Any
10
13
  from typing import Callable
11
14
  from typing import Dict
15
+ from typing import List
12
16
  from typing import Optional
17
+ from typing import Tuple
13
18
 
14
19
  import sys as _sys # noqa: E402
15
20
  from pathlib import Path as _Path # noqa: E402
16
21
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
17
22
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
18
23
  import fabric_hub as _hub
24
+ import fabric_interop_rules as _rules
25
+ import fabric_mandate as _mandate
26
+ from fabric_time import format_rfc3339 as _format_rfc3339 # noqa: E402 - the core's (FAA-65)
19
27
  import fabric_service as _fs
20
28
  from fabric_board_shapes import ( # noqa: E402 - the kit (FAA-65)
21
29
  BoardError,
@@ -210,3 +218,141 @@ class BoardRequest:
210
218
 
211
219
  def __repr__(self) -> str: # never the body
212
220
  return "BoardRequest(%r, %r)" % (self.request_id, self.capability)
221
+
222
+
223
+ # -- the operator's mandate in a board request (FAA-135; contract project-comms.md, DEC-0125 MD-3) ---------------------
224
+
225
+ #: `mandate_from` naming every trusted sender of the board the service is installed on: the operator's personal board.
226
+ PERSONAL_BOARD = "personal-board"
227
+ _OPENER = re.compile(r"```json[ \t]*")
228
+ _CLOSER = re.compile(r"```[ \t]*")
229
+
230
+
231
+ def _json_blocks(body: str) -> List[str]:
232
+ """The bodies of the ```json blocks, each fence on its own line: one pass over the lines, so linear in the body
233
+ (an anchored regex rescanned to the end from every unclosed opener). Lines end at "\\n" alone, one "\\r" before it
234
+ dropped, as in the Node twin; an unclosed opener ends the scan."""
235
+ lines = [line[:-1] if line.endswith("\r") else line for line in body.split("\n")]
236
+ blocks: List[str] = []
237
+ i = 0
238
+ while i < len(lines):
239
+ if _OPENER.fullmatch(lines[i]):
240
+ j = i + 1
241
+ while j < len(lines) and not _CLOSER.fullmatch(lines[j]):
242
+ j += 1
243
+ if j == len(lines):
244
+ break
245
+ blocks.append("\n".join(lines[i + 1:j]))
246
+ i = j
247
+ i += 1
248
+ return blocks
249
+
250
+
251
+ def _no_constant(name: str) -> Any:
252
+ raise ValueError(name) # NaN and Infinity are not JSON
253
+
254
+
255
+ def _bounded_int(literal: str) -> int:
256
+ """An integer of at most 4300 digits on every Python: 3.9 without int_max_str_digits converts a longer one in
257
+ quadratic time, and later ones refuse it, so all of them now refuse it alike."""
258
+ if len(literal) - literal.startswith("-") > 4300:
259
+ raise ValueError("an integer longer than 4300 digits")
260
+ return int(literal)
261
+
262
+
263
+ def read_mandate(request: "BoardRequest", mandate_from: Any, agent_id: Optional[str]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
264
+ """The mandate a request carries, read only when the service opted in (`agent_id` and `mandate_from` set), the board
265
+ verified the sender (`provenance: trusted`) and its Project is one `mandate_from` names (or `PERSONAL_BOARD`): a T1
266
+ caller by project-comms. Answers `(mandate, None)`, `(None, None)` when none is read, or `(None, code)` for a body
267
+ that carries one the kit will not read: `mandate-blocks` (more than one) or `mandate-unreadable` (a block naming
268
+ `mandate/1` that is not JSON). A `source` in the block is dropped: a board block carries none (OA-7). Whether it
269
+ covers a call is `covers`' answer, never this one's."""
270
+ if agent_id is None or mandate_from is None or not isinstance(request.body, str):
271
+ return None, None
272
+ sender = request.sender if isinstance(request.sender, dict) else {}
273
+ principal = sender.get("principal") if isinstance(sender.get("principal"), dict) else {}
274
+ if principal.get("provenance") != "trusted":
275
+ return None, None
276
+ if not isinstance(sender.get("project"), str) or not sender["project"]: # comms-message requires it; the caller
277
+ return None, None
278
+ if mandate_from != PERSONAL_BOARD and _fold(sender["project"]) not in mandate_from:
279
+ return None, None
280
+ found = []
281
+ for block in _json_blocks(request.body):
282
+ try:
283
+ value = json.loads(block, parse_constant=_no_constant, parse_int=_bounded_int)
284
+ except (ValueError, RecursionError):
285
+ if "mandate/1" in block:
286
+ return None, "mandate-unreadable"
287
+ continue
288
+ if isinstance(value, dict) and value.get("kind") == "mandate/1":
289
+ value.pop("source", None) # a Project declares no protocols: a board block carries no source (OA-7)
290
+ found.append(value)
291
+ if len(found) > 1:
292
+ return None, "mandate-blocks"
293
+ return (found[0], None) if found else (None, None)
294
+
295
+
296
+ CALL_MEMBERS = ("capability", "action", "effect", "spendUsd", "project") # what mandate_covers reads of a call
297
+
298
+
299
+ def covers(mandate: Optional[Dict[str, Any]], call: Any, agent_id: Optional[str], wall: Callable[[], float],
300
+ caller: Any = None) -> Optional[Dict[str, Any]]:
301
+ """`mandate_covers` for a call under a board request's mandate, its sender placed as T1 and no caller agent id (the
302
+ board names a Project's principal, not an agent the chain can bind; the chain's end alone binds it, MD-3). A covered
303
+ answer's `recordAs` names `caller`, the sender Project the board verified (MD-3 records the caller, as the action
304
+ path does). None when no mandate travelled: the handler asks the operator as it would without one. The call's
305
+ `spendUsd` and `project` are read failing closed, as the kit's other mandate readers read them (FAA-139): a spend
306
+ that is not a finite number is none, which a ceiling refuses; a `project` that is not a string, None included (the
307
+ contract's mandateCovers refuses a null project), is unreadable, which a mandate limited to projects refuses; a call
308
+ that names none leaves `project` out. Only the five members `mandate_covers` reads are read, each once, the way the
309
+ handler's own `call[name]` reads it (a `__getitem__` or a default answers, never the mapping's storage), and the
310
+ rest of the call is never touched."""
311
+ if mandate is None or agent_id is None:
312
+ return None
313
+ if isinstance(call, Mapping):
314
+ read: Dict[str, Any] = {}
315
+ for name in CALL_MEMBERS:
316
+ try:
317
+ value = call[name]
318
+ except KeyError: # not named; any other error the mapping raises passes to the handler
319
+ continue
320
+ if name == "spendUsd":
321
+ value = _rules.mandate_spend(value)
322
+ if value is None:
323
+ continue
324
+ elif name == "project":
325
+ value = value if type(value) is str else _rules.UNREADABLE_PROJECT
326
+ read[name] = value
327
+ call = read
328
+ clock, now = wall(), ""
329
+ if isinstance(clock, (int, float)) and not isinstance(clock, bool):
330
+ try:
331
+ now = _format_rfc3339(clock * 1000) if math.isfinite(clock) else ""
332
+ except (OverflowError, ValueError): # finite but outside the dates a clock can name
333
+ now = ""
334
+ answer = _mandate.mandate_covers({"mandate": mandate, "tier": "T1", "now": now, "self": agent_id, "call": call})
335
+ if "recordAs" in answer:
336
+ answer = {"ok": True, "recordAs": dict(answer["recordAs"], caller=caller)}
337
+ return answer
338
+
339
+
340
+ def mandate_options(agent_id: Any, mandate_from: Any) -> Any:
341
+ """The responder's opt-in, checked: an agent id the registry accepts, and `mandate_from` either `PERSONAL_BOARD` or
342
+ a collection of Project ids (strings). Answers `mandate_from` as the responder keeps it (a frozenset or the string)."""
343
+ if agent_id is not None and not _mandate.is_agent_id(agent_id):
344
+ raise BoardError("invalid_arguments", "agent_id must be the service's registry agent id.")
345
+ if mandate_from is None or mandate_from == PERSONAL_BOARD:
346
+ return mandate_from
347
+ try:
348
+ projects = None if isinstance(mandate_from, (str, bytes, Mapping)) else list(mandate_from)
349
+ except TypeError:
350
+ projects = None
351
+ if projects is None or not all(isinstance(p, str) and p for p in projects):
352
+ raise BoardError("invalid_arguments", "mandate_from is %r or a collection of Project ids." % PERSONAL_BOARD)
353
+ return frozenset(_fold(p) for p in projects)
354
+
355
+
356
+ def _fold(project: str) -> str:
357
+ """A Project id as Fabric's board compares it: a uuid lower-cased, any other id as written (_hub._project)."""
358
+ return project.lower() if _hub.UUID.fullmatch(project) else project
@@ -17,6 +17,7 @@ import sys as _sys # noqa: E402
17
17
  from pathlib import Path as _Path # noqa: E402
18
18
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
19
19
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
20
+ import fabric_board_link as _link
20
21
  import fabric_hub as _hub
21
22
  import fabric_service as _fs
22
23
  from fabric_board_shapes import ( # noqa: E402 - the kit (FAA-65)
@@ -26,6 +27,7 @@ from fabric_board_link import ( # noqa: E402,F401 - re-exported: callers import
26
27
  BoardRequest,
27
28
  BoardSession,
28
29
  Checkpoint,
30
+ PERSONAL_BOARD,
29
31
  )
30
32
  from fabric_board_slots import ( # noqa: E402,F401 - the slot records (FAA-155, FAA-163); the constants re-exported
31
33
  HOLD_RETRY_MAX_SECONDS,
@@ -63,12 +65,18 @@ NOT_OURS = frozenset(["queued", "claimed", "answered"])
63
65
  class WorkContext:
64
66
  """What a handler may do while it works: `progress(note)` posts a note on the request's thread (and renews the
65
67
  lease); `begin_effect(summary)` must be called (and must return) before the handler spends or acts outside
66
- the service; `stopped` is set when the attempt is no longer this responder's. Both raise `Stopped` then."""
68
+ the service; `stopped` is set when the attempt is no longer this responder's. Both raise `Stopped` then.
69
+ `mandate` is the operator's mandate the request carried, when the responder reads one (FAA-135), and
70
+ `mandate_covers(call)` whether it covers a call (`mandate_covers`' answer), None when none travelled."""
67
71
 
68
- def __init__(self, responder: "Responder", entry: Dict[str, Any]):
69
- self._responder, self._entry = responder, entry
72
+ def __init__(self, responder: "Responder", entry: Dict[str, Any], mandate: Optional[Dict[str, Any]] = None,
73
+ caller: Optional[str] = None):
74
+ self._responder, self._entry, self.mandate, self._caller = responder, entry, mandate, caller
70
75
  self.stopped = threading.Event()
71
76
 
77
+ def mandate_covers(self, call: Dict[str, Any]) -> Optional[Dict[str, Any]]:
78
+ return _link.covers(self.mandate, call, self._responder.agent_id, self._responder.wall, self._caller)
79
+
72
80
  def progress(self, note: Optional[str] = None) -> None:
73
81
  if self.stopped.is_set():
74
82
  raise Stopped()
@@ -133,13 +141,16 @@ class Responder(SlotKeeper):
133
141
  observe: Optional[Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]] = None,
134
142
  emit: Optional[Callable[..., Any]] = None, service: str = "service",
135
143
  now: Callable[[], float] = time.monotonic, rand: Callable[[], float] = random.random,
136
- spawn: Optional[Callable[[Callable[[], None]], Any]] = None, session_factory: Optional[Callable[..., BoardSession]] = None):
144
+ spawn: Optional[Callable[[Callable[[], None]], Any]] = None, session_factory: Optional[Callable[..., BoardSession]] = None,
145
+ agent_id: Optional[str] = None, mandate_from: Any = None, wall: Callable[[], float] = time.time):
137
146
  if admit is None or handler is None:
138
147
  raise BoardError("invalid_arguments", "A responder needs the service's own input gate (admit) and a handler.")
139
148
  caps = list(capabilities)
140
149
  if not caps or any(not isinstance(c, str) or not _CAPABILITY.fullmatch(c) for c in caps) or len(set(caps)) != len(caps):
141
150
  raise BoardError("invalid_arguments", "Name each capability once, [a-z][a-z0-9._-]{1,127}.")
142
151
  self.hub, self.project = hub, _hub._project(project_id)
152
+ #: FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; None reads none).
153
+ self.mandate_from, self.agent_id, self.wall = _link.mandate_options(agent_id, mandate_from), agent_id, wall
143
154
  self.capabilities = caps
144
155
  self.credential_file, self.handler, self.admit, self.observe = Path(credential_file), handler, admit, observe
145
156
  self.checkpoint = Checkpoint(Path(checkpoint_file))
@@ -735,7 +746,11 @@ class Responder(SlotKeeper):
735
746
  return True
736
747
 
737
748
  def _run(self, entry: Dict[str, Any], request: BoardRequest) -> None:
738
- ctx = WorkContext(self, entry)
749
+ mandate, unread = _link.read_mandate(request, self.mandate_from, self.agent_id)
750
+ if unread: # by its code alone: the block holds the operator's words
751
+ self._emit("board.request.mandate_unread", "warning", "Request %s carries a mandate the kit does not read (%s); "
752
+ "it is worked without one." % (entry["request"], unread), request=entry["request"], reason=unread)
753
+ ctx = WorkContext(self, entry, mandate, request.sender.get("project") if isinstance(request.sender, dict) else None)
739
754
  work = _Work(entry, request, ctx)
740
755
  work.renewed_at = self.now()
741
756
  self.current = work
@@ -33,6 +33,7 @@ import fabric_service as _fs # noqa: E402
33
33
  from fabric_base import store_lock # noqa: E402 - one lock per store path (R3)
34
34
  from fabric_actions import action_caller, readable_action # noqa: E402 - DEC-0121
35
35
  from fabric_mandate import EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, is_agent_id # noqa: E402 - DEC-0125, DEC-0126
36
+ from fabric_jsvalue import real_number as _real_number # noqa: E402 - the core's (FAA-65)
36
37
  from fabric_time import format_rfc3339 # noqa: E402 - the core's (FAA-65)
37
38
  from fabric_page_links import ( # noqa: E402 - DEC-0123
38
39
  PageLinkError, dashboards_link, input_request_link_findings, page_link_findings, parse_dashboards_link)
@@ -40,6 +41,7 @@ from fabric_page_links import ( # noqa: E402 - DEC-0123
40
41
  from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
41
42
  _days_from_civil,
42
43
  UNREADABLE_PROJECT,
44
+ kept_step,
43
45
  mandate_spend,
44
46
  PROTOCOL,
45
47
  EXTENSION_KEY,
@@ -393,30 +395,33 @@ class JobStore:
393
395
  steps: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Any]:
394
396
  """input_required with MCP elicitation requests, keyed; build them with choice_request / form_request / url_request.
395
397
  `steps` (DEC-0125) maps a request key to the approval step it asks for, {effect, spendUsd?, project?}, kept in the
396
- job's private part so an answer's `_meta.mandate` is read for that step (CallContext.approval_step writes it)."""
397
- for key, step in (steps or {}).items():
398
- if key not in (input_requests or {}) or not isinstance(step, dict) or step.get("effect") not in EFFECT_CLASSES:
399
- raise InteropError("Step %r: an input request's key with its effect class (DEC-0125)." % key)
400
- if not input_requests:
401
- raise InteropError("input_required needs at least one input request.")
402
- for key, request in input_requests.items():
403
- if not _KEY.match(key) or request.get("method") != "elicitation/create":
404
- raise InteropError("Input request %r must be a keyed elicitation/create request." % key)
398
+ job's private part so an answer's `_meta.mandate` is read for that step (CallContext.approval_step writes it).
399
+ A step is kept as the gate reads it (kept_step), so a job's start and its answer agree."""
400
+ if not isinstance(input_requests, dict) or not input_requests: # a JSON object of requests, first (readings 38, 39)
401
+ raise InteropError("input_required needs a JSON object of at least one input request.")
402
+ kept: Dict[str, Dict[str, Any]] = {}
403
+ for key, step in (steps or {}).items(): # the value checked is the value kept (seam reading 34)
404
+ kept[key] = kept_step(step) if key in input_requests and isinstance(step, dict) else {}
405
+ if not (type(kept[key].get("effect")) is str and kept[key]["effect"] in EFFECT_CLASSES):
406
+ raise InteropError("Step %r: an input request's key with its effect class (DEC-0125)." % (key,))
407
+ for key, request in input_requests.items(): # each a keyed object, as Node refuses one (product reading 39)
408
+ if not isinstance(key, str) or not _KEY.match(key) or not isinstance(request, dict) or request.get("method") != "elicitation/create":
409
+ raise InteropError("Input request %r must be a keyed elicitation/create request." % (key,))
405
410
  for finding in input_request_link_findings({"inputRequests": input_requests}): # FAC-SEM-143 (D12)
406
411
  raise InteropError("%s: %s" % (finding["instancePath"], finding["message"]))
407
- return self._transition(job_id, "input_required", {"steps": dict(steps or {})},
412
+ return self._transition(job_id, "input_required", {"steps": kept},
408
413
  inputRequests=input_requests, statusMessage=message)
409
414
 
410
415
  def answer(self, job_id: str, input_responses: Dict[str, Any]) -> Dict[str, Any]:
411
- """Apply inputResponses to an input_required job; unknown keys are ignored (MCP Tasks rule).
416
+ """Apply inputResponses (a JSON object, a dict) to an input_required job; unknown keys are ignored (MCP Tasks rule).
412
417
 
413
418
  Returns the responses that matched; when any did, the job is working again."""
414
419
  with self._lock:
415
420
  job = self.get(job_id)
416
421
  if job["status"] != "input_required":
417
422
  return {}
418
- matched = {key: value for key, value in (input_responses or {}).items()
419
- if key in job.get("inputRequests", {}) and isinstance(value, dict)
423
+ matched = {key: value for key, value in (input_responses if isinstance(input_responses, dict) else {}).items()
424
+ if isinstance(job.get("inputRequests"), dict) and key in job["inputRequests"] and isinstance(value, dict)
420
425
  and value.get("action") in ("accept", "decline", "cancel")}
421
426
  if matched: # the answered question's kept steps go in the same write: none outlives its answer
422
427
  self._transition(job_id, "working", {"steps": {}})
@@ -532,9 +537,7 @@ class CallContext:
532
537
  if step["covered"]:
533
538
  return step["record"]
534
539
  self.server._fault(capability, step) # the start hears it as the answer does, under the same name (MJ-1)
535
- spend = mandate_spend(spend_usd) # a NaN or infinity reads as no estimate, and json would write a bare NaN
536
- kept = {"effect": effect, **({"spendUsd": spend} if spend is not None else {}),
537
- **({"project": project if isinstance(project, str) else UNREADABLE_PROJECT} if project is not None else {})}
540
+ kept = {"effect": effect, "spendUsd": spend_usd, "project": project} # request_input keeps it as the gate reads it
538
541
  self.server.jobs.request_input(job_id, {key: request}, step["message"] or message, steps={key: kept})
539
542
  return None
540
543
 
@@ -712,9 +715,9 @@ class McpToolServer:
712
715
  raise InteropError("Tool %s: a human step needs its link, an https URL or a path (DEC-0121 AP-4)." % tool["name"])
713
716
  if not (target is None or isinstance(target, str) or callable(target)):
714
717
  raise InteropError("Tool %s: target is an argument's name or a function of the arguments." % tool["name"])
715
- if self.agent_id is not None and action["gate"] == "relayed" and not (isinstance(effect, str) and effect in EFFECT_CLASSES):
718
+ if self.agent_id is not None and action["gate"] == "relayed" and not (type(effect) is str and effect in EFFECT_CLASSES):
716
719
  raise InteropError("Tool %s: a relayed action on a server that reads mandates names its effect class (DEC-0125)." % tool["name"])
717
- if effect is not None and not (isinstance(effect, str) and effect in EFFECT_CLASSES):
720
+ if effect is not None and not (type(effect) is str and effect in EFFECT_CLASSES):
718
721
  raise InteropError("Tool %s: effect is one of %s." % (tool["name"], ", ".join(EFFECT_CLASSES)))
719
722
  if not (spend is None or callable(spend) or (isinstance(spend, (int, float)) and not isinstance(spend, bool))):
720
723
  raise InteropError("Tool %s: spend is a number of USD or a function of the arguments." % tool["name"])
@@ -772,18 +775,20 @@ class McpToolServer:
772
775
  caller_agent = (verdict or {}).get("caller")
773
776
  # The estimate is read as a double, as the contract does: an int past the float range is infinite, and an
774
777
  # estimate that is not a finite number reads as none, which a mandate's ceiling refuses (fail closed).
778
+ number = _real_number(spend) # by what it stores; a bool, a mock or not a number is none (seam reading 18)
775
779
  try:
776
- double = float(spend) if isinstance(spend, (int, float)) and not isinstance(spend, bool) else math.nan
780
+ double = float(number) if number is not None else math.nan
777
781
  except OverflowError:
778
782
  double = math.inf
783
+ spend = number
779
784
  finite = math.isfinite(double) # the service's own number goes on when it is finite (25 stays 25)
780
- if project is _UNREADABLE or not (project is None or isinstance(project, str)):
781
- project = _UNREADABLE_PROJECT # a function that raised or named no string: a scoped mandate refuses
785
+ if project is _UNREADABLE or not (project is None or type(project) is str): # an exact str, as the core compares
786
+ project = _UNREADABLE_PROJECT # a function that raised or named no exact str: a scoped mandate refuses
782
787
  return {"self_id": self.agent_id, "effect": entry.get("effect"),
783
788
  "caller_agent": caller_agent if isinstance(caller_agent, str) and caller_agent else None,
784
789
  # an estimate that raised or is not finite is none: a mandate with a ceiling then refuses (fail closed)
785
790
  "spend_usd": spend if finite else None,
786
- "project": project if isinstance(project, str) else None}
791
+ "project": project} # None or an exact str, read once above: never re-decided into no project (unit r33)
787
792
 
788
793
  @staticmethod
789
794
  def _read(value: Any, arguments: Dict[str, Any]) -> Any:
@@ -800,8 +805,9 @@ class McpToolServer:
800
805
  agent_id, a step approval_step kept is read under the call's `_meta.mandate` too (C3.3)."""
801
806
  job_id = str(arguments.get("id", ""))
802
807
  job = self.jobs.get(job_id)
803
- steps = self.jobs.private(job_id).get("steps") or {}
804
- asked = job.get("inputRequests", {}) if job["status"] == "input_required" else {}
808
+ private = self.jobs.private(job_id) # one read of the job's private part (unit reading 39)
809
+ steps = private.get("steps") if isinstance(private.get("steps"), dict) else {}
810
+ asked = job.get("inputRequests") if job["status"] == "input_required" and isinstance(job.get("inputRequests"), dict) else {}
805
811
  records: Dict[str, Any] = {}
806
812
  for key, value in arguments["inputResponses"].items():
807
813
  if key not in asked or not isinstance(value, dict) or value.get("action") not in ("accept", "decline", "cancel"):
@@ -811,7 +817,7 @@ class McpToolServer:
811
817
  if self.agent_id is not None and isinstance(step, dict):
812
818
  # A kept step's mandate is read as approval_step read it, under the job's own capability, which must
813
819
  # be a capability name (MJ-1); the action itself stays fabric.job.get, so it is a readable action.
814
- capability = self.jobs.private(job_id).get("capability")
820
+ capability = private.get("capability")
815
821
  agent = (verdict or {}).get("caller")
816
822
  mandate = {"self_id": self.agent_id, "effect": step.get("effect"), "spend_usd": step.get("spendUsd"),
817
823
  "project": step.get("project"), "caller_agent": agent if isinstance(agent, str) and agent else None,
@@ -917,6 +923,8 @@ class McpToolServer:
917
923
  return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
918
924
  caller, inputs = self._caller(headers, tier, verdict), None
919
925
  responses = arguments.get("inputResponses")
926
+ if name == "fabric.job.get" and responses is not None and not isinstance(responses, dict): # seam r37: never a crash
927
+ return self._error(rid, -32602, "fabric.job.get takes inputResponses as a JSON object (interop C3.3).")
920
928
  if name == "fabric.job.get" and self.input_actions and isinstance(responses, dict) and responses:
921
929
  try:
922
930
  inputs = self._gate_inputs(arguments, headers, tier, meta, caller, verdict)
@@ -150,6 +150,7 @@ def _own(value: Any, key: str) -> Any:
150
150
  return value.get(key, _ABSENT) if isinstance(value, dict) else _ABSENT
151
151
 
152
152
 
153
+ import fabric_jsvalue as _jsvalue # noqa: E402 - the core's (FAA-65)
153
154
  from fabric_time import days_from_civil as _days_from_civil # noqa: E402 - the core's (FAA-65)
154
155
 
155
156
 
@@ -341,14 +342,14 @@ def job_step(mandate: Any, *, tier: Any, now: Any, self_id: Any, capability: Any
341
342
  it reads as mandate-malformed here as it does at the answer (FAA-178; McpToolServer does)."""
342
343
  if mandate is None:
343
344
  return {"covered": False, "message": None}
344
- if not (isinstance(effect, str) and effect in EFFECT_CLASSES):
345
+ if not (type(effect) is str and effect in EFFECT_CLASSES): # exact, as the core compares it (seam reading 34)
345
346
  raise InteropError("A job's approval step names its effect class (one of %s; DEC-0125)." % ", ".join(EFFECT_CLASSES))
346
347
  call: Dict[str, Any] = {"capability": capability, "action": key, "effect": effect}
347
348
  spend = mandate_spend(spend_usd) # read as action_gate reads it, so the start and the answer agree (FAA-139)
348
349
  if spend is not None:
349
350
  call["spendUsd"] = spend
350
351
  if project is not None:
351
- call["project"] = project if isinstance(project, str) else UNREADABLE_PROJECT
352
+ call["project"] = project if type(project) is str else UNREADABLE_PROJECT # exact, as the kept step (seam r32)
352
353
  covers: Dict[str, Any] = {"mandate": mandate, "tier": tier, "now": now, "self": self_id, "call": call}
353
354
  if isinstance(caller_agent, str) and caller_agent:
354
355
  covers["callerAgent"] = caller_agent
@@ -379,8 +380,11 @@ UNREADABLE_PROJECT = "\x00unreadable"
379
380
  def mandate_spend(value: Any) -> Any:
380
381
  """A spend estimate as a mandate reads it (FAA-139): a number read as a double, as the contract does; one that is
381
382
  not a finite double (an int past the float range, NaN, infinity) or not a number is none, which a mandate's
382
- ceiling refuses (fail closed). A finite number stays as given (25 stays 25)."""
383
- if not isinstance(value, (int, float)) or isinstance(value, bool):
383
+ ceiling refuses (fail closed). A finite number stays as given (25 stays 25). A subclass of int or float is read by
384
+ the number it holds, never by an overridden `float()` or `int()` (numpy.float64 3.0 reads 3.0; one whose `float()`
385
+ answers 0.0 for 100 reads 100); a bool is none."""
386
+ value = _jsvalue.real_number(value) # a bool, a mock that claims a number, or not a number: none
387
+ if value is None:
384
388
  return None
385
389
  try:
386
390
  return value if math.isfinite(float(value)) else None
@@ -388,6 +392,15 @@ def mandate_spend(value: Any) -> Any:
388
392
  return None
389
393
 
390
394
 
395
+ def kept_step(step: Dict[str, Any]) -> Dict[str, Any]:
396
+ """A job's approval step {effect, spendUsd?, project?} as a job file keeps it: read as the gate reads it, so a job's
397
+ start and its answer agree (0.61.0 seam reading 32, readings 33 and 34): a spend through mandate_spend (dropped
398
+ when none), a project as an exact str, else UNREADABLE_PROJECT (dropped when None); each member read once."""
399
+ effect, spend, project = step.get("effect"), mandate_spend(step.get("spendUsd")), step.get("project")
400
+ return {"effect": effect, **({"spendUsd": spend} if spend is not None else {}),
401
+ **({"project": project if type(project) is str else UNREADABLE_PROJECT} if project is not None else {})}
402
+
403
+
391
404
  def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target: Optional[str] = None,
392
405
  elicited: bool = False, approval: Any = None, action_digest: Optional[str] = None,
393
406
  human_step_url: Optional[str] = None, now: Any = None, self_id: Optional[str] = None,
@@ -408,7 +421,7 @@ def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target
408
421
  per-agent credential; never a name the call carries), `spend_usd` (its own estimate), `project`, and `mandate_capability` (a job's kept step: the job's capability
409
422
  the mandate is read under, DEC-0141 MJ-1; unusable, the refusal is operator-decision-required). Both are read failing
410
423
  closed, as McpToolServer reads them (FAA-139): a spend that is not a finite double is none (`mandate_spend`), a
411
- project that is not a string is UNREADABLE_PROJECT, which a mandate limited to projects refuses. A covered call
424
+ project that is not an exact str (a subclass included) is UNREADABLE_PROJECT, which a mandate limited to projects refuses. A covered call
412
425
  answers {ok: True, record: {by: operator, via: mandate, task, chain, caller}, decision: None, mandate}; without
413
426
  `self_id` the mandate is not read and the call is answered as before it existed."""
414
427
  import fabric_delegation as _fd # noqa: E402 - beside this file: DL-19
@@ -421,7 +434,7 @@ def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target
421
434
  if self_id is not None and isinstance(meta, dict) and "mandate" in meta:
422
435
  inputs.update({"mandate": meta["mandate"], "self": self_id})
423
436
  spend_usd = mandate_spend(spend_usd)
424
- project = project if project is None or isinstance(project, str) else UNREADABLE_PROJECT
437
+ project = project if project is None or type(project) is str else UNREADABLE_PROJECT
425
438
  for name, value in (("effect", effect), ("callerAgent", caller_agent), ("spendUsd", spend_usd), ("project", project),
426
439
  ("mandateCapability", mandate_capability)):
427
440
  if value is not None:
@@ -145,11 +145,29 @@ def get_string(value: Any, key: str) -> str:
145
145
 
146
146
 
147
147
  def is_number(value: Any) -> bool:
148
- return isinstance(value, (int, float)) and not isinstance(value, bool)
148
+ """An int or a float by its real type, a subclass's included; a bool, or a value that only claims a number through
149
+ `__class__` (a mock, a proxy), is not one (0.61.0 seam reading 19)."""
150
+ kind = type(value)
151
+ return kind is not bool and issubclass(kind, (int, float))
152
+
153
+
154
+ def real_number(value: Any) -> Any:
155
+ """The plain int or float a number holds (is_number), read by what it stores, never by its own float(), int() or
156
+ comparisons; None for anything else (0.61.0 seam reading 18, unit and product readings 19)."""
157
+ if not is_number(value):
158
+ return None
159
+ return float.__float__(value) if issubclass(type(value), float) else int.__int__(value)
160
+
161
+
162
+ def held(value: Any) -> Any:
163
+ """A number by what it stores (real_number); any other value as given, so it reads as it did before."""
164
+ number = real_number(value)
165
+ return value if number is None else number
149
166
 
150
167
 
151
168
  def double(value: Any) -> float:
152
169
  """A JSON number as JavaScript holds it: the nearest double, and an integer beyond the double range ±Infinity."""
170
+ value = held(value)
153
171
  try:
154
172
  return float(value)
155
173
  except OverflowError:
@@ -184,7 +202,8 @@ def set_has(items: List[Any], value: Any) -> bool:
184
202
  _JS_SPACE = "\t\n\v\f\r              

   "
185
203
 
186
204
 
187
- _JS_DECIMAL = re.compile(r"[+-]?(?:(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|Infinity)", re.ASCII)
205
+ # One way to split the digits, so a long string that fails to match costs its length (0.61.0 seam reading 22).
206
+ _JS_DECIMAL = re.compile(r"[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|Infinity)", re.ASCII)
188
207
 
189
208
 
190
209
  _JS_RADIX = re.compile(r"0(?:[xX](?P<x>[0-9a-fA-F]+)|[oO](?P<o>[0-7]+)|[bB](?P<b>[01]+))", re.ASCII)
@@ -196,9 +215,10 @@ def js_to_number(value: Any) -> float:
196
215
  return math.nan
197
216
  if value is None:
198
217
  return 0.0
199
- if isinstance(value, bool):
218
+ if value is True or value is False: # by identity: isinstance asks a number subclass's own __class__ (unit reading 30)
200
219
  return 1.0 if value else 0.0
201
220
  if is_number(value):
221
+ value = held(value) # by the number it holds: a subclass's own float() or comparison is never asked
202
222
  try:
203
223
  return float(value)
204
224
  except OverflowError: