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

11 KiB

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.