2bb6faa121
Withdrawn: the offline_access mandate built an hour ago. An agent running on its own account acts for itself. Nobody asked for the 06:00 run, so naming a person as its principal was a fiction — and choosing which person was arbitrary, as the first implementation showed by picking whichever test user was to hand. It also cost more than it was worth. An offline token has no exp, dies only after 30 days unused, is not rotated on use, and is revocable only by killing that user's offline session: a permanent bearer credential standing in for a consent nobody gave. Now client_credentials, with the service account carrying read_only — the least privilege the job needs. That is not a convention the workflow observes: the MCP server maps the role to a consumer group at discovery, so tools/list under this identity returns get-product and list-products and nothing else. create-product is invisible rather than merely unused. Nothing downstream changes. azp still names the agent, audiences are still per-server, and policy still reads roles — a service account has roles like anyone else. The mandate client stays in the realm, unused, for the case where a person's own authority genuinely is in play: Recouvrement's dunning, which is human-validated already. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
5.9 KiB
Markdown
150 lines
5.9 KiB
Markdown
# Workflows
|
|
|
|
Exported as JSON and committed. The n8n instance is not the source of truth.
|
|
|
|
## Deploying a change
|
|
|
|
**n8n does not re-register an active workflow when you PATCH it.** The running
|
|
webhook keeps executing the definition it was activated with, so an edit appears
|
|
to have no effect — and the failure it keeps producing is the *old* one, which
|
|
is a very good way to spend an afternoon debugging something already fixed.
|
|
|
|
Deactivate, apply, reactivate, in that order:
|
|
|
|
```
|
|
POST /rest/workflows/<id>/deactivate {"versionId": "<current>"}
|
|
PATCH /rest/workflows/<id> {"nodes":…, "connections":…, "versionId": "<current>"}
|
|
POST /rest/workflows/<id>/activate {"versionId": "<current>"}
|
|
```
|
|
|
|
`versionId` changes at every step, so re-read it each time. And note
|
|
`PATCH {"active": true}` returns 200 and leaves the workflow inactive, silently:
|
|
activation has its own endpoint.
|
|
|
|
## approvisionnement-check-availability
|
|
|
|
Working. `POST /webhook/check-availability` with the end user's token in
|
|
`X-Subject-Token`:
|
|
|
|
```
|
|
"Peut-on promettre S10_1949 ?"
|
|
→ {"answer":"Yes — 1952 Alpine Renault 1300 (S10_1949) can be promised:
|
|
7305 in stock, above the reorder threshold of 50.","escalate":false}
|
|
|
|
"Peut-on promettre la Vespa ?"
|
|
→ {"answer":"No product reference in the question…","escalate":true}
|
|
```
|
|
|
|
It exchanges the token itself, using the agent's client secret from the pod
|
|
environment so the committed JSON holds no secret, then talks MCP in two calls:
|
|
`initialize` to obtain the session id from a **response header**, then
|
|
`tools/call`. No `notifications/initialized` is needed.
|
|
|
|
MCP rather than the REST route, which would have been one call instead of two:
|
|
the per-tool ACL by consumer group lives on the MCP route only. Going through
|
|
REST would give an autonomous agent broader access than a conversational one,
|
|
which is exactly backwards.
|
|
|
|
The product code is read with a regex, not a model. This agent's critical path
|
|
is rules; a question naming no product escalates rather than being guessed at.
|
|
The regex is loose after the underscore on purpose — a reference that looks
|
|
valid but does not exist must reach the catalog and come back as "no such
|
|
product", because answering "you named no product" would be false.
|
|
|
|
### The one that took longest
|
|
|
|
A product code that does not exist returned an **empty body**, and the reason is
|
|
worth keeping.
|
|
|
|
MCP does not report a tool failure as a JSON-RPC `error`. It reports it as a
|
|
perfectly successful **result** carrying `isError: true`, with the reason as
|
|
text:
|
|
|
|
```
|
|
data: {"jsonrpc":"2.0","result":{"content":[{"type":"text",
|
|
"text":"HTTP call failed with status 404: {\"detail\":\"Not found.\"}"}],
|
|
"isError":true},"id":2}
|
|
```
|
|
|
|
A client that only checks `rpc.error` therefore reads a failure as data. This
|
|
one went on to `JSON.parse` the sentence "HTTP call failed with status 404" as
|
|
if it were a product, threw, and the webhook answered nothing at all.
|
|
|
|
Two lessons, both now in the node:
|
|
|
|
- **check `result.isError`, not just `error`** — they mean different things: a
|
|
protocol failure versus a tool that ran and failed;
|
|
- **guard every `JSON.parse` of a payload you did not construct.** The throw is
|
|
what turned a wrong answer into no answer, which for an autonomous agent is
|
|
the worse of the two.
|
|
|
|
Now:
|
|
|
|
```
|
|
"Peut-on promettre S99_NOPE ?"
|
|
→ {"answer":"There is no product S99_NOPE in the catalog.","escalate":true}
|
|
```
|
|
|
|
and through the A2A facade, `escalate: true` becomes a task in state **failed**
|
|
rather than a completed one — the honest shape for "a human has to look at
|
|
this".
|
|
|
|
## approvisionnement-periodic-reorder
|
|
|
|
The periodic half. A schedule trigger at 06:00, deliberately periodic rather
|
|
than a batch standing in for events: grouping references into a purchase
|
|
proposal follows a buying cycle, while *detecting* a coming stock-out is
|
|
reactive and waits for Kafka.
|
|
|
|
Verified end to end on the live catalog:
|
|
|
|
```
|
|
scanned 110 of 110, complete
|
|
1 reference below the threshold of 50:
|
|
S24_2000 1960 BSA Gold Star DBD34 (Highway 66 Mini Classics)
|
|
on hand 15 → propose ordering 135
|
|
escalate: true
|
|
```
|
|
|
|
### It acts under its own machine identity
|
|
|
|
`client_credentials`, not a mandate. An agent running on its own account acts
|
|
for itself — nobody asked for the 06:00 run, so naming a person as its principal
|
|
is a fiction and choosing which person is arbitrary.
|
|
|
|
The service account carries **`read_only`**, and the MCP server maps that to a
|
|
consumer group at discovery, so `create-product` is not merely unused — it is
|
|
invisible to this identity:
|
|
|
|
```
|
|
tools/list under the machine identity → get-product, list-products
|
|
```
|
|
|
|
Least privilege enforced by the realm and the gateway rather than by this
|
|
workflow behaving itself. An earlier revision used an `offline_access` mandate;
|
|
see CLAUDE.md for why it was withdrawn.
|
|
|
|
### The proposal has nowhere to go
|
|
|
|
It is computed and escalated, never written, because there is nothing to write
|
|
it to: the `classicmodels` schema has no purchase order and no supplier entity —
|
|
`productvendor` is a string on the product — and the catalog's only write tool
|
|
is `create-product`, which is the Catalogue agent's business.
|
|
|
|
So `catalog:write` was **not** granted to this agent, contrary to the design's
|
|
scope list. A scope with no reachable target is an authorisation waiting for a
|
|
use, which is the wrong order.
|
|
|
|
Closing this needs a `reorders` entity in the domain, or a channel to a human —
|
|
there is no mail server in the cluster either. Same shape as the design's own
|
|
note that a Livraison agent needs a `shipments` domain first.
|
|
|
|
### It scans one page, on purpose
|
|
|
|
200 covers the 110 references, and the node reports `complete: false` and
|
|
escalates if it ever does not. A proposal set computed from part of the catalog
|
|
looks exactly like one computed from all of it, so the difference has to be in
|
|
the output. A catalog that outgrows one page needs a paging loop **and** the
|
|
read-volume cap this repo already lists as mandatory for autonomous agents.
|
|
|