feat: the periodic reorder proposal, and what it cannot do

The periodic half of Approvisionnement: a 06:00 schedule that reads the catalog
and proposes purchase quantities for anything below the reorder threshold.
Verified on live data — 110 of 110 references scanned, S24_2000 at 15 on hand,
135 proposed.

Three things it deliberately does not do, each recorded rather than worked
around:

It halts without a mandate. No user session exists at 06:00, and the
offline_access mandate the design calls for does not exist yet, so the first
node stops before any token exchange and before reading anything. An agent
without an identity does nothing rather than inventing an authority.

It writes nothing. The schema has no purchase order and no supplier entity, and
the catalog's only write tool is create-product, which belongs to another agent.
So catalog:write was not granted, contrary to the design's scope list: a scope
with no reachable target is an authorisation waiting for a use.

It scans one page and says so. complete: false escalates, because a proposal set
computed from part of the catalog looks exactly like one computed from all of
it.

Also: check_availability now states its basis. Deducting quantities committed on
open orders would need the sales API to be filterable by product code — it is
not, so the honest alternative was paging 2996 rows per question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stéphane Tailland
2026-08-29 17:33:54 +02:00
parent 95b6e3a1c9
commit 06c102fb72
3 changed files with 348 additions and 1 deletions
+54
View File
@@ -88,3 +88,57 @@ Now:
and through the A2A facade, `escalate: true` becomes a task in state **failed** 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 rather than a completed one — the honest shape for "a human has to look at
this". this".
## 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 stops without a mandate, and that is the point
No user session exists at 06:00, so authority has to be pre-established. The
design calls for an `offline_access` mandate — the authorising user's refresh
token, stored encrypted, exchanged at trigger time, so `sub` stays the person
who mandated and `azp` says which agent acted.
**That does not exist yet.** The first node looks for
`APPROVISIONNEMENT_MANDATE_TOKEN` and, finding none, halts before any exchange
and before reading anything. Verified: the run completes, `Exchange token` never
executes, nothing is read. An agent without an identity does nothing rather than
inventing an authority.
### 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.
@@ -157,7 +157,7 @@
}, },
{ {
"parameters": { "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 = $input.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\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 : `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 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 = $input.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// 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}.`\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}.`,\n escalate: false,\n}}];\n"
}, },
"id": "answer", "id": "answer",
"name": "Answer", "name": "Answer",
@@ -0,0 +1,293 @@
{
"name": "Approvisionnement \u2014 periodic reorder proposal",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "days",
"triggerAtHour": 6
}
]
}
},
"id": "every-morning",
"name": "Every morning",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
]
},
{
"parameters": {
"jsCode": "// No user session exists at 6am, so the authority has to be pre-established.\n//\n// NOT IMPLEMENTED. The design calls for an offline_access mandate: the user who\n// authorises the agent obtains a refresh token, it is stored encrypted, and the\n// exchange happens at trigger time \u2014 so `sub` stays the person who mandated and\n// `azp` says which agent acted.\n//\n// Until that exists this run has no identity of its own. It reads\n// APPROVISIONNEMENT_MANDATE_TOKEN from the environment if one was placed there\n// by hand, and stops cleanly otherwise rather than inventing an authority.\nconst token = $env.APPROVISIONNEMENT_MANDATE_TOKEN;\nif (!token) {\n return [{ json: {\n halted: true,\n reason: 'No mandate. This run needs an offline_access token from the user who '\n + 'authorised the agent; none is configured, so it did nothing.',\n }}];\n}\nreturn [{ json: { halted: false, token } }];\n"
},
"id": "mandate",
"name": "Mandate",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"version": 2
},
"conditions": [
{
"leftValue": "={{ $json.halted }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "false"
}
}
],
"combinator": "and"
}
},
"id": "has-a-mandate?",
"name": "Has a mandate?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
440,
0
]
},
{
"parameters": {
"method": "POST",
"url": "https://keycloak.sttlab.local/realms/classic-models/protocol/openid-connect/token",
"sendBody": true,
"contentType": "form-urlencoded",
"bodyParameters": {
"parameters": [
{
"name": "grant_type",
"value": "urn:ietf:params:oauth:grant-type:token-exchange"
},
{
"name": "client_id",
"value": "classic-models-agent-approvisionnement"
},
{
"name": "client_secret",
"value": "={{ $env.APPROVISIONNEMENT_CLIENT_SECRET }}"
},
{
"name": "subject_token",
"value": "={{ $json.token }}"
},
{
"name": "subject_token_type",
"value": "urn:ietf:params:oauth:token-type:access_token"
},
{
"name": "scope",
"value": "openid mcp-catalog-audience token-exchange-broker-audience"
}
]
},
"options": {}
},
"id": "exchange-token",
"name": "Exchange token",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
660,
-100
]
},
{
"parameters": {
"method": "POST",
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.access_token }}"
},
{
"name": "Accept",
"value": "application/json, text/event-stream"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"approvisionnement-periodic\", \"version\": \"0.1.0\"}}}",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"id": "mcp-initialize",
"name": "MCP initialize",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
880,
-100
]
},
{
"parameters": {
"method": "POST",
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $('Exchange token').item.json.access_token }}"
},
{
"name": "Accept",
"value": "application/json, text/event-stream"
},
{
"name": "mcp-session-id",
"value": "={{ $json.headers['mcp-session-id'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 2, \"method\": \"tools/call\", \"params\": {\"name\": \"list-products\", \"arguments\": {\"query_page\": 1, \"query_page_size\": 200}}}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"id": "list-products",
"name": "List products",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1100,
-100
],
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "// Which references need a purchase order, and why.\n//\n// The proposal is computed and goes no further, because there is nowhere for it\n// to go: the classicmodels schema has no purchase order, no supplier entity \u2014\n// `productvendor` is a string on the product \u2014 and the catalog's only write\n// tool is create-product, which is the Catalogue agent's business, not this\n// one's. So this run escalates rather than writing, which is also what an\n// autonomous agent meeting a decision it cannot take is supposed to do.\nconst THRESHOLD = 50;\nconst COVER = 3; // reorder up to three times the threshold\n\nconst item = $input.first();\nconst raw = item?.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: { proposals: [], escalate: true,\n reason: 'The catalog did not answer in the expected shape.' }}];\n}\n\nlet rpc;\ntry { rpc = JSON.parse(line.slice(5).trim()); }\ncatch (e) { return [{ json: { proposals: [], escalate: true,\n reason: \"The catalog's reply could not be read.\" }}]; }\n\n// A tool failure is a successful result carrying isError, not a JSON-RPC error.\nif (rpc.error || rpc.result?.isError) {\n const why = rpc.error?.message ?? rpc.result?.content?.[0]?.text ?? 'unknown';\n return [{ json: { proposals: [], escalate: true,\n reason: `The catalog refused the read: ${why}` }}];\n}\n\nconst page = JSON.parse(rpc.result.content[0].text);\nconst products = page.results ?? [];\nconst scanned = products.length;\nconst total = page.count ?? scanned;\n\nconst proposals = products\n .filter((p) => Number(p.quantityinstock) < THRESHOLD)\n .map((p) => ({\n productcode: p.productcode,\n productname: p.productname,\n vendor: p.productvendor,\n on_hand: Number(p.quantityinstock),\n propose_order: THRESHOLD * COVER - Number(p.quantityinstock),\n }));\n\nreturn [{ json: {\n proposals,\n scanned,\n // Loud when the scan was partial: a proposal set computed from part of the\n // catalog looks exactly like one computed from all of it.\n complete: scanned >= total,\n escalate: proposals.length > 0 || scanned < total,\n reason: scanned < total\n ? `Scanned ${scanned} of ${total} references; the rest were not read.`\n : (proposals.length\n ? `${proposals.length} reference(s) below the threshold of ${THRESHOLD}. `\n + 'Nowhere to record a purchase order \u2014 the domain has no such concept \u2014 '\n + 'so this needs a human.'\n : 'Nothing below the threshold.'),\n}}];\n"
},
"id": "propose-reorders",
"name": "Propose reorders",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
-100
]
},
{
"parameters": {},
"id": "no-mandate",
"name": "No mandate",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
660,
120
]
}
],
"connections": {
"Every morning": {
"main": [
[
{
"node": "Mandate",
"type": "main",
"index": 0
}
]
]
},
"Mandate": {
"main": [
[
{
"node": "Has a mandate?",
"type": "main",
"index": 0
}
]
]
},
"Has a mandate?": {
"main": [
[
{
"node": "Exchange token",
"type": "main",
"index": 0
}
],
[
{
"node": "No mandate",
"type": "main",
"index": 0
}
]
]
},
"Exchange token": {
"main": [
[
{
"node": "MCP initialize",
"type": "main",
"index": 0
}
]
]
},
"MCP initialize": {
"main": [
[
{
"node": "List products",
"type": "main",
"index": 0
}
]
]
},
"List products": {
"main": [
[
{
"node": "Propose reorders",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}