From c5d17d1d04654e9888ec1cd732e59bde0b91b072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Mon, 31 Aug 2026 08:31:07 +0200 Subject: [PATCH] Drop the stale-read workaround; the cache is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferring the event's figure over the read existed only because the catalog gateway cached reads for 60 seconds. That cache has been removed at the source, so the read is current and there is no second source of truth to arbitrate. The crossing context is now appended only while the reference is still below the threshold — an entry outlives the condition it describes. Co-Authored-By: Claude Opus 5 --- workflows/README.md | 39 +++++++++++-------- .../approvisionnement-check-availability.json | 2 +- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/workflows/README.md b/workflows/README.md index 94dc1f9..57f3f32 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -108,32 +108,37 @@ webhook — its data lives in that workflow and there is no other way in. The no is `onError: continueRegularOutput`: an availability answer must not depend on the watchlist being up. -### A dated fact beats an undated read +### A stale read once made it contradict itself -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: +The agent published this, 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: +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. -``` -Basis: the latest stock movement, which is more recent than the catalog read -``` +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 the fix, with the cache deliberately warmed at 38 and the true -stock moved to 8 inside the window: the answer said **8**. +Measured after: a `PATCH` to 300 followed three seconds later by the same +question answers **300**. -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. +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. ## approvisionnement-periodic-reorder diff --git a/workflows/approvisionnement-check-availability.json b/workflows/approvisionnement-check-availability.json index c9ed2e4..a479f3a 100644 --- a/workflows/approvisionnement-check-availability.json +++ b/workflows/approvisionnement-check-availability.json @@ -157,7 +157,7 @@ }, { "parameters": { - "jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst item = $('Read stock').first();\nif (!item) {\n return [{ json: {\n answer: `The catalog returned nothing for ${decided.productcode}. It may not exist.`,\n escalate: true,\n }}];\n}\nconst raw = item.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nlet rpc;\ntry {\n rpc = JSON.parse(line.slice(5).trim());\n} catch (e) {\n return [{ json: {\n answer: `The catalog's reply for ${decided.productcode} could not be read.`,\n escalate: true,\n }}];\n}\n\n// A JSON-RPC `error` is a protocol failure \u2014 a malformed call, a session gone.\nif (rpc.error) {\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\n// A *tool* failure is not that. MCP reports it as a perfectly successful result\n// carrying `isError: true`, with the reason as text \u2014 so a client that only\n// checks `error` reads a failure as data and tries to parse \"HTTP call failed\n// with status 404\" as a product. Which is exactly what this did.\nconst text = rpc.result?.content?.[0]?.text ?? '';\nif (rpc.result?.isError) {\n const missing = text.includes('404');\n return [{ json: {\n answer: missing\n ? `There is no product ${decided.productcode} in the catalog.`\n : `The catalog could not answer for ${decided.productcode}: ${text}`,\n escalate: true,\n }}];\n}\n\nlet product;\ntry {\n product = JSON.parse(text);\n} catch (e) {\n return [{ json: {\n answer: `The catalog returned something unreadable for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\nlet stock = Number(product.quantityinstock);\n// What the reactive half noticed, if anything. `product.stock_changed` is\n// consumed as it happens, so a crossing that occurred twenty minutes ago is\n// known here \u2014 and \"it fell below this afternoon, under demand\" is a different\n// decision from \"it has been sitting below for a month\".\n//\n// Read from the watchlist, and absent if the watchlist could not be read: an\n// availability answer must not depend on it.\nlet movement = null;\ntry {\n const watch = $('Recent movement').first()?.json ?? {};\n movement = (watch.entries ?? []).find((e) => e.productcode === product.productcode) ?? null;\n} catch (e) {\n movement = null;\n}\n\n// The two figures can disagree, and when they do the read is the wrong one.\n//\n// A movement is the catalog's own dated statement that stock went from X to Y.\n// A read is an undated copy that may have been taken before that happened \u2014\n// which is how this agent came to answer \"68 in stock, above the threshold\" and\n// \"it crossed the threshold, from 68\" in the same sentence. It was measured:\n// the read lagged the event by up to a minute.\n//\n// So a dated fact beats an undated one. This only helps a reference the\n// reactive half is already watching; for any other, the read stands with\n// whatever staleness it has, and nothing here can tell.\nlet basedOnMovement = false;\nif (movement && Number.isFinite(Number(movement.on_hand)) && Number(movement.on_hand) !== stock) {\n stock = Number(movement.on_hand);\n basedOnMovement = true;\n}\n\nlet context = '';\nif (movement) {\n const when = movement.last_movement_at ?? movement.observed_at;\n const driver = movement.driver === 'demand'\n ? 'driven by orders'\n : 'a stock correction, not demand';\n context = movement.stock_out\n ? ` It ran out at ${when} (${driver}).`\n : ` It crossed the threshold at ${when} (${driver}), from ${movement.was}.`;\n}\n\nconst available = stock >= THRESHOLD;\n\n// Stock on hand only. Quantities already committed on unshipped orders are\n// NOT deducted, and the answer says so rather than implying a certainty it does\n// not have: the sales API cannot be filtered by product code, so working it out\n// would mean paging the whole orderdetails table on every question \u2014 sixty\n// pages, for one answer. See the backlog note in CLAUDE.md.\nconst basis = (basedOnMovement\n ? 'the latest stock movement, which is more recent than the catalog read'\n : 'stock on hand')\n + '; quantities committed on open orders are not deducted';\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}. `\n + `Basis: ${basis}.${context}`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised. `\n + `Basis: ${basis}.${context}`,\n escalate: false,\n}}];\n" + "jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst item = $('Read stock').first();\nif (!item) {\n return [{ json: {\n answer: `The catalog returned nothing for ${decided.productcode}. It may not exist.`,\n escalate: true,\n }}];\n}\nconst raw = item.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nlet rpc;\ntry {\n rpc = JSON.parse(line.slice(5).trim());\n} catch (e) {\n return [{ json: {\n answer: `The catalog's reply for ${decided.productcode} could not be read.`,\n escalate: true,\n }}];\n}\n\n// A JSON-RPC `error` is a protocol failure \u2014 a malformed call, a session gone.\nif (rpc.error) {\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\n// A *tool* failure is not that. MCP reports it as a perfectly successful result\n// carrying `isError: true`, with the reason as text \u2014 so a client that only\n// checks `error` reads a failure as data and tries to parse \"HTTP call failed\n// with status 404\" as a product. Which is exactly what this did.\nconst text = rpc.result?.content?.[0]?.text ?? '';\nif (rpc.result?.isError) {\n const missing = text.includes('404');\n return [{ json: {\n answer: missing\n ? `There is no product ${decided.productcode} in the catalog.`\n : `The catalog could not answer for ${decided.productcode}: ${text}`,\n escalate: true,\n }}];\n}\n\nlet product;\ntry {\n product = JSON.parse(text);\n} catch (e) {\n return [{ json: {\n answer: `The catalog returned something unreadable for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\n// What the reactive half noticed, if anything. `product.stock_changed` is\n// consumed as it happens, so a crossing that occurred twenty minutes ago is\n// known here \u2014 and \"it fell below this afternoon, under demand\" is a different\n// decision from \"it has been sitting below for a month\".\n//\n// Read from the watchlist, and absent if the watchlist could not be read: an\n// availability answer must not depend on it.\nlet movement = null;\ntry {\n const watch = $('Recent movement').first()?.json ?? {};\n movement = (watch.entries ?? []).find((e) => e.productcode === product.productcode) ?? null;\n} catch (e) {\n movement = null;\n}\n\n// Only while the reference is still low. An entry stays on the watchlist until\n// a human clears it, so a reference that has since been restocked still has\n// one \u2014 and \"300 in stock \u2026 it crossed the threshold, from 68\" reads as a\n// contradiction even though both halves are true.\nlet context = '';\nif (movement && !available) {\n const when = movement.last_movement_at ?? movement.observed_at;\n const driver = movement.driver === 'demand'\n ? 'driven by orders'\n : 'a stock correction, not demand';\n context = movement.stock_out\n ? ` It ran out at ${when} (${driver}).`\n : ` It crossed the threshold at ${when} (${driver}), from ${movement.was}.`;\n}\n\n// Stock on hand only. Quantities already committed on unshipped orders are\n// NOT deducted, and the answer says so rather than implying a certainty it does\n// not have: the sales API cannot be filtered by product code, so working it out\n// would mean paging the whole orderdetails table on every question \u2014 sixty\n// pages, for one answer. See the backlog note in CLAUDE.md.\nconst basis = 'stock on hand; quantities committed on open orders are not deducted';\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}. `\n + `Basis: ${basis}.${context}`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised. `\n + `Basis: ${basis}.${context}`,\n escalate: false,\n}}];\n" }, "id": "answer", "name": "Answer",