@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.
- package/CHANGELOG.md +142 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +58 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +23 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +117 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +24 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +24 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +146 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +33 -25
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +19 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +23 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +17 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +86 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +116 -18
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +23 -23
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +16 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,147 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.61.0 (2026-11-04)
|
|
4
|
+
|
|
5
|
+
The board responder reads the operator's mandate from a request of his own Project (FAA-135), and working-with-fabric
|
|
6
|
+
says that his approval holds for the whole task, at every link (FAA-195).
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `Responder(..., agent_id=, mandate_from=)`: with both set, a request whose sender the board verified
|
|
11
|
+
(`provenance: trusted`) and whose Project `mandate_from` names (a uuid in any case; or every such sender,
|
|
12
|
+
`mandate_from="personal-board"`, only on the personal board's responder) is read for one `mandate/1` block fenced
|
|
13
|
+
` ```json `, each fence at the start of its own line with no indent, in its body (contract project-comms.md, DEC-0125 MD-3). The handler gets it as `ctx.mandate`, and
|
|
14
|
+
`ctx.mandate_covers(call)` answers `mandate_covers` with the sender placed as T1, the responder's wall clock (`wall=`,
|
|
15
|
+
`time.time` by default; Node `Date.now`, milliseconds) and the sender Project as the `caller` of a covered answer's
|
|
16
|
+
`recordAs`; None when no mandate travelled, and `{"fault": "now-unreadable"}` when the clock returns a value that is not a finite instant (one that raises passes its
|
|
17
|
+
error to the handler). A `source` in the block is dropped (OA-7). Two `mandate/1` blocks, or one whose text holds
|
|
18
|
+
`mandate/1` and is not JSON (an integer longer than 4300 digits included, on every Python), read as none, with a `board.request.mandate_unread` warning carrying the request id and its `reason`, never the block.
|
|
19
|
+
Off by default: a responder that names neither reads nothing, as before. Node's `WorkContext` serialises without the
|
|
20
|
+
mandate. Both kits (FAA-135). `ctx.mandate_covers` reads the call's `spendUsd` and `project` failing closed, as the
|
|
21
|
+
kit's MCP and job mandate readers do (FAA-139): a spend that is not a finite number is none, which a `perCallMaxUsd`
|
|
22
|
+
refuses, and a `project` that is not a string (in Python an exact `str`, so a subclass too: the core compares a
|
|
23
|
+
project by exact type, so every Python reader refuses one there; the board path marks it unreadable first), None (Node null) included, is refused by a mandate limited to
|
|
24
|
+
projects, as the contract's `mandateCovers` refuses a null project (the MCP and job readers read None as naming
|
|
25
|
+
none; leave `project` out on the board) (a draft of this release passed them as given; 0.61.0 seam reading 14). Only
|
|
26
|
+
the five members `mandate_covers` reads are read, each once, as the contract reads them (`call[name]` in Python; a
|
|
27
|
+
plain property read in Node, inherited, getter and Proxy members included), but for a Node spend, read as the call's
|
|
28
|
+
own member; the rest of a call is never touched (0.61.0 seam readings 16 and 17).
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- The Python mandate readers (McpToolServer and `action_gate`, the job step, and the core `mandate_covers` and
|
|
33
|
+
`action_decision` beneath them; a draft of this release's board path too) read a spend estimate that is an `int` or
|
|
34
|
+
`float` subclass through its own `float()` and comparisons, so a subclass whose `float()` answered 0.0 for 100, or
|
|
35
|
+
whose `>` lied about an integer past the double range, passed a `perCallMaxUsd` of 5. A number is now an `int` or
|
|
36
|
+
`float` by its real type (`fabric_jsvalue.is_number`, never `__class__`) and is read by the number it stores
|
|
37
|
+
(`real_number`: `float.__float__`, `int.__int__`), in `mandate_spend`, `js_to_number`, `double` and McpToolServer's
|
|
38
|
+
estimate: an honest subclass such as `numpy.float64` keeps its number, a lying one is read as what it holds. A value
|
|
39
|
+
that only claims a number through `__class__` (a mock with `spec=float`, a lazy proxy), a `Decimal` or a
|
|
40
|
+
`numpy.int64` is no estimate: the call is answered and a `perCallMaxUsd` refuses it, in every reader. The core
|
|
41
|
+
`mandate_covers` reads a spend only when it is a number or JSON data as `json.loads` gives it, by exact type all the
|
|
42
|
+
way down, so for its direct callers a tuple, bytes, a `str`, `list` or `dict` subclass, or a list holding a number
|
|
43
|
+
subclass is no estimate either, a stricter reading than 0.60.0's (checked only under a ceiling, without recursion in one
|
|
44
|
+
pass, then read as `Number()` reads it, in time linear in the structure (a list is never joined: two or more items are
|
|
45
|
+
NaN, one is read for itself); a shared or cyclic list or dict, which `json.loads` never gives, is no estimate, and a spend whose lists nest in
|
|
46
|
+
lists more than 100 deep (`MAX_SPEND_DEPTH`, a dict between them ends the count) is no estimate, so the spend a
|
|
47
|
+
refusal carries as `estimateUsd` is one a serialiser writes: that nesting is what `String()` of a list recurses
|
|
48
|
+
through, so 0.60.0 raised RecursionError a few hundred deep (about 500 on Python 3.14, 330 on 3.9) and Node's
|
|
49
|
+
`mandateCovers` and the contract throw RangeError a few thousand deep, and between 100 and those depths the core is
|
|
50
|
+
stricter than all three, a fail-closed twin split that no board, MCP or job reader reaches, since they take no list
|
|
51
|
+
or dict spend; under a dict there is no bound, since `Number()` never descends a dict, NaN at any depth in all of
|
|
52
|
+
them; a dict with an own `toString`, at the top or reached through lists alone, is no estimate too, where 0.60.0
|
|
53
|
+
read it as NaN and Node and the contract throw TypeError). A bool is none in `mandate_spend`, `action_decision` and McpToolServer as before, while
|
|
54
|
+
the core `mandate_covers` reads it as JavaScript's `Number()` does, as the contract's `mandateCovers` does (0.61.0
|
|
55
|
+
seam readings 17 to 22, 24 and 26 to 29, product readings 18 to 20 and 22 to 27, unit readings 19, 21, 24, 26 and 28 to 30). `js_to_number` tested a bool with `isinstance`, which asks
|
|
56
|
+
a number subclass's own `__class__`: one claiming `bool` was read as 1 under a ceiling, and one whose `__class__`
|
|
57
|
+
raised made the core raise (older than 0.61.0); it tests a bool by identity now (0.61.0 unit reading 30). A job's approval step kept a `str`-subclass project (a `(str, Enum)` member included) as given, which
|
|
58
|
+
the start refused by the core's exact-type compare but the job file then wrote as its plain string, so the answer
|
|
59
|
+
covered it, and the same held for a step a service keeps itself through `request_input(steps=)` or Node's
|
|
60
|
+
`requestInput({steps})` (a `String` object project, a `Number` object spend); both stores now keep a step as the gate
|
|
61
|
+
reads it (`kept_step` / `keptStep`: a spend through `mandate_spend`, a project as an exact `str` or a primitive
|
|
62
|
+
string, else `UNREADABLE_PROJECT`), and `job_step`, `action_gate` and McpToolServer read a project as an exact `str`
|
|
63
|
+
too, as the board path does, so the start and the answer agree, and a value that only claims `str` never reaches the
|
|
64
|
+
job file (older than 0.61.0; 0.61.0 seam reading 32, readings 33). Node's `JobStore.answer` matched an answer
|
|
65
|
+
keyed by an inherited name (`constructor`, `toString`) as an asked question and cleared the real one; it matches an
|
|
66
|
+
own key only, as Python does (older than 0.61.0; 0.61.0 seam reading 36). Both stores' `answer` match nothing when
|
|
67
|
+
the answer's `inputResponses` or the job's `inputRequests` is not a plain object (a hand-edited file, an array), and
|
|
68
|
+
McpToolServer answers a `fabric.job.get` whose `inputResponses` is present, not `null` (read as a poll, as before)
|
|
69
|
+
and not a JSON object with `-32602`:
|
|
70
|
+
0.60.0 raised out of `handle()` for a non-empty one on a job waiting `input_required` (the transport sent no reply)
|
|
71
|
+
and read an empty list or string, `0`, `false`, or any non-object on another job or an unknown id, as no answer or
|
|
72
|
+
`unknown-job` (a client that writes an empty map as `[]` now gets `-32602`; for an unknown id on a server without
|
|
73
|
+
`authorize`, `-32602` comes before `unknown-job`, where a server with `authorize` answers `unknown-job` first). Both stores' `request_input` /
|
|
74
|
+
`requestInput` refuse requests that are not a JSON object (before they read a step), and a request that is
|
|
75
|
+
not a keyed object (under a string key, a dict in Python, in Node an object that is not null, an array or a function), with `InteropError` (0.60.0 Python raised `AttributeError` or `TypeError`, by the shape and whether
|
|
76
|
+
`steps=` was passed; Node kept an array, which an answer keyed by an index or `length` then matched), and McpToolServer's answer gate reads only an object of requests (older
|
|
77
|
+
than 0.61.0; 0.61.0 seam and unit readings 37 and 38). The same split held for an
|
|
78
|
+
effect: Python admitted a `str`-subclass effect (`isinstance`) that the core then refused at the start and the job
|
|
79
|
+
file wrote as a plain string the answer covered; `job_step` (when a mandate travelled), `request_input(steps=)` and
|
|
80
|
+
`add_tool` (with an action) now admit an effect as an exact `str` (an `InteropError` otherwise, as Node's `requestInput` already refused a `String` object),
|
|
81
|
+
and both stores read each member of a step once and keep the value they checked (0.61.0 seam and unit readings 34). `js_to_number`'s decimal
|
|
82
|
+
pattern backtracked over a long digit string that fails to match, quadratic in its length; it now costs the length
|
|
83
|
+
(0.61.0 seam reading 22).
|
|
84
|
+
- `InstanceLock.acquire` (Python, `fabric_lock.py`): when writing the pid failed after the lock was taken (a full disk, an
|
|
85
|
+
I/O error, an interrupt), the descriptor stayed held, and the lock name too, since the caller drops the object
|
|
86
|
+
(`release()` would have freed only the name, on Linux and Windows), so the same process could not start the service
|
|
87
|
+
again. The lock now lets both go and re-raises the first error, even when the
|
|
88
|
+
close itself fails; the Node twin
|
|
89
|
+
(`fabric-lock.mjs`), whose caller drops a lock that failed to start, does the same for its descriptor or its name,
|
|
90
|
+
the first error standing in both.
|
|
91
|
+
Found by a service that vendors the kit, on the board.
|
|
92
|
+
|
|
93
|
+
### Changed
|
|
94
|
+
|
|
95
|
+
- working-with-fabric's mandate section: every hand-over within a task carries the four parts of the operator's
|
|
96
|
+
approval (`source` only where OA-7 lets it), and each receiver acts on them without asking him again, narrowing only
|
|
97
|
+
(human gates stay, MD-6); a relayed receiver's chain starts with it (OA-2), so the four-agent bound limits one
|
|
98
|
+
mandate, never the task; a mandate lapses within seven days of his words, and a task still open asks him once for a
|
|
99
|
+
new one, his same words with `at` when he confirms (MD-1), then weekly until the contract's CT-205 (its answer
|
|
100
|
+
`7f372925` on board thread `118f95f9`; CT-205 is a row the contract proposes after this kit's pin, on its branch
|
|
101
|
+
`agent/template-parity-20261103`); a link whose chain already holds four agents hands on no mandate and never restates its own: the four
|
|
102
|
+
parts go as a relay through OA-3 (his own Project on the board or his local session), whose receiver states its own (OA-2),
|
|
103
|
+
as the contract confirmed on board thread `118f95f9` (message `adf0c61a`); references/project-board.md and building-fabric-services
|
|
104
|
+
references/interop.md say the relay names the scope in force and the held `expiresAt`, which the receiver's mandate
|
|
105
|
+
does not pass (the kit's bound); and, as the contract proposes until CT-205,
|
|
106
|
+
no agent takes a relay for a task it already leads; references/interop.md says a service has no route past four agents until CT-205. `expiresAt` is counted from `at` and
|
|
107
|
+
covers the task's length (the kit's `operator_mandate` defaults to a day; references/interop.md says to pass it); the
|
|
108
|
+
source and spend sentences are reworded with no change in rule, and Step 5's lane-restart paragraph drops its reason
|
|
109
|
+
sentence ("The stop is usually stale by then.") for room, its rule unchanged; the paragraph for a client without `_meta`
|
|
110
|
+
leaves the mandate section for Step 5's routes (CT-125), and two sentences that restated rules kept elsewhere in the
|
|
111
|
+
section are dropped (FAA-196 (6)). A scenario eval `an-approval-holds-for-the-whole-task` (FAA-195).
|
|
112
|
+
- working-with-fabric references/project-board.md says what a kit service reads from a board request: one `mandate/1`
|
|
113
|
+
block fenced ` ```json ` at the start of its own lines, its chain ending in the service's registry agent id, never a
|
|
114
|
+
decision block; and what comes back: `mandate-not-for-this-call` is handed on again to the right id without asking
|
|
115
|
+
him, `mandate-expired` asks him once for a new one.
|
|
116
|
+
|
|
117
|
+
### Upgrading
|
|
118
|
+
|
|
119
|
+
- `fabric_board_link` (and `fabric-board-link.mjs`) now imports `fabric_interop_rules`, so a board responder's install
|
|
120
|
+
brings it and its `fabric_delegation` too (`fabric_kit.py install --module fabric_board` copies 33 modules, 31 in
|
|
121
|
+
0.60.0): reinstall with `fabric_kit.py install` and check with `status`, never by copying `fabric_board_link` alone.
|
|
122
|
+
- A Python service that passes a job or tool project as a `(str, Enum)` member or another `str` subclass, under a
|
|
123
|
+
mandate limited to projects, is now asked at a job's answer as at its start: pass the plain string (`member.value`).
|
|
124
|
+
An effect passed that way to `approval_step`, `job_step` (when the start call carried a mandate), `request_input(steps=)` or `add_tool` with an action now raises
|
|
125
|
+
`InteropError`: pass `member.value` there too. A Node service that keeps a step through `requestInput({steps})` with
|
|
126
|
+
a `String` object project (under a mandate limited to projects) or a `Number` object spend (under a `perCallMaxUsd`)
|
|
127
|
+
is likewise asked at the answer: pass the primitive value.
|
|
128
|
+
- A kept step holds only `effect`, `spendUsd` and `project`, read as the step's accessors answer them (Node property
|
|
129
|
+
access, Python `.get`), where 0.60.0 kept the step's JSON form (Node `JSON.stringify`, Python `json.dumps`, which reads a
|
|
130
|
+
`dict` subclass through its `items()`): a
|
|
131
|
+
service that put its own keys in `steps=` keeps them elsewhere. The two differ for a Node step that is a function
|
|
132
|
+
(0.60.0 kept no step), an array, an object whose members are inherited (a class's getters included) or not
|
|
133
|
+
enumerable, or one with its own `toJSON`, and for a Python `dict` subclass whose `.get` or `items()` answers otherwise
|
|
134
|
+
than its storage: such a step is now kept as its accessors answer, and an accessor that throws for the spend or the project,
|
|
135
|
+
which 0.60.0 never called, now throws from `requestInput` / `request_input` (the job stays `working`).
|
|
136
|
+
- A job waiting `input_required` whose approval step 0.60.0 kept keeps it as 0.60.0 wrote it: a `str`-subclass
|
|
137
|
+
project or effect, or a Node `String` or `Number` object, as its plain value, and the step kinds above in their JSON
|
|
138
|
+
form. A step so kept with its effect but no project is covered at a 0.61.0 answer by a mandate limited to projects;
|
|
139
|
+
one kept with no effect waits for the operator (`effect-missing` where a step is kept, `no-decision` where none is),
|
|
140
|
+
and a Node job whose requests 0.60.0 kept as an array can no longer be answered. Answer or cancel such jobs before
|
|
141
|
+
upgrading.
|
|
142
|
+
- Python McpToolServer answers a `fabric.job.get` whose `inputResponses` is not a JSON object (`[]` and `""`
|
|
143
|
+
included), on any job the caller may read, `-32602` (Node has no tool server): send an object, or leave it out (or `null`) to poll.
|
|
144
|
+
|
|
3
145
|
## 0.60.0 (2026-11-03)
|
|
4
146
|
|
|
5
147
|
The board responder is ready to find a slot the operator moves back to it, once Fabric offers the move (FAA-165), and four smaller fixes: working-with-fabric
|
package/README.md
CHANGED
|
@@ -72,7 +72,8 @@ requests other Projects post to its Project on Fabric's project board uses the b
|
|
|
72
72
|
(`scripts/fabric_hub.py` and `scripts/fabric_board.py`, Node `fabric-hub.mjs` and `fabric-board.mjs`): the
|
|
73
73
|
`board.respond` grant, enroll, claim, accept the exact digest, complete, a checkpoint before every step, and restarts
|
|
74
74
|
that reconcile or take accepted work back by a transfer, unless the slot was moved to another session (until the
|
|
75
|
-
operator's take-back, or a move back once Fabric offers one)
|
|
75
|
+
operator's take-back, or a move back once Fabric offers one), and, when the service opts in, the operator's
|
|
76
|
+
`mandate/1` from a trusted request of his own Project (the [board reference](plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md)):
|
|
76
77
|
|
|
77
78
|
```bash
|
|
78
79
|
python3 plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py example-agent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "fabric-agent-adapter",
|
|
4
4
|
"displayName": "Fabric Agent Adapter",
|
|
5
5
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.61.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.61.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "3830a3353923e20f826d466610e1c479f120bec0"
|
|
11
11
|
---
|
|
@@ -14,7 +14,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
14
14
|
compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
15
15
|
metadata:
|
|
16
16
|
author: PassionCode.ai
|
|
17
|
-
version: "0.
|
|
17
|
+
version: "0.61.0"
|
|
18
18
|
contract-version: "0.1.0"
|
|
19
19
|
extension: "fabric-service/0.1"
|
|
20
20
|
extension-commit: "3830a3353923e20f826d466610e1c479f120bec0"
|
|
@@ -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.
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
354
|
-
`shutdown(drain)`, `take_back(capability)`; `WorkContext.progress(note)`,
|
|
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)
|
|
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.
|
|
@@ -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
|
|
270
|
-
|
|
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=)`)
|
|
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})
|
|
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
|
|
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
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md
CHANGED
|
@@ -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 |
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs
CHANGED
|
@@ -6,6 +6,9 @@ import path from 'node:path';
|
|
|
6
6
|
import { HubError, MCP_PROTOCOL_VERSION, postMcp, toolAnswer } from './fabric-hub.mjs';
|
|
7
7
|
import { atomicWrite, ensurePrivateDir } from './fabric-service.mjs';
|
|
8
8
|
import { BoardError, RESPOND_TOOLS, isObj, refusalProblems } from './fabric-board-shapes.mjs';
|
|
9
|
+
import { UNREADABLE_PROJECT, mandateSpend } from './fabric-interop-rules.mjs'; // FAA-139
|
|
10
|
+
import { isAgentId, mandateCovers } from './fabric-mandate.mjs';
|
|
11
|
+
import { formatRfc3339 } from './fabric-time.mjs';
|
|
9
12
|
|
|
10
13
|
/** MCP over HTTP with a board session's one-session bearer; calls are serialised; close() sends the session's DELETE. */
|
|
11
14
|
export class BoardSession {
|
|
@@ -179,4 +182,118 @@ export class BoardRequest {
|
|
|
179
182
|
toJSON() {
|
|
180
183
|
return { requestId: this.requestId, capability: this.capability }; // never the body
|
|
181
184
|
}
|
|
185
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
186
|
+
return this.toJSON(); // console.log(request) prints no body either
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// -- the operator's mandate in a board request (FAA-135; contract project-comms.md, DEC-0125 MD-3) ---------------------
|
|
191
|
+
|
|
192
|
+
/** `mandateFrom` naming every trusted sender of the board the service is installed on: the operator's personal board. */
|
|
193
|
+
export const PERSONAL_BOARD = 'personal-board';
|
|
194
|
+
const OPENER = /^```json[ \t]*$/;
|
|
195
|
+
const CLOSER = /^```[ \t]*$/;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The bodies of the ```json blocks, each fence on its own line: one pass over the lines, linear in the body. Lines end
|
|
199
|
+
* at "\\n" alone, one "\\r" before it dropped, as in the Python twin (never U+2028 or a lone "\\r"); an unclosed
|
|
200
|
+
* opener ends the scan.
|
|
201
|
+
*/
|
|
202
|
+
function jsonBlocks(body) {
|
|
203
|
+
const lines = body.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
|
|
204
|
+
const blocks = [];
|
|
205
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
206
|
+
if (!OPENER.test(lines[i])) continue;
|
|
207
|
+
let j = i + 1;
|
|
208
|
+
while (j < lines.length && !CLOSER.test(lines[j])) j += 1;
|
|
209
|
+
if (j === lines.length) break;
|
|
210
|
+
blocks.push(lines.slice(i + 1, j).join('\n'));
|
|
211
|
+
i = j;
|
|
212
|
+
}
|
|
213
|
+
return blocks;
|
|
214
|
+
}
|
|
215
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
216
|
+
/** A Project id as Fabric's board compares it: a uuid lower-cased, any other id as written (normalProject). */
|
|
217
|
+
const fold = (project) => (UUID.test(project) ? project.toLowerCase() : project);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The mandate a request carries, read only when the service opted in (`agentId` and `mandateFrom` set), the board
|
|
221
|
+
* verified the sender (`provenance: trusted`) and its Project is one `mandateFrom` names (or `PERSONAL_BOARD`): a T1
|
|
222
|
+
* caller by project-comms. Answers `{ mandate, unread }`: `mandate` null when none is read, `unread` `mandate-blocks`
|
|
223
|
+
* (more than one) or `mandate-unreadable` (a block naming `mandate/1` that is not JSON). A `source` in the block is
|
|
224
|
+
* dropped: a board block carries none (OA-7).
|
|
225
|
+
*/
|
|
226
|
+
export function readMandate(request, mandateFrom, agentId) {
|
|
227
|
+
const none = { mandate: null, unread: null };
|
|
228
|
+
if (agentId == null || mandateFrom == null || typeof request.body !== 'string') return none;
|
|
229
|
+
const sender = isObj(request.sender) ? request.sender : {};
|
|
230
|
+
const principal = isObj(sender.principal) ? sender.principal : {};
|
|
231
|
+
if (principal.provenance !== 'trusted') return none;
|
|
232
|
+
if (typeof sender.project !== 'string' || !sender.project) return none; // comms-message requires it; the caller
|
|
233
|
+
if (mandateFrom !== PERSONAL_BOARD && !mandateFrom.has(fold(sender.project))) return none;
|
|
234
|
+
const found = [];
|
|
235
|
+
for (const block of jsonBlocks(request.body)) {
|
|
236
|
+
let value;
|
|
237
|
+
try {
|
|
238
|
+
value = JSON.parse(block);
|
|
239
|
+
} catch {
|
|
240
|
+
if (block.includes('mandate/1')) return { mandate: null, unread: 'mandate-unreadable' };
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (isObj(value) && value.kind === 'mandate/1') {
|
|
244
|
+
delete value.source; // a Project declares no protocols: a board block carries no source (OA-7)
|
|
245
|
+
found.push(value);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (found.length > 1) return { mandate: null, unread: 'mandate-blocks' };
|
|
249
|
+
return found.length ? { mandate: found[0], unread: null } : none;
|
|
182
250
|
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `mandateCovers` for a call under a board request's mandate, its sender placed as T1 and no caller agent id (the
|
|
254
|
+
* chain's end alone binds it, MD-3). A covered answer's `recordAs` names `caller`, the sender Project the board
|
|
255
|
+
* verified (MD-3 records the caller). `wall` answers milliseconds. Null when no mandate travelled. `spendUsd` and
|
|
256
|
+
* `project` are read failing closed, as the kit's other mandate readers read them (FAA-139): a spend that is not a
|
|
257
|
+
* finite number is none (a ceiling refuses it), a `project` that is not a string, null included (the contract's
|
|
258
|
+
* mandateCovers refuses a null project), unreadable; a call that names none leaves `project` out. Only the five
|
|
259
|
+
* members mandateCovers reads are read, each once: a spend as an own member (an inherited one is none, which a ceiling
|
|
260
|
+
* refuses), a capability, action, effect and project as the contract reads them, a plain [[Get]] whose undefined is
|
|
261
|
+
* absent (an inherited, non-enumerable, getter or Proxy member included); the rest of the call is never touched. A
|
|
262
|
+
* getter whose value changes is answered on its one read.
|
|
263
|
+
*/
|
|
264
|
+
export function covers(mandate, call, agentId, wall, caller = null) {
|
|
265
|
+
if (mandate == null || agentId == null) return null;
|
|
266
|
+
if (call !== null && typeof call === 'object' && !Array.isArray(call)) {
|
|
267
|
+
const read = {};
|
|
268
|
+
for (const key of ['capability', 'action', 'effect']) {
|
|
269
|
+
const value = call[key]; // the contract's read: whatever [[Get]] answers, undefined being absent
|
|
270
|
+
if (value !== undefined) read[key] = value;
|
|
271
|
+
}
|
|
272
|
+
const spend = mandateSpend(Object.hasOwn(call, 'spendUsd') ? call.spendUsd : undefined);
|
|
273
|
+
if (spend !== undefined) read.spendUsd = spend;
|
|
274
|
+
const project = call.project;
|
|
275
|
+
if (project !== undefined) read.project = typeof project === 'string' ? project : UNREADABLE_PROJECT;
|
|
276
|
+
call = read;
|
|
277
|
+
}
|
|
278
|
+
const clock = wall();
|
|
279
|
+
let now = '';
|
|
280
|
+
try {
|
|
281
|
+
if (typeof clock === 'number' && Number.isFinite(clock)) now = formatRfc3339(clock);
|
|
282
|
+
} catch { // finite but outside the dates a clock can name
|
|
283
|
+
now = '';
|
|
284
|
+
}
|
|
285
|
+
const answer = mandateCovers({ mandate, tier: 'T1', now, self: agentId, call });
|
|
286
|
+
return answer.recordAs ? { ok: true, recordAs: { ...answer.recordAs, caller } } : answer;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The responder's opt-in, checked: answers `mandateFrom` as the responder keeps it (a Set or the string). */
|
|
290
|
+
export function mandateOptions(agentId, mandateFrom) {
|
|
291
|
+
if (agentId != null && !isAgentId(agentId)) throw new BoardError('invalid_arguments', 'agentId must be the service\'s registry agent id.');
|
|
292
|
+
if (mandateFrom == null || mandateFrom === PERSONAL_BOARD) return mandateFrom ?? null;
|
|
293
|
+
const projects = typeof mandateFrom === 'string' || mandateFrom instanceof String || typeof mandateFrom?.[Symbol.iterator] !== 'function' ? null : [...mandateFrom];
|
|
294
|
+
if (projects === null || !projects.every((p) => typeof p === 'string' && p)) {
|
|
295
|
+
throw new BoardError('invalid_arguments', `mandateFrom is '${PERSONAL_BOARD}' or a collection of Project ids.`);
|
|
296
|
+
}
|
|
297
|
+
return new Set(projects.map(fold));
|
|
298
|
+
}
|
|
299
|
+
|
|
@@ -5,10 +5,10 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { Backoff, HubError, normalProject } from './fabric-hub.mjs';
|
|
6
6
|
import { acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
|
|
7
7
|
import { BoardError, CAPABILITY, LEASE_SECONDS, REQUEST_STATES, SUMMARY_MAX, Stopped, isInt, isObj, outcomeProblems, receiptProblems } from './fabric-board-shapes.mjs';
|
|
8
|
-
import { BoardRequest, BoardSession, Checkpoint } from './fabric-board-link.mjs';
|
|
8
|
+
import { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD, covers, mandateOptions, readMandate } from './fabric-board-link.mjs';
|
|
9
9
|
import { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES, SlotKeeper } from './fabric-board-slots.mjs';
|
|
10
10
|
|
|
11
|
-
export { BoardRequest, BoardSession, Checkpoint }; // re-exported: callers import them from here since 0.40.0
|
|
11
|
+
export { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD }; // re-exported: callers import them from here since 0.40.0
|
|
12
12
|
export { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES }; // since 0.56.0 defined in fabric-board-slots.mjs
|
|
13
13
|
|
|
14
14
|
export const RENEW_SECONDS = 20;
|
|
@@ -34,10 +34,23 @@ const NOT_OURS = new Set(['queued', 'claimed', 'answered']);
|
|
|
34
34
|
export class WorkContext {
|
|
35
35
|
#responder;
|
|
36
36
|
#entry;
|
|
37
|
-
|
|
37
|
+
#caller;
|
|
38
|
+
constructor(responder, entry, mandate = null, caller = null) {
|
|
38
39
|
this.#responder = responder;
|
|
39
40
|
this.#entry = entry;
|
|
41
|
+
this.#caller = caller;
|
|
40
42
|
this.stopped = false;
|
|
43
|
+
this.mandate = mandate; // the operator's mandate the request carried, when the responder reads one (FAA-135)
|
|
44
|
+
}
|
|
45
|
+
/** Whether the request's mandate covers `call` (`mandateCovers`' answer); null when none travelled. */
|
|
46
|
+
mandateCovers(call) {
|
|
47
|
+
return covers(this.mandate, call, this.#responder.agentId, this.#responder.wall, this.#caller);
|
|
48
|
+
}
|
|
49
|
+
toJSON() {
|
|
50
|
+
return { stopped: this.stopped }; // never the mandate: it holds the operator's words
|
|
51
|
+
}
|
|
52
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
53
|
+
return this.toJSON(); // console.log(ctx) prints no mandate either
|
|
41
54
|
}
|
|
42
55
|
async progress(note) {
|
|
43
56
|
if (this.stopped) throw new Stopped();
|
|
@@ -69,13 +82,15 @@ function noticeSubject(value, project, episode) {
|
|
|
69
82
|
*/
|
|
70
83
|
export class Responder extends SlotKeeper {
|
|
71
84
|
constructor({ hub, projectId, capabilities, credentialFile, checkpointFile, handler, admit, observe, emit, service = 'service',
|
|
72
|
-
now = () => performance.now() / 1000, rand = Math.random, sessionFactory } = {}) {
|
|
85
|
+
now = () => performance.now() / 1000, rand = Math.random, sessionFactory, agentId = null, mandateFrom = null, wall = Date.now } = {}) {
|
|
73
86
|
super();
|
|
74
87
|
if (typeof admit !== 'function' || typeof handler !== 'function') throw new BoardError('invalid_arguments', 'A responder needs the service\'s own input gate (admit) and a handler.');
|
|
75
88
|
const caps = [...(capabilities ?? [])];
|
|
76
89
|
if (!caps.length || caps.some((c) => typeof c !== 'string' || !CAPABILITY.test(c)) || new Set(caps).size !== caps.length) throw new BoardError('invalid_arguments', 'Name each capability once, [a-z][a-z0-9._-]{1,127}.');
|
|
77
90
|
Object.assign(this, { hub, capabilities: caps, credentialFile, handler, admit, observe, emitTo: emit, now, rand });
|
|
78
91
|
this.project = normalProject(projectId);
|
|
92
|
+
// FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; null reads none)
|
|
93
|
+
Object.assign(this, { mandateFrom: mandateOptions(agentId, mandateFrom), agentId: agentId ?? null, wall });
|
|
79
94
|
this.service = String(service).replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 40) || 'service';
|
|
80
95
|
this.checkpoint = new Checkpoint(checkpointFile);
|
|
81
96
|
this.sessionFactory = sessionFactory ?? ((grant) => new BoardSession(grant));
|
|
@@ -679,7 +694,11 @@ export class Responder extends SlotKeeper {
|
|
|
679
694
|
}
|
|
680
695
|
|
|
681
696
|
async runWork(entry, request) {
|
|
682
|
-
const
|
|
697
|
+
const { mandate, unread } = readMandate(request, this.mandateFrom, this.agentId);
|
|
698
|
+
if (unread) { // by its code alone: the block holds the operator's words
|
|
699
|
+
this.emit('board.request.mandate_unread', 'warning', `Request ${entry.request} carries a mandate the kit does not read (${unread}); it is worked without one.`, { request: entry.request, reason: unread });
|
|
700
|
+
}
|
|
701
|
+
const ctx = new WorkContext(this, entry, mandate, isObj(request.sender) ? request.sender.project ?? null : null);
|
|
683
702
|
const work = { entry, request, ctx, done: false, outcome: null, failure: null, renewedAt: this.now() };
|
|
684
703
|
this.current = work;
|
|
685
704
|
work.promise = Promise.resolve().then(() => this.handler(request, ctx)).then((outcome) => { work.outcome = outcome; }, (e) => { work.failure = e; }).finally(() => { work.done = true; });
|
|
@@ -282,6 +282,16 @@ export function mandateSpend(value) {
|
|
|
282
282
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/** A job's approval step {effect, spendUsd?, project?} as a job file keeps it: read as the gate reads it, so a job's
|
|
286
|
+
* start and its answer agree (0.61.0 seam reading 32, readings 33 and 34): a spend through mandateSpend (dropped when none), a project
|
|
287
|
+
* as a primitive string, else UNREADABLE_PROJECT (dropped when null or undefined). */
|
|
288
|
+
export function keptStep(step) {
|
|
289
|
+
const { effect, spendUsd, project } = step; // each member read once (seam reading 34)
|
|
290
|
+
const spend = mandateSpend(spendUsd);
|
|
291
|
+
return { effect, ...(spend !== undefined ? { spendUsd: spend } : {}),
|
|
292
|
+
...(project !== undefined && project !== null ? { project: typeof project === 'string' ? project : UNREADABLE_PROJECT } : {}) };
|
|
293
|
+
}
|
|
294
|
+
|
|
285
295
|
export function actionGate({ action, tier, caller, meta, target, elicited = false, approval, actionDigest, humanStepUrl, now, self, effect, callerAgent, spendUsd, project, mandateCapability } = {}) {
|
|
286
296
|
const inputs = { action, tier, caller, elicited: elicited === true, now };
|
|
287
297
|
if (isObject(meta) && Object.hasOwn(meta, 'decision')) inputs.decision = meta.decision;
|