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",