Files
classic-models-agent-flows/workflows/README.md
T
Stéphane Tailland 245415e7c6 Let check_availability use what the reactive half saw
The watchlist is read before answering, so a crossing that happened twenty
minutes ago is part of the answer.

It also fixes a contradiction the agent was publishing: the catalog domain
caches reads for 60 seconds, so an agent woken by an event reads a figure taken
up to a minute earlier — it answered "68 in stock, above the threshold" and
"it crossed the threshold, from 68" in the same sentence. A movement is the
catalog's own dated statement; a read is an undated copy. The dated one wins,
and the answer says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 08:18:59 +02:00

268 lines
11 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".
### It now knows what the reactive half saw
`check-availability` reads the watchlist before answering, so a crossing that
happened twenty minutes ago is part of the answer:
```
"Peut-on promettre S12_1099 ?"
→ "No — 1968 Ford Mustang (S12_1099) is down to 8 … It crossed the threshold
at 06:18:24 (driven by orders), from 68."
```
"It fell below this afternoon, under demand" is a different decision from "it
has been sitting below for a month", and only the event carries that.
The watchlist is read over an in-cluster call to the stock-watch workflow's own
webhook — its data lives in that workflow and there is no other way in. The node
is `onError: continueRegularOutput`: an availability answer must not depend on
the watchlist being up.
### A dated fact beats an undated read
The two figures can disagree, and when they do the **read** is the wrong one.
This was not theoretical — the agent answered, in one sentence:
```
"68 in stock, above the reorder threshold of 50 … It crossed the threshold, from 68."
```
The catalog domain caches reads for **60 seconds**. An agent woken by an event
reacts in seconds and then reads a figure taken up to a minute earlier. A
movement is the catalog's own *dated* statement that stock went from X to Y; a
read is an undated copy. So the movement wins, and the answer says the basis
changed:
```
Basis: the latest stock movement, which is more recent than the catalog read
```
Measured after the fix, with the cache deliberately warmed at 38 and the true
stock moved to 8 inside the window: the answer said **8**.
This only helps a reference the reactive half is already watching. For any
other, the read stands with whatever staleness it has, and nothing in the
workflow can tell — which is worth remembering before treating any single read
as current.
## 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`.