@passioncode-ai/fabric-agent-adapter 0.60.0 → 0.62.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 (56) hide show
  1. package/CHANGELOG.md +246 -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 +4 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +1 -1
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +57 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +11 -9
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +8 -1
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +19 -2
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +58 -7
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +4 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -2
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +23 -6
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +4 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +6 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +16 -3
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +18 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +33 -3
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +10 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +117 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +24 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -1
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +33 -3
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +14 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -8
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +24 -4
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +3 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +57 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +16 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +146 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +20 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +55 -4
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +5 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +33 -25
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +19 -6
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +23 -3
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +17 -3
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +86 -7
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +57 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +217 -29
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +42 -4
  53. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  54. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +23 -23
  55. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +16 -1
  56. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -95,9 +95,58 @@ from `leaseExpiresAt`.
95
95
  why not; a refusal accepts the request and completes it `failed_known` with that sentence, so the requester learns it
96
96
  and the request does not bounce. A redacted body is refused the same way. The kit never hands a body to a model, a
97
97
  shell or a tool; what the handler does with an admitted body (and the checks it needs before a model, a tool or a
98
- spend sees it) is the service's own responsibility. The kit's responder reads no approval or mandate from a message yet
99
- (0.52.0): a request from the operator's own Project may carry `mandate/1` (DEC-0125 MD-3), and a handler acts on it only
100
- once the kit reads it (FAA-135).
98
+ spend sees it) is the service's own responsibility.
99
+
100
+ ### The operator's mandate in a request (FAA-135, 0.61.0)
101
+
102
+ A request one of the operator's own Projects sends for his task may carry his `mandate/1` as a JSON code block in its
103
+ body (contract project-comms.md, DEC-0125 MD-3). The kit cannot tell his personal board from an organization's
104
+ (`com.status` names neither), so reading one is opt-in and fail-closed:
105
+
106
+ - `Responder(..., agent_id="example-agent", mandate_from={"<project id>", ...})` (Node: `agentId`, `mandateFrom` as a
107
+ `Set`), or `mandate_from=PERSONAL_BOARD` (`"personal-board"`) on the responder whose hub and Project are his personal
108
+ board's, where every participant is his own Project; never on an organization context's responder (one instance per
109
+ context: a member's Project there is T2). Without both
110
+ options nothing is read, as before 0.61.0.
111
+ - A request is read only when the board verified its sender (`provenance: trusted`) and its Project is one
112
+ `mandate_from` names. Its sender is then a T1 caller. Project ids match as Fabric compares them: a uuid in any case,
113
+ any other id as written. The mandate is a block fenced ` ```json `, the opening and the closing fence each at the start
114
+ of its own line, with no indent (lowercase; an indented fence, as inside a list item, is not read either; ` ````json `, ` ```JSON ` and `~~~` are not read), whose value has `kind: "mandate/1"`; any other JSON
115
+ block, a decision block included, is not read, with no warning. A fence quoted inside another fenced block is still
116
+ read: quote an example in prose, not in a fence. Two `mandate/1` blocks, whoever their chains end in, or one whose text holds `mandate/1` and is
117
+ not JSON (`NaN` included), read as none (a broken block without `mandate/1` is skipped, and a mandate beside it
118
+ is still read), and the responder emits `board.request.mandate_unread` (a warning) with the
119
+ request id and its `reason`, `mandate-blocks` or `mandate-unreadable`: the block holds his words, which reach no event, log or
120
+ checkpoint. A Project declares no `protocols`, so a board block carries no `source` (OA-7): any `source` in it is
121
+ dropped unread. Python reads two kinds of valid JSON as not JSON where Node reads them, both fail-closed: a block nested
122
+ about 1,000 levels deep on Python 3.9 to 3.11 and about 10,000 on 3.12 and 3.13 (none on 3.14), and an integer longer
123
+ than 4300 digits on every Python (the kit refuses it there, as `int_max_str_digits` does from 3.11 and
124
+ the 3.8.14, 3.9.14 and 3.10.7 security releases; a limit configured
125
+ lower, `PYTHONINTMAXSTRDIGITS` on a Python that has one, refuses an integer longer than it).
126
+ - The handler gets `ctx.mandate` (the one block without `source`, or None) and `ctx.mandate_covers(call)` (Node
127
+ `ctx.mandateCovers(call)`, `call` being `{capability, action?, effect, spendUsd?, project?}`: pass `project` when the work
128
+ acts in one, since a call naming none is not limited by the mandate's `projects`; both are read failing closed, as
129
+ the kit's other mandate readers read them, FAA-139: a `spendUsd` that is not a finite number is none, which a
130
+ `perCallMaxUsd` refuses, and a `project` that is not a string (in Python an exact `str`, so a subclass too), None
131
+ (Node null) included, is refused by a mandate
132
+ limited to projects, as the contract's `mandateCovers` refuses a null project: leave `project` out when the work
133
+ acts in none; the MCP and job readers read None as naming none; only these five members are read, each once, as the
134
+ contract reads them, `call[name]` in Python and a plain property read in Node, an inherited, getter or Proxy member
135
+ included, but for a Node spend, read as the call's own member, so an inherited spend is none; a number subclass is
136
+ read by the number it holds, never by its `float()`, in every Python reader, and a mock, a proxy or a `Decimal` is no
137
+ spend; all five are read up front, whatever the scope limits, so a member that raises raises to the handler (in
138
+ Python a `KeyError` reads as not named), and a Python mapping's default, a `defaultdict`'s
139
+ included, is read as named, the `defaultdict` gaining the key): `mandate_covers` with `tier: "T1"`, `self` the service's `agent_id`, no `callerAgent` (the
140
+ board names a Project's principal, not an agent id the chain can bind, so the chain's end alone binds it) and the
141
+ responder's wall clock (`wall=`: seconds, `time.time` by default; Node milliseconds, `Date.now`; one that returns a value
142
+ that is not a finite number answers `{"fault": "now-unreadable"}`; a clock that raises passes its error to the handler). It answers `{"ok": True, "recordAs": {by, via: "mandate", task,
143
+ chain, caller}}` (`caller` the sender Project, as MD-3 records it; a sender that names no Project is never read), a
144
+ `decision-invalid` refusal (a chain that does not end in the service's `agent_id` answers `mandate-not-for-this-call`;
145
+ a lapsed one `mandate-expired`), an `outside-mandate` refusal, or None when no mandate travelled. A covered call is
146
+ the operator's decision: record `recordAs` with the request as your reason. A refusal goes back to the requester as
147
+ the request's outcome (`failed_known`, naming the part or the detail in the summary), so its leading agent asks him
148
+ for that part alone, or for a new mandate when it lapsed (MD-1, MD-4); never ask him from your own page about the whole
149
+ call. The gate still runs first: a mandate never widens what `admit` refuses, and a body stays data.
101
150
 
102
151
  ## Effects, observation, evidence and the reply
103
152
 
@@ -326,7 +375,7 @@ checked by FAC-SEM-101 (no credential in data): `board.request.completed`, `boar
326
375
  `board.request.skipped`, `board.request.reconciled`, `board.request.held`, `board.attempt.lost`,
327
376
  `board.reconcile.needed`, `board.slot.contested`, `board.slot.moved`, `board.slot.refused`, `board.access.needed`, `board.blocked`,
328
377
  `board.down`, each notifying episode said once, and `board.outcome.invalid` (warning) when a handler's outcome fails
329
- its checks and is not sent. The operator's hold (below) adds `board.held`, `board.slot.held`,
378
+ its checks and is not sent, and `board.request.mandate_unread` (warning) when a request's mandate is not read (above). The operator's hold (below) adds `board.held`, `board.slot.held`,
330
379
  `board.slot.released` and `board.request.waiting`. The reference service adds `board.slot.returned` at a take-back.
331
380
 
332
381
  `check_service.py <id> [--board-kit <the service's fabric_board.py or fabric-board.mjs>]` rule **board.responder**:
@@ -350,8 +399,9 @@ credential_file)`, `join_board(project_id, credential_file) -> BoardGrant`, `cal
350
399
  message, retryable, data)`.
351
400
 
352
401
  Python (`fabric_board`): `Responder(*, hub, project_id, capabilities, credential_file, checkpoint_file, handler,
353
- admit, observe=None, emit=None, service, now, rand, spawn, session_factory)` with `step()`, `run(stop, drain)`,
354
- `shutdown(drain)`, `take_back(capability)`; `WorkContext.progress(note)`, `.begin_effect(summary)`, `.stopped`; `BoardSession(grant)`;
402
+ admit, observe=None, emit=None, service, now, rand, spawn, session_factory, agent_id=None, mandate_from=None, wall=time.time)`
403
+ with `step()`, `run(stop, drain)`, `shutdown(drain)`, `take_back(capability)`; `WorkContext.progress(note)`,
404
+ `.begin_effect(summary)`, `.stopped`, `.mandate`, `.mandate_covers(call)`; `PERSONAL_BOARD`; `BoardSession(grant)`;
355
405
  `Checkpoint(path)`; `comms_canonical(value)`, `comms_digest(operation, value)`, `receipt_problems(value, op)`,
356
406
  `refusal_problems(value)`, `directory_findings(page)`, `directory_problems(page)`, `find_project(page, project_id,
357
407
  capability)`, `artifact_ref(uri, content|path, label)`, `evidence_problems(list)`, `outcome_problems(outcome,
@@ -359,4 +409,5 @@ effect_began)`, `self_check()`; `BoardError`, `Stopped`.
359
409
 
360
410
  Node: the same names in camelCase (`locateHub(root, {alive, estate, now})`, `new HubClient(root, {alive, timeout,
361
411
  estate, post})`, `new Responder({...})` with async `step()`, `run({signal, drain})`, `shutdown(drain)`,
362
- `takeBack(capability)`; `await ctx.progress(note)`, `await ctx.beginEffect(summary)`; `selfCheck()`). Both kits read and write the same checkpoint file.
412
+ `takeBack(capability)`; `await ctx.progress(note)`, `await ctx.beginEffect(summary)`, `ctx.mandate`, `ctx.mandateCovers(call)`;
413
+ the options `agentId`, `mandateFrom` and `wall` (milliseconds); `selfCheck()`). Both kits read and write the same checkpoint file.
@@ -45,8 +45,10 @@ const answer = await request('GET', 'https://api.example/v1/items', { headers, d
45
45
 
46
46
  Every answer comes back as a `Response`, whatever its status; `HttpError` means no usable answer came, with `kind`
47
47
  `connection`, `timeout`, `too-large` or `refused`, the status when there was one, and the URL redacted to scheme,
48
- host, port and path (`redact_url`); a URL that is not http or https keeps only its scheme (`htps:<redacted>`), and
49
- one that does not parse as a URL is `<unparseable url>`. The path is not redacted (Node writes it as `new URL`
48
+ host, port and path (`redact_url`); a URL that is not http or https keeps only its scheme (`htps:<redacted>`, and `ssh:<redacted>`
49
+ for `ssh://h:99999/r` too), and an http, https, ws, wss or ftp URL that does not parse (Node reads it with `new URL`;
50
+ Python reads its own special-scheme form and prints what it finds, never the user information), or a value with no
51
+ scheme, is `<unparseable url>`. The path is not redacted (Node writes it as `new URL`
50
52
  normalises it, Python as given), so a key never belongs in it. Python's `check_url` refuses a host
51
53
  with a backslash, space, control character (C0, DEL or C1) or `%`, a URL with a space or ASCII control character before its
52
54
  first `#` (the default transport sends a URL up to there, as Node's does; a transport you pass receives the URL as given), or a character outside ASCII in its path or query (ASCII whitespace at its ends is trimmed, as urllib and `new URL` trim it; a Unicode space ending the part before the first `#` is refused, where Node keeps it percent-encoded), a bracketed host that is not an IPv6 address with no zone, and a port that is not
@@ -133,8 +133,8 @@ part before the first `#` is refused, as `check_url` refuses it, because Node ke
133
133
  trim it and read the keys when it ends the URL); a leading space is refused as not a URL), a host holding `%`, a backslash, a Unicode space or a control character (urllib
134
134
  would decode or dial it as another host), a bracketed host that is no IPv6 address, a port that is not ASCII digits or is above
135
135
  65535, no host, and an empty userinfo. The Node kit accepts several of these as `new URL` reads them, among them a `%`
136
- host decoded, a backslash as `/`, a tab or line break removed, an empty userinfo dropped and an extra slash before the
137
- host skipped. The
136
+ host decoded, a backslash as `/`, a tab or line break removed and an extra slash before the host skipped. A user
137
+ information as written is refused by both kits, empty or holding a backslash (since 0.62.0). The
138
138
  Python kit verifies Ed25519 itself (RFC 8032, standard library only, strict as OpenSSL: `S` below the group order,
139
139
  canonical points, no small-order key); it follows no redirect when it reads the key set; the Node kit uses `node:crypto` and refuses small-order keys
140
140
  itself (`isSmallOrderKey`), because Node before 26 lets OpenSSL accept a signature under one.
@@ -202,6 +202,8 @@ const { dirs, instance, lock } = await holdContextInstance('example-agent', 'org
202
202
  `~/Library/Application Support/<id>.<instance>`, `~/Library/Logs/<id>.<instance>`, `~/Library/Caches/<id>.<instance>`),
203
203
  never inside the personal tree. `launchd_uninstall(..., purge=True, service_id=..., instance=...)` /
204
204
  `launchdUninstall(..., {purge, serviceId, instance})` purge only that instance's folders.
205
+ - **Its descriptor names its repository.** A new instance is a new install, so its descriptor passes
206
+ `source.repository` as the personal one does (FAA-198, since 0.62.0); without it the write is refused.
205
207
  - **What it checks, before anything is created.** `context_instance` / `contextInstance` is pure and answers
206
208
  `{context, kind, org?, instance, key, dirs, descriptorPath, grant?}`, never the record. An organization's instance
207
209
  needs the estate record revision this machine applied (verified with `verify_estate_record`; the helper verifies no
@@ -266,8 +266,13 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
266
266
  - A `spend` or `project` function that raises, or a spend that is not a finite number, fails closed: the call is
267
267
  answered, and a mandate with a `perCallMaxUsd` ceiling or a `projects` list refuses it (`outside-mandate: spend` or
268
268
  `outside-mandate: project`); the estimate is read as a double, so an integer past the float range is infinite and
269
- refused under a ceiling, and a project function that answers anything but a string or `None` names no project a
270
- mandate can list. A refusal outside a mandate
269
+ refused under a ceiling (a subclass of int or float is read by the number it holds, never by its `float()` or
270
+ comparisons; a bool, a `Decimal`, a `numpy.int64` or a value that only claims a number through `__class__`, a mock
271
+ or a proxy, is no estimate), and a project function that answers anything but a string or `None` names no project a
272
+ mandate can list (in Python an exact `str`: the core compares a project by exact type, so a `str` subclass, a
273
+ `StrEnum` member included, is refused by a mandate limited to projects, at a job's start and at its answer alike,
274
+ since the kept step holds it as unreadable; in Node only a primitive string is a project, so a `String` object is
275
+ refused the same way). A refusal outside a mandate
271
276
  tells the caller the MD-4 order: call again within its own mandate, else pass the refusal up; only the leading agent
272
277
  asks the operator.
273
278
  - `add_tool(tool, handler, action=, effect=, spend=, project=)`: on a server with `agent_id`, a `relayed` action names
@@ -310,7 +315,9 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
310
315
  server with `agent_id`) answers the record when the mandate covers the step `{capability: the job's, action: key,
311
316
  effect}` and asks nothing, the job going on as the operator's decision; otherwise the job waits `input_required` on
312
317
  `request`, its message naming the part not covered (`outside-mandate: <part>`) or saying the mandate expired, and the
313
- step is kept in the job's private part (`request_input(..., steps=)`). An answer's `_meta.mandate` that covers that
318
+ step is kept in the job's private part (`request_input(..., steps=)`), as the gate reads it (`kept_step` /
319
+ `keptStep`: an effect admitted as an exact `str` or a primitive string, a spend through `mandate_spend`, a project as
320
+ an exact `str` or a primitive string, else unreadable, each member read once; other keys are not kept). An answer's `_meta.mandate` that covers that
314
321
  step is then read when `input_actions=True`, beside a decision for the key. The rule alone is `job_step(mandate, ...)`
315
322
  / `jobStep({mandate, tier, now, self, capability, key, effect, callerAgent, spendUsd, project})`; Node's
316
323
  `requestInput(jobId, inputRequests, statusMessage, {steps})` keeps the same private part. Node has no
@@ -325,7 +332,11 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
325
332
  `capabilities` name `fabric.job.get` covers no step of another capability.
326
333
  Node has no tool server: a Node service reads `store.load(jobId).private.steps` and `.private.capability` BEFORE
327
334
  `store.answer` (which clears the steps), gates the answer with `actionGate({..., mandateCapability:
328
- private.capability})`, then answers. It logs the `fault` that `jobStep` and `actionGate` return beside their
335
+ private.capability})` for each answered key the job asked whose value's action is accept, decline or cancel, one
336
+ decision per call (when `job.inputRequests` and the answer's `inputResponses` are plain
337
+ objects, `Object.hasOwn(job.inputRequests, key)`, its step read with `Object.hasOwn(steps, key)`, as McpToolServer
338
+ gates only asked keys; McpToolServer answers an `inputResponses` that is present, not `null` and no object with
339
+ `-32602`, and reads `null` and `{}` as a poll, which the contract's schema does not admit), then answers. It logs the `fault` that `jobStep` and `actionGate` return beside their
329
340
  answer, as `on_fault` does in Python: `mandate-capability-missing` at the start means the job's tool is not a
330
341
  capability name; at the answer it also means `mandateCapability` was not passed. Any service that gates a job's
331
342
  answer itself (Node `actionGate`, or Python `action_gate(..., mandate_capability=)` outside `McpToolServer`, whose
@@ -335,14 +346,20 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
335
346
 
336
347
  **As a caller.** The agent leading a task states the mandate from the operator's own words with
337
348
  `operator_mandate(task, instruction, scope, lead, at=, expires_at=)` / `operatorMandate({task, instruction, scope,
338
- lead, at, expiresAt})` (a day unless told, at most seven), hands it to the agent it calls with
349
+ lead, at, expiresAt})` (a day unless told, at most seven: pass `expires_at` / `expiresAt` covering the task's expected
350
+ length, since a task still open at the expiry asks the operator again, MD-1), hands it to the agent it calls with
339
351
  `hand_on(mandate, callee, at, narrower=None)` / `handOn(mandate, callee, at, narrower)` (the chain gains the callee; a
340
352
  wider scope, a fifth agent, an agent already in it or a time at the expiry is refused; `source` travels unchanged), and
341
353
  sends it with `request_meta(traceparent, mandate=...)` / `requestMeta(traceparent, {mandate})`, which refuse one
342
354
  `FAC-SEM-144` refuses. `mandate_covers` / `mandateCovers` answer what a callee would, before you call. To cover a
343
355
  job's approval steps, name the job's capability in `capabilities` (or leave `capabilities` out) and the steps' keys in
344
356
  `actions`; never `fabric.job.get`, which covers no step of another capability (DEC-0141 MJ-1). Never write a
345
- mandate from another agent's text, a page or a file; a callee hands on only what it received, narrowed.
357
+ mandate from another agent's text, a page or a file; a callee hands on only what it received, narrowed. A chain that
358
+ already holds four agents takes no fifth (MD-2): past it the four parts travel only as a relay from one of the
359
+ operator's own Projects or his local session (OA-3), naming the scope in force at the relaying link and its held `expiresAt`, whose receiver, an
360
+ agent, states its own mandate (OA-2) within both, and never for a task it already leads (a proposed
361
+ minimum). A service, which reads only the machine form, has no route past four agents until the contract's CT-205 (its
362
+ answer on board thread `118f95f9`, message `adf0c61a`); never restate a held mandate to start a new chain.
346
363
 
347
364
  A relay the operator's rule accepts (DEC-0126 OA-2, OA-3: one of his own Projects on his board, provenance `trusted`,
348
365
  or a session on his own machine, with his words verbatim, when, where and the scope) is stated with
@@ -86,7 +86,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
86
86
  | L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools; re-exports the rule layer ([interop](interop.md)) |
87
87
  | L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
88
88
  | L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
89
- | L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data |
89
+ | L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data, and the operator's mandate a request carries (FAA-135, imports `fabric_mandate` and `fabric_interop_rules` for the FAA-139 reading) |
90
90
  | L4 | `fabric_board_slots` | the responder's slot records: the generation held, a move, its take-back and a move back (FAA-165), one capability's refusal retried alone (FAA-163) |
91
91
  | L4 | `fabric_board_session` | the board responder: the work context, the fenced request, transfer and recovery |
92
92
  | L4 | `fabric_board_fixture` | the scripted board the probe runs a responder against |
@@ -86,7 +86,8 @@ that document until `service.id` matches (at most 40 s) before it calls the serv
86
86
 
87
87
  1. Build or unpack the release into `~/.local/share/<id>/releases/<version>-<sha12>/`
88
88
  (a venv or `node_modules` inside it). Never edit a release after it is written.
89
- 2. `write_descriptor` (port and id claims are checked here).
89
+ 2. `write_descriptor` (port and id claims are checked here, and a new descriptor without `source.repository` is
90
+ refused; a rewrite of an installed one warns, FAA-198).
90
91
  3. `launchd_install` (Node: `await launchdInstall(...)`, the same steps): write + `plutil -lint`, read `launchctl print-disabled gui/<uid>`,
91
92
  `enable` **only when no override is recorded** (a first install), `bootout` and wait
92
93
  until `launchctl print gui/<uid>/<label>` fails, `bootstrap` with up to five retries,
@@ -150,7 +151,8 @@ never infers supervision from its parent process.
150
151
 
151
152
  **An app** a person installs and starts has no supervisor (contract DEC-0069). Its descriptor says
152
153
  `"lifecycle": {"manager": "person", "app": "<the installed bundle or executable>"}`, `installedBy {by: "person", role,
153
- tool}` and no `origin`: it says the app is installed, never that it runs. The installer writes it; where there is none
154
+ tool}`, `source.repository` (the app's own repository; its first write is refused without it, FAA-198) and no `origin`:
155
+ it says the app is installed, never that it runs. The installer writes it; where there is none
154
156
  (a macOS app dragged into Applications), the app writes it on a start when `app_descriptor_action` /
155
157
  `appDescriptorAction` answers `write` (no descriptor yet, another version in `installedBy.tool`, or another path than
156
158
  `lifecycle.app`), and never from an App Translocation path. Its live origin and process id go in its own liveness file,
@@ -15,10 +15,12 @@ Order matters: each step leaves the service answering.
15
15
  delete it with the operator's consent.
16
16
  6. **Regenerate the plist** from `launchd_plist`, pointing at a release; delete stray
17
17
  `.plist.bak` files from `~/Library/LaunchAgents` after moving them elsewhere.
18
- 7. **Write the descriptor** from the installer; resolve any port claim it refuses by
19
- moving the newcomer, never the established service.
20
- 8. **Re-run the probe** until nothing `FAIL`s; record the table in the service's
21
- verification document.
18
+ 7. **Write the descriptor** from the installer, with `source.repository` (a new descriptor without it is refused);
19
+ resolve any port claim it refuses by moving the newcomer, never the established service.
20
+ 8. **Declare what it uses** with `connections_extension` / `connectionsExtension` in the well-known document; a
21
+ service that uses nothing outside itself declares an empty list (`connections_extension([])`), which reads `PASS`.
22
+ 9. **Re-run the probe** until nothing `FAIL`s and `descriptor.source-repository` and `well-known.connections` read
23
+ `PASS` (a `NOT_RUN` is not a pass); record the table in the service's verification document.
22
24
 
23
25
  ## Gaps that existing services usually have
24
26
 
@@ -55,7 +55,8 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
55
55
  | `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task), `node` (Project Observatory's node supervises it as its child, with no supervisor file, DEC-0061), `person` (an app a person installs and starts, then `app`, the installed app, DEC-0069) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032), `label` and `plist` beside any but `launchd` (DEC-0057), `app` beside any but `person`. Both kits and the probe accept all six; both kits write the unit and the task (`systemd_unit`, `task_xml` / `systemdUnit`, `taskXml`). An app's descriptor says it is installed, never that it runs: the probe checks `app` is still there (`descriptor.app-installed`) and reads no well-known document; a reader that finds the app gone treats the descriptor as stale (`app_descriptor_stale` / `appDescriptorStale`), and an app that writes its own descriptor rewrites it only when `app_descriptor_action` / `appDescriptorAction` says `write` |
56
56
  | `paths` | `data` (required), `logs[]`, optional `config`, `cache` |
57
57
  | `commands` | only `doctor` and `update`, argument arrays whose first item is an absolute or `~/` path (Windows: also `C:\…`, `C:/…` or `~\…`, never a network share; DEC-0032) |
58
- | `source.repository`, `fabricManifest` | optional |
58
+ | `source.repository` | the repository of the service's code (`https://github.com/owner/name`), which links the agent to its project. Optional in the schema; the kit's `write_descriptor` / `writeDescriptor` refuse a new descriptor without it since 0.62.0 (`source_refusal` / `sourceRefusal` says why, FAA-198), and rewriting one already installed without it warns (`NoSourceRepositoryWarning`; Node `process.emitWarning` with code `FABRIC_NO_SOURCE_REPOSITORY`), so an installed agent's update keeps working. The probe names its absence (`descriptor.source-repository`, `NOT_RUN`) |
59
+ | `fabricManifest` | optional |
59
60
  | `installedAt`, `installedBy` | when, and who installed it: the installer and its version as a string, or `{by, role?, tool}` (DEC-0061): `by` `person` (with `role` `operator` or `administrator`), `node` or `organization`; `role` for a person only; `tool` the installer and its version. It names a role, never a person. An app's descriptor uses the object form with `by: "person"`. `register_remote` / `registerRemote` and the sample write the object form |
60
61
  | `runner` | optional `{kind?, credentialKind}` (DEC-0061): `credentialKind` `subscription`, `key`, `platform-metered` or `none`: a kind, never a credential (`FAC-SEM-086` refuses a credential-shaped value here or in `installedBy`); a subscription names its vendor's runner (`claude-code`, `kimi-code`, `codex`) |
61
62
  | `peer` | optional `{endpoint, custody, residency, maxDataClass}` (DEC-0061): the service is offered to others. `endpoint` `https://<dns-name>[:<port>][/<path>]`, never a reserved name (`FAC-SEM-085`); `custody` `organization-managed`, `personal` or `processor-hosted` (an organization's cloud a processor runs under a processing agreement, DEC-0099); `residency` 1–32 ISO 3166-1 alpha-2 codes, `EU` admitted, user-assigned codes refused; `maxDataClass` `public` < `internal` < `confidential` < `restricted`. An offer needs `runner` and never runs on a subscription. A host sends a request from anyone but the operator only when `peer_refusal` / `peerRefusal` answers null (`FAC-SEM-088`); usage rows may take `route: "platform-metered"` |
@@ -127,7 +128,8 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
127
128
  `connectionsExtension(declared, {degraded, states})`: `state` comes from the service's own checks (`states`, by
128
129
  `id`), else `degraded` when `degraded[]` names it, else `unknown`; a declaration a host would hide is refused with
129
130
  `ServiceError`, and `build_well_known` refuses one passed in by hand. `connections_view` / `connectionsView` read
130
- a peer's list as Fabric Dashboards does, and `check_service.py` reports `well-known.connections`. Build it each
131
+ a peer's list as Fabric Dashboards does, and `check_service.py` reports `well-known.connections` (`NOT_RUN` when
132
+ the document has no connections declaration, FAA-199, and `PASS` for an empty list; the reference service declares its template and its demo source). Build it each
131
133
  time the well-known document is built, from the same `degraded` list:
132
134
 
133
135
  ```python
@@ -42,8 +42,18 @@ same objects (well-known document, events feed, login code) at the same paths.
42
42
  - `commands` may carry `doctor` (a local executable); never `update`.
43
43
  - `source.repository` names the repository of the service's code, so Fabric Dashboards can tell code apart from a store
44
44
  that versions data. Pass it as `registerRemote({ ..., source: { repository } })` / `register_remote(..., source=...)`
45
- (since 0.49.3); the kit checks it as the schema's URI (RFC 3986 characters, a host and a valid port when it names an
46
- authority) and refuses a `user:password@` or any value shaped like a credential in it.
45
+ (since 0.49.3; a new registration without it is refused since 0.62.0, before the token file is written, and a
46
+ rotation that passes none keeps the installed one, FAA-198); the
47
+ kit checks it as the schema's URI (RFC 3986 characters, a host and a valid port when it names an
48
+ authority) and refuses a `user:password@`, a user outside a URL's authority (an scp form), and a value shaped like a
49
+ credential in its user, query or fragment or as a path segment; a path segment in lower case, or of letters with no
50
+ digit, is the repository's name unless a key's shape starts it or follows a `.`, `_` or `-` in it (since 0.62.0, so
51
+ `task-pipeline-dashboard` and `Task-Pipeline-Dashboard` are accepted, a name that starts with `sk-` and 16 more
52
+ characters is not, in any case, nor `%73k-` and the rest; a host is read for a key's shape anywhere in it, inside a word too, so
53
+ `task-pipeline-dashboard.example.com` is refused, FAA-200 (32)). A path and a user information are read as written and with
54
+ their ASCII escapes decoded, and a password as written and as the URL standard reads it (`https://h\u:pw@x/r` is
55
+ refused). A checkout's origin in scp form (`git@host:owner/name.git`) is refused: write it `https://host/owner/name`,
56
+ the repository's own page with no path below it, no query and no fragment, which the estate's readers join.
47
57
  - `installedBy` is the installer's name and version as a string (`"example-installer 1.2.0"`), or
48
58
  `{by, role?, tool}` where `by` is `person`, `node` or `organization` and `role` (`operator`, `administrator`) is a
49
59
  person's only. There is no `agent` kind: an agent that registers a service writes the string form, or
@@ -111,5 +121,8 @@ The login rules keep every `Set-Cookie` of the answer and judge the session cook
111
121
 
112
122
  Rotate the token by setting the new value on the platform and re-running `registerRemote` on the
113
123
  operator's computer with it; the old token stops working the moment the platform restarts with the
114
- new value, and so does every session begun under it when `LoginCodes` has `token=`. Rotate the
124
+ new value, and so does every session begun under it when `LoginCodes` has `token=`. The re-run keeps the
125
+ installed `source` when it passes none (one the kit refuses, edited by hand, is dropped with the warning); pass
126
+ `runner`, `peer`, `summary`, `doctor` and `installedBy` / `installed_by` again, since a re-run writes only what it is
127
+ given (without `installedBy` it writes the default person form). Rotate the
115
128
  session key on the platform to sign every operator out.
@@ -64,6 +64,7 @@ from typing import Any, Dict, List, Optional, Tuple
64
64
  sys.path.insert(0, str(Path(__file__).resolve().parent))
65
65
  import fabric_service as fs # noqa: E402
66
66
  import fabric_connections as fcn # noqa: E402
67
+ from check_service_rules import shown_repository # noqa: E402 - a repository in evidence carries no userinfo or query
67
68
  import fabric_interop as fi # noqa: E402
68
69
 
69
70
  Result = Dict[str, str]
@@ -194,6 +195,18 @@ class Probe:
194
195
  else:
195
196
  unique = "port %d is unique" % self.port
196
197
  self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
198
+ # FAA-198: the repository that links the agent to its project; a new install is refused without it, an installed one
199
+ # is named here (NOT_RUN is no pass) until its owner adds it.
200
+ # A source descriptor.valid refuses is no pass either, and the evidence never carries a URL's userinfo or query,
201
+ # where a password rides (0.62.0 unit reading 2).
202
+ if not fs.names_repository(self.d):
203
+ self.add("descriptor.source-repository", "NOT_RUN", "the descriptor %s" % fs.NO_SOURCE_REPOSITORY)
204
+ # what the source adds, read in the member rules, which a missing required key never cuts short (unit reading 16)
205
+ elif added := sorted(set(fs.descriptor_member_problems(self.d)) - set(fs.descriptor_member_problems(
206
+ {k: v for k, v in self.d.items() if k != "source"}))): # its own problem (a member name it names is the descriptor's own, FAA-200 (39))
207
+ self.add("descriptor.source-repository", "NOT_RUN", "source is refused: %s" % added[0])
208
+ else:
209
+ self.add("descriptor.source-repository", "PASS", "names %s" % shown_repository(self.d["source"]["repository"]))
197
210
  if (self.d.get("lifecycle") or {}).get("manager") == "person":
198
211
  # DEC-0069: installed, never running — a reader checks the app is still there; liveness is the app's own file.
199
212
  present = fs.app_present(self.d)
@@ -275,6 +288,9 @@ class Probe:
275
288
  self.add("well-known.connections", "FAIL" if view["reported"] else "PASS",
276
289
  "; ".join(f["message"] for f in fcn.connections_rules(wk)[:5]) or
277
290
  "%d connection(s), %d named in degraded[]" % (len(view["connections"]), failing))
291
+ else: # FAA-199: named, never a pass, so the connection map is not left empty unseen
292
+ self.add("well-known.connections", "NOT_RUN", "declares no %s: a host cannot draw what the service uses "
293
+ "(connections_extension / connectionsExtension)" % fcn.CONNECTIONS_EXTENSION_KEY)
278
294
  # DEC-0121, FAC-SEM-142: every button's agent twin, declared; no declaration is NOT_RUN (D10).
279
295
  self.add("well-known.actions", *well_known_actions_verdict(wk))
280
296
  # DEC-0122: where the public Agent Card is; no member is NOT_RUN.
@@ -809,9 +825,9 @@ class Probe:
809
825
  if not source:
810
826
  self.add(rule, "NOT_RUN", "data is inside repository %s and the descriptor names no source.repository to tell code from data" % top)
811
827
  elif remote.returncode == 0 and same_repository(remote.stdout.strip(), source):
812
- self.add(rule, "FAIL", "data lives in the service's own code checkout %s (%s)" % (top, source))
828
+ self.add(rule, "FAIL", "data lives in the service's own code checkout %s (%s)" % (top, shown_repository(source)))
813
829
  else:
814
- self.add(rule, "PASS", "data is in repository %s, which is not the service's code (%s)" % (top, remote.stdout.strip() or "no remote"))
830
+ self.add(rule, "PASS", "data is in repository %s, which is not the service's code (%s)" % (top, shown_repository(remote.stdout.strip()) or "no remote"))
815
831
 
816
832
  def lock_rule(self, data: Path) -> None:
817
833
  lock = data / "service.lock"
@@ -18,6 +18,36 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
18
18
 
19
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
20
20
  import fabric_service as fs # noqa: E402 - beside this file
21
+ from fabric_http import redact_url # noqa: E402
22
+ from fabric_secrets import ascii_decoded, repository_name, secret_argument # noqa: E402
23
+
24
+ _LOCAL_PATH = re.compile(r"[/~.\\]|[A-Za-z]:[/\\]") # how a local path starts, never a user before an `@`
25
+ _SCP_HOST = re.compile(r"[^@/:\\]+:") # what follows the `@` of user[:password]@host:path
26
+ _SPECIAL = re.compile(r"(?:https?|wss?|ftp|file):", re.I) # the URL standard's special schemes, read with or without `//`
27
+ _URL = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://") # a scheme that starts the value; a later `://` is path text (unit reading 9)
28
+
29
+
30
+ def shown_repository(value: str) -> str:
31
+ """A repository or git origin as evidence: a URL through redact_url, which drops its user information as written
32
+ first (git's reading, `https://h\\u:pw@x/r`); a local path (one that starts with `/`, `~`, `.`,
33
+ `\\` or a drive) by its name; an scp form (user[:password]@host:path) from its host, its user and password dropped
34
+ whatever they hold, up to the last `@` that a host and `:` follow; anything else by its name. Then a query and a
35
+ fragment. A value with a piece shaped like a credential that is no name (fabric_secrets.repository_name) is not
36
+ printed (0.62.0 readings 2 to 11). Read first as redact_url reads it: C0 controls and space off its ends, every tab,
37
+ CR and LF out, so `https:/<tab>/u:pw@h` is the URL it is (unit reading 11)."""
38
+ value = re.sub(r"[\t\n\r]", "", value.strip("".join(map(chr, range(0x21)))))
39
+ if _URL.match(value) or _SPECIAL.match(value): # `https:user@host/x` is a URL too, as the URL standard reads it
40
+ shown = redact_url(value) # which drops the user information as written first (seam readings 10 and 11)
41
+ else:
42
+ cut = 0
43
+ if not _LOCAL_PATH.match(value):
44
+ ats = [i for i, c in enumerate(value) if c == "@"]
45
+ hosts = [at for at in ats if _SCP_HOST.match(value, at + 1)]
46
+ cut = (hosts or ats or [-1])[-1] + 1 # the last `@` a host and `:` follow, else the last `@` (unit reading 8)
47
+ shown = re.split(r"[?#]", value[cut:], maxsplit=1)[0]
48
+ pieces = [piece for written in (shown, ascii_decoded(shown)) for piece in re.split(r"[/\\:]", written)
49
+ if piece and not repository_name(piece)] # as written and decoded, `%73k-` being `sk-` (readings 11)
50
+ return "<a repository shaped like a credential>" if any(secret_argument(piece) for piece in pieces) else shown
21
51
  import fabric_actions as fa # noqa: E402 - beside this file (DEC-0121)
22
52
  import fabric_a2a as fa2 # noqa: E402 - beside this file (DEC-0122)
23
53
 
@@ -177,7 +207,7 @@ def board_responder_verdict(wk: Any, kit: Optional[str] = None) -> Tuple[str, st
177
207
  path = Path(kit) if kit else Path(__file__).resolve().parent / "fabric_board.py"
178
208
  code = ("import json, sys; sys.path.insert(0, sys.argv[1]); import %s as board; "
179
209
  "print(json.dumps(board.self_check()))" % path.stem)
180
- out = subprocess.run([sys.executable, "-E", "-s", "-c", code, str(path.resolve().parent)], capture_output=True,
210
+ out = subprocess.run([sys.executable, "-E", "-s", "-B", "-c", code, str(path.resolve().parent)], capture_output=True,
181
211
  text=True, timeout=60, cwd=str(path.resolve().parent))
182
212
  problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the kit did not run: %s" % out.stderr.strip()[-200:]]
183
213
  except Exception as exc: # noqa: BLE001 - a kit that cannot run its conversation fails the rule
@@ -658,5 +688,5 @@ def service_checkout(descriptor: Any, repo: Optional[str]) -> Tuple[Optional[Pat
658
688
  continue
659
689
  seen.add(top)
660
690
  if same_repository(_git_out(Path(top), "remote", "get-url", "origin") or "", source):
661
- return Path(top), "the checkout of %s that holds %s" % (source, place)
662
- return None, "no checkout of %s holds the descriptor's commands, manifest or launchd job; pass --repo" % source
691
+ return Path(top), "the checkout of %s that holds %s" % (shown_repository(source), place)
692
+ return None, "no checkout of %s holds the descriptor's commands, manifest or launchd job; pass --repo" % shown_repository(source)
@@ -7,7 +7,7 @@ import { PROTOCOL, ServiceError, nowIso } from './fabric-base.mjs';
7
7
  import { tokenMatches, windowsTokenProblem } from './fabric-platform.mjs';
8
8
  import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
9
9
  import { isPlainObject } from './fabric-usage.mjs';
10
- import { validateDescriptor, writeDescriptor } from './fabric-descriptor.mjs';
10
+ import { descriptorPath, namesRepository, sourceRefusal, validateDescriptor, writeDescriptor } from './fabric-descriptor.mjs';
11
11
 
12
12
  export const SESSION_COOKIE = 'fabric_session';
13
13
 
@@ -275,8 +275,17 @@ export function registerRemote({ id, instance = 'default', name, summary, origin
275
275
  installedAt: nowIso(), installedBy, ...(runner ? { runner: { ...runner } } : {}), ...(peer ? { peer: { ...peer } } : {}),
276
276
  ...(source != null ? { source: isPlainObject(source) ? { ...source } : source } : {}),
277
277
  };
278
+ if (source == null) { // a rotation that names no source keeps the installed one (0.62.0 seam reading 1)
279
+ let installed = null;
280
+ try { installed = JSON.parse(fs.readFileSync(descriptorPath(id, instance, dir), 'utf8'))?.source; } catch { /* none installed */ }
281
+ // Only one the kit accepts: a hand-edited source never blocks a rotation; it is dropped with the warning.
282
+ const base = new Set(validateDescriptor(d));
283
+ if (namesRepository({ source: installed }) && validateDescriptor({ ...d, source: installed }).every((p) => base.has(p))) d.source = { ...installed };
284
+ }
278
285
  const problems = validateDescriptor(d);
279
286
  if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
287
+ const refusal = sourceRefusal(d, dir); // before the token is written: a refused install leaves nothing (FAA-198)
288
+ if (refusal) throw new ServiceError(refusal);
280
289
  ensurePrivateDir(path.dirname(tokenFile));
281
290
  atomicWrite(tokenFile, String(token).trim(), 0o600);
282
291
  return writeDescriptor(d, dir);
@@ -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
+