# 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//deactivate {"versionId": ""} PATCH /rest/workflows/ {"nodes":…, "connections":…, "versionId": ""} POST /rest/workflows//activate {"versionId": ""} ``` `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:` 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`.