06d0ab5e2d
The reactive half of the agent. It consumes product.stock_changed from the catalog topic and reports only a crossing of the threshold — every further order line on an already-low reference would otherwise raise the same alarm, and an agent that repeats itself is one a human learns to ignore. A stock-out is treated as its own fact even when the reference was already low. The detection lands on a watchlist readable over a webhook, because a proposal that reached only an execution log would be lost between two nightly runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
9.3 KiB
Markdown
222 lines
9.3 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.
|
|
|
|
|
|
## approvisionnement-stock-watch
|
|
|
|
The reactive half, and the one the design had been waiting on. It is woken by
|
|
`product.stock_changed` on `classic-models.catalog` rather than by a clock, so a
|
|
reference that runs low at 14:32 is noticed at 14:32 instead of being reported
|
|
at 06:00 the next morning.
|
|
|
|
Verified end to end against the live catalog:
|
|
|
|
```
|
|
S12_1099 68 → 38 detected: "fell from 68 to 38, below the threshold of 50"
|
|
38 → 30 not detected — already below, figures refreshed instead
|
|
30 → 0 detected: "is out of stock. Nothing can be promised."
|
|
0 → 68 not detected, entry refreshed (a correction, not demand)
|
|
POST {"clear":["S12_1099"]} → watching: 0
|
|
```
|
|
|
|
### Only a crossing is news
|
|
|
|
`before >= threshold && after < threshold`. Without that test, every further
|
|
order line on an already-low reference raises the same alarm again, and an agent
|
|
that repeats itself is one a human learns to ignore.
|
|
|
|
A stock-out is treated as its own fact even when the reference was already below
|
|
the threshold: *low* and *nothing left to promise* are different problems.
|
|
|
|
A movement that changes nothing newsworthy still **refreshes** an entry already
|
|
being watched. Otherwise the watchlist would show the figure from the moment of
|
|
detection, which is the number least likely to still be true.
|
|
|
|
`reason` carries `order:<n>` for demand and `manual-adjustment` for someone
|
|
correcting the books. Both can justify a purchase order; only demand says
|
|
anything about the future, so the distinction is recorded rather than filtered
|
|
on.
|
|
|
|
### The detection has somewhere to go
|
|
|
|
`$getWorkflowStaticData('global')`, keyed by reference, readable over
|
|
`GET /webhook/stock-watchlist` and cleared with
|
|
`POST {"clear": ["S24_2000"]}`. The purchase *proposal* still has nowhere to be
|
|
written — the domain has no purchase order and no supplier entity — but a
|
|
detection that only reached an execution log would be lost between two nightly
|
|
runs, which would make the reactive half pointless.
|
|
|
|
The obvious next consumer is `check-availability`: "can we promise X?" is a
|
|
better answer when it knows X crossed the threshold at 14:32.
|
|
|
|
### Kafka details worth keeping
|
|
|
|
- **The offset moves only on success** (`resolveOffset: onCompletion`,
|
|
`allowedStatuses: ["success"]`). A failed execution replays its event rather
|
|
than losing it — for an autonomous agent a detection dropped in silence is
|
|
worse than one made twice.
|
|
- **`fromBeginning: false`.** Otherwise every restart replays the whole
|
|
retention window and re-detects crossings dealt with days ago.
|
|
- Most messages on the topic are not `stock_changed`, and the workflow says so
|
|
explicitly and succeeds. A workflow that threw on an uninteresting event would
|
|
replay it forever.
|
|
- The consumer has its **own** SCRAM account,
|
|
`classic-models-agent-approvisionnement`: Read on that one topic and that one
|
|
consumer-group prefix. It cannot read sales or payments, and it cannot write —
|
|
so a compromised workflow can raise no event another agent would act on.
|
|
|
|
### Two traps
|
|
|
|
- **n8n's Kafka credential has a `ca` field.** The cluster CA goes there, so
|
|
there was no need to touch `NODE_EXTRA_CA_CERTS` or to disable verification.
|
|
Not obvious from the node's UI, which shows it only once `ssl` is on.
|
|
- **A webhook declaring several methods has one output per method**, in the
|
|
order declared. Wiring only the first silently drops every POST: the execution
|
|
reports **success** and n8n answers `No item to return was found`.
|