Files
Stéphane Tailland 1ec9e308ba Build the supplier: reorder.requested -> decide -> deliver
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>
2026-08-31 16:41:46 +02:00

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`.