1ec9e308ba
The service's own supplier, simulated: a fixed lead time or a 15% chance of a stockout is the whole model, on purpose — this simulates that fulfilment can fail, not why a given supplier would. One service, two capacities: this workflow both raises reorder.requested (the periodic run) and answers its own request (this workflow), under the same Kafka account and Keycloak client, per the 2026-08-31 decision that réapprovisionnement stays one service. The delivery write goes through the front door — patch-product, under a new restocker role that may PATCH a product and nothing else — so the resulting product.stock_changed event is the only stock-update fact. No second, n8n-emitted event claims a delivery happened with nothing to certify it. Shipped with, and fixed, a bug where the delivery-application node spread the wrong upstream , silently dropping reorder_id/productcode/quantity two nodes before the failure surfaced. Verified separately: the write+event chain (a real bug, now fixed) and the state machine (persists correctly on a real trigger; a manual test run does not persist staticData, which is a testing limitation, not a production one). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
402 lines
18 KiB
Markdown
402 lines
18 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.
|
|
|
|
## supply-procurement
|
|
|
|
The service's own supplier, simulated. Reacts to `reorder.requested` (emitted
|
|
by the periodic run below, once it finds a reference under threshold),
|
|
decides — a fixed lead time, or a 15% chance the supplier cannot fulfil —
|
|
answers with `reorder.confirmed`/`reorder.rejected`, and on the confirmed
|
|
delivery date writes the stock itself.
|
|
|
|
```
|
|
reorder.requested {productcode, quantity, requested_at}
|
|
→ reorder.confirmed {deliver_on} — 85% of the time
|
|
→ reorder.rejected {reason: "supplier stockout"} — 15%
|
|
```
|
|
|
|
No purchase-order entity, no supplier entity, no negotiation — the whole model
|
|
is one probability and one lead time, on purpose: this simulates *that*
|
|
fulfilment can fail, not *why* a given supplier would.
|
|
|
|
### One service, two capacities — not two identities
|
|
|
|
This is still the supply agent, not a separate "procurement" component. It
|
|
both requests (the periodic run) and answers its own request (this workflow),
|
|
under the same Kafka account and the same Keycloak client — the 2026-08-31
|
|
decision that réapprovisionnement is one service, not a split into a demand
|
|
side and a supply side.
|
|
|
|
### The write goes through the front door
|
|
|
|
On the delivery date, this workflow reads the product's current stock and
|
|
PATCHes it — through Kong, through the same `patch-product` MCP tool a human
|
|
product manager would use, under a **new, narrow role**: `restocker` may PATCH
|
|
a product and nothing else, not create, not replace, not delete. It sends
|
|
`X-Stock-Change-Reason: delivery:<reorder-id>`, which `classic-models-api`
|
|
only honours in that exact shape; anything else falls back to
|
|
`manual-adjustment`. The resulting `product.stock_changed` event is the *only*
|
|
stock-update event — there is deliberately no second, n8n-emitted fact
|
|
claiming a delivery happened, because nothing would certify it against the
|
|
one that actually changed the database. See `classic-models-api`'s commit
|
|
"Accept a delivery reason on a stock PATCH" for the backend side.
|
|
|
|
### Two triggers, one shared state
|
|
|
|
`$getWorkflowStaticData('global').pendingDeliveries`, keyed by reorder id —
|
|
the same pattern the stock-watch workflow already uses for its watchlist, now
|
|
shared across **two** trigger chains in this one workflow: the Kafka-triggered
|
|
decision writes an entry when the supplier confirms, and a daily schedule
|
|
(06:15, deliberately after the periodic run's 06:00) reads what is due and
|
|
applies it. A pending entry is deleted only once the PATCH is confirmed by the
|
|
API's own response — a failed write leaves it in place so tomorrow's run
|
|
retries it, rather than losing a delivery nobody applied.
|
|
|
|
### A bug this shipped with, and how it was found
|
|
|
|
`Apply delivery` sits between `Read current stock` and `Update stock`, and its
|
|
first version wrote `{ ...$json, new_quantity }` — but `$json` at that point is
|
|
`Read current stock`'s own output, not the due-delivery record two nodes
|
|
earlier. `reorder_id`/`productcode`/`quantity` were silently dropped, and
|
|
`Update stock` failed two nodes later with `"path_productcode" is a required
|
|
property` — a symptom far from its cause. Fixed by naming the source
|
|
explicitly: `$('Due deliveries').item.json` for what the delivery *is*, `$json`
|
|
for what the catalog *says now*. The same rule `check_availability` already
|
|
follows for `Recent movement` — an implicit `$json` is only ever the
|
|
*immediately preceding* node, never "whichever upstream node has the field I
|
|
want."
|
|
|
|
### Manual test runs do not persist static data
|
|
|
|
Verified the hard way: triggering this workflow via `/rest/workflows/{id}/run`
|
|
executes for real (the write happens, the event is genuine) but the
|
|
`pendingDeliveries` mutation is **not** saved back to the workflow — the entry
|
|
reappears on the next check. Only a genuine trigger firing (a real Kafka
|
|
message, the real schedule) persists it. A manual run is fine for proving the
|
|
logic; it is not fine for proving the state machine, and the two were verified
|
|
separately here.
|
|
|
|
## supply-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".
|
|
|
|
### Deducting open orders would double-count
|
|
|
|
The answer used to end with *"quantities committed on open orders are not
|
|
deducted"*. That caveat is now **wrong**, and acting on it would be worse than
|
|
leaving it: placing an order line decrements stock in the same transaction, so
|
|
an open order has already taken what it needs. Subtracting it again counts it
|
|
twice.
|
|
|
|
The API gained the filters that would have made the deduction possible —
|
|
`list-order-details` takes `productcode` and `ordernumber__status` — and the
|
|
right conclusion was that the deduction is no longer the thing to build.
|
|
|
|
What remains true, and is said once: the order lines that came with the seed
|
|
data predate the stock movement and never decremented anything. They are
|
|
history, not commitments.
|
|
|
|
### 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 stale read once made it contradict itself
|
|
|
|
The agent published this, in one sentence:
|
|
|
|
```
|
|
"68 in stock, above the reorder threshold of 50 … It crossed the threshold, from 68."
|
|
```
|
|
|
|
Both halves were internally consistent and the answer was wrong. The catalog
|
|
domain was caching reads for **60 seconds** at the gateway, so an agent woken by
|
|
an event read the figure from before the event.
|
|
|
|
The first fix was in this workflow — prefer the event's dated figure over the
|
|
undated read. It has been **removed**, because the cache was removed instead:
|
|
caching a read-write API buys little and costs correctness on every mutable
|
|
resource, since the gateway has no invalidation on write. Working around it here
|
|
would have left four other domains lying to whatever reads them next.
|
|
|
|
Measured after: a `PATCH` to 300 followed three seconds later by the same
|
|
question answers **300**.
|
|
|
|
The lesson worth keeping is not about caches. **Do not treat a single read as
|
|
current unless something guarantees it** — and an agent cannot tell.
|
|
|
|
### The crossing is mentioned only while it still holds
|
|
|
|
An entry stays on the watchlist until a human clears it, so a reference that has
|
|
since been restocked still has one. Saying "300 in stock … it crossed the
|
|
threshold, from 68" reads as a contradiction even though both halves are true,
|
|
so the context is appended only while the reference is actually below the
|
|
threshold.
|
|
|
|
## supply-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 answers with a date
|
|
|
|
The other half of what filtering unlocked. `check_availability` now consults
|
|
the sales domain too: `list-order-details` filtered by product **and**
|
|
status (`In Process`, `On Hold`) — `Orderdetail` carries the order's
|
|
`requireddate` and `order_status` denormalised on the line itself, so this is
|
|
one call per status, not one per order.
|
|
|
|
```
|
|
"Peut-on promettre S18_1749 ?"
|
|
→ "Yes … 2720 in stock, above the reorder threshold of 50. Basis: stock on
|
|
hand, already net of order lines placed through the API. 2 open order(s)
|
|
already commit to this reference; the nearest is due 2005-05-04
|
|
(order 10407, On Hold)."
|
|
```
|
|
|
|
This is **not** the deduction that turned out to be wrong two sessions ago —
|
|
that would have subtracted these quantities from stock a second time. This is
|
|
additive context only: what already-open orders expect, and by when. Silent
|
|
when there is none; never blocks the answer if the sales domain cannot be
|
|
reached.
|
|
|
|
### Two token exchanges, on purpose
|
|
|
|
Catalog and sales are different audiences, so this workflow now holds two
|
|
narrow tokens rather than widening the one it already had — the same reason
|
|
every agent in this estate exchanges per callee rather than requesting a
|
|
broad token once.
|
|
|
|
### A straight chain, not two branches joining
|
|
|
|
The obvious shape was two parallel paths — catalog and sales — converging on
|
|
`Answer`. Not built that way: whether a node with two incoming connections
|
|
waits for both before running is exactly the kind of thing worth being certain
|
|
of rather than assuming, and this endpoint answers rarely enough that a few
|
|
hundred extra milliseconds from running sequentially is free. One straight
|
|
chain, ten nodes, nothing to get wrong about ordering.
|
|
|
|
### 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.
|
|
|
|
|
|
## supply-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-supply`: 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`.
|