Compare commits

...

17 Commits

Author SHA1 Message Date
Stéphane Tailland 1ec9e308ba Build the supplier: reorder.requested -> decide -> deliver
The service's own supplier, simulated: a fixed lead time or a 15% chance of a
stockout is the whole model, on purpose — this simulates that fulfilment can
fail, not why a given supplier would.

One service, two capacities: this workflow both raises reorder.requested (the
periodic run) and answers its own request (this workflow), under the same
Kafka account and Keycloak client, per the 2026-08-31 decision that
réapprovisionnement stays one service.

The delivery write goes through the front door — patch-product, under a new
restocker role that may PATCH a product and nothing else — so the resulting
product.stock_changed event is the only stock-update fact. No second,
n8n-emitted event claims a delivery happened with nothing to certify it.

Shipped with, and fixed, a bug where the delivery-application node spread the
wrong upstream , silently dropping reorder_id/productcode/quantity two
nodes before the failure surfaced. Verified separately: the write+event chain
(a real bug, now fixed) and the state machine (persists correctly on a real
trigger; a manual test run does not persist staticData, which is a testing
limitation, not a production one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:41:46 +02:00
Stéphane Tailland d11ca36764 Document the date context in check_availability
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:46:40 +02:00
Stéphane Tailland de7725bfb2 Let check_availability answer with a date
Filtering by product and status on order lines was only half the job — the date
lived one hop away, on the order. classic-models-api now denormalises
requireddate and order_status onto the line itself, so this is one MCP call per
status rather than one per order.

Additive only: this is not the committed-quantity deduction that turned out to
be wrong, since stock is already net of it. It just says what open orders
expect and by when.

A straight chain of ten nodes rather than two branches joining on Answer —
whether n8n waits for both inputs before running a fan-in node isn't something
to bet correctness on without watching it execute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:46:14 +02:00
Stéphane Tailland bc83df3ed0 Finish the rename in the prose
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:06:42 +02:00
Stéphane Tailland 7ec4ffad37 Rename the supply agent everywhere it is named
Its Keycloak client, audience scope, client-secret key, workflow names and file
names, and the A2A card. Kafka moved first, under its own account and consumer
group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:02:20 +02:00
Stéphane Tailland 620fefc51b Say what the availability answer is actually based on
The caveat "quantities committed on open orders are not deducted" stopped being
true when placing an order line started decrementing stock. Deducting them now
would count them twice — the API gained the filters that would have made the
deduction possible, and the right conclusion was that it is no longer the thing
to build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:53:49 +02:00
Stéphane Tailland ed99334201 Deploy the adapter fix and document the four outcomes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:39:25 +02:00
Stéphane Tailland 6eb77d862f Stop reporting a silent workflow as a completed task
n8n answers 200 with an empty body when a node throws before the Respond node
runs — a refused token exchange is the common way in. The adapter read that as
a completed task carrying "(the workflow returned nothing)", which tells the
caller the task succeeded: the one thing certainly false.

Off-contract replies get the same treatment. They are still returned raw so
drift is visible, but a body that is not the contract cannot be read as
agreement.

Also removes a backlog section that described the events as unbuilt and said an
event should carry sub and azp from the caller's token — the design the user
overruled. Anyone reading it would have built the wrong consumer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:39:03 +02:00
Stéphane Tailland 78456c7902 Name the Kubernetes objects in English
catalogue-agent and approvisionnement-agent named a Deployment, a Service, an
Ingress and a Certificate in French, in an estate where nothing else is.

The peer URLs move with them, set through the existing per-environment override
so the rename takes effect without rebuilding an image; the defaults in the code
carry the same values for the next build.

The Keycloak client ids, the image names and the repository names are still
French. They are a separate, heavier move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:12:47 +02:00
Stéphane Tailland 2156cf4673 Keep -agent in the hostname, shorten the rest
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:10:50 +02:00
Stéphane Tailland 087b1a6d14 Give the agent a short hostname
assistant-agent.sttlab.local and its two siblings were long, and two of them
were French where nothing else in the estate is.

The Kubernetes object names are unchanged — they name a Deployment and a
Service, and nobody types them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:06:38 +02:00
Stéphane Tailland a64f5026e3 Give the agent a door a human can reach
Nothing outside the cluster could talk to it: every conversation went through
kubectl run curl, which makes an agent that works and nobody can use.

The same authentication subrequest that guards the n8n endpoints guards this
one. The agent forwards and exchanges the caller's token, so a forged one
fails at the realm — but failing downstream is not refusing, and an
unauthenticated caller could still make it do inference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:00:25 +02:00
Stéphane Tailland b04c5cbb11 Do not declare the agent's Kafka account here
Kafka is administered centrally. An ACL is not a team's decision to make about
itself, so the account moved to k8s-platform/kafka-topology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 08:51:22 +02:00
Stéphane Tailland f810efbc04 Own the agent's Kafka account here
An account belongs to the consumer, not the broker: this repo says what the
agent may read, so adding or removing the agent adds or removes its access.
It was in infra-kafka, which deploys a cluster and should hold no opinion about
who uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 08:42:15 +02:00
Stéphane Tailland c5d17d1d04 Drop the stale-read workaround; the cache is gone
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 <noreply@anthropic.com>
2026-08-31 08:31:07 +02:00
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
Stéphane Tailland 33d811fa68 Quote the two ConfigMap values inside the value
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 08:10:03 +02:00
20 changed files with 1507 additions and 464 deletions
+34 -41
View File
@@ -4,7 +4,7 @@
| Agent | Client id | Trigger | | Agent | Client id | Trigger |
|---|---|---| |---|---|---|
| Approvisionnement | `classic-models-agent-approvisionnement` | event-driven, plus a periodic ordering run | | the supply agent | `classic-models-agent-supply` | event-driven, plus a periodic ordering run |
| Recouvrement | `classic-models-agent-recouvrement` | weekly, Monday | | Recouvrement | `classic-models-agent-recouvrement` | weekly, Monday |
They share a repo because they are not code: n8n workflow exports, same tool, They share a repo because they are not code: n8n workflow exports, same tool,
@@ -19,7 +19,7 @@ conversational agents.
They remain full A2A agents: they publish an Agent Card, accept tasks, answer. They remain full A2A agents: they publish an Agent Card, accept tasks, answer.
## Approvisionnement ## Supply
| | | | | |
|---|---| |---|---|
@@ -118,56 +118,49 @@ Recouvrement's dunning is the candidate, and it is a human-validated step
already. The `classic-models-mandate` client is left in the realm, unused, for already. The `classic-models-mandate` client is left in the realm, unused, for
that case. that case.
## Backlog ## Events over Kafka — built, and one rule about them
**Events over Kafka, emitted by `classic-models-api`.** The target for the Built 2026-08-31. `classic-models-api` emits domain events through a
reactive half. The estate emits no domain event today — the API has no signals transactional outbox; a relay publishes them; `supply-stock-watch`
and no outbound webhook, and there is no bus in the cluster — which is what made consumes `product.stock_changed` and is the reactive half this repo had been
a nightly sweep look like the only option. waiting on. The contract is `classic-models-api/docs/DOMAIN_EVENTS.md` — read it
before writing a consumer.
The emission belongs **in the API**, not in a database CDC and not in a gateway **An event carries no actor, and that is deliberate.**
plugin. Only the API knows which change is a business event rather than a row
update, and only it holds the identity that made it — `sub` and `azp` from the
caller's token, which the event has to carry if a downstream agent is to act
under the same authority as the person who triggered it.
Two facts about that codebase shape the work: An earlier draft of this file said the opposite — that the event had to carry
`sub` and `azp` from the caller's token so a downstream agent could act under
the same authority. The user overruled it:
- Every write goes through one `BaseModelViewSet` in > *"Nous gérons ici des événements, pas des 'commandes'. L'agent s'exécute avec
`api/v1/classicmodels/views.py`. One hook there covers products, orders, > sa propre identité machine, on se fout de savoir qui est à l'origine de
payments and the rest — Django signals scattered across models would be the > l'émission de l'événement."*
harder version of the same thing.
- **The models are `managed = False`.** The tables are the pre-existing
classicmodels schema and Django owns no migrations for them. A transactional
outbox means introducing the estate's first managed table beside unmanaged
ones — which works, and is worth deciding deliberately rather than
discovering.
An outbox is the part that matters: emitting from a viewset after `save()` drops The reasoning, kept because it generalises: an event is a **fact**, not a
the event if the process dies between the two, and an agent that never hears command. A consumer acts under its own identity. An actor field would have had
about a stock movement is worse than one that hears late. no consumer and one obvious misuse — anyone able to write to the topic could name
any actor and have an agent act as them. A signed token is proof; a JSON field
is not. Causality travels as `traceparent`, in the system that exists for it.
Until this exists, detection stays unbuilt rather than being approximated by a The emission still belongs **in the API** rather than in a database CDC or a
periodic scan: a batch that pretends to be reactive is worse than an absent gateway plugin: only the API knows which change is a business event rather than
feature, because it looks finished. a row update.
The periodic ordering run does not wait on any of it.
## Two controls that become mandatory here ## Two controls that become mandatory here
They were deferrable with a human in the loop. They are not, once an agent Both **built** 2026-08-31, before the first agent looped unattended.
loops on its own.
- **Read volume caps per `(azp, sub)`**, enforced on Kong. An agent reads a - **Read volume caps per `(azp, sub)`** — a scope authorises a read and says
thousand records in seconds where a human clicks ten; a scope authorises the nothing about volume; an agent reads a thousand records where a human clicks
read but says nothing about volume. ten.
- **Authorisation on tool-call parameters**, not just the tool name. `write` - **Authorisation on tool-call parameters**, not just the tool name. `write`
authorises writing, not "this €40k transfer". authorises writing, not "this €40k transfer".
An autonomous agent hitting a rule that requires approval must **fail cleanly The rules that stay true whatever is built next: an autonomous agent hitting a
and escalate**, never decide. And any approval screen must be rendered from the rule that requires approval must **fail cleanly and escalate**, never decide.
structured tool-call parameters — never from a sentence the model produced, or a And any approval screen must be rendered from the structured tool-call
prompt injection writes what the human validates. parameters — never from a sentence the model produced, or a prompt injection
writes what the human validates.
## Before writing workflows ## Before writing workflows
@@ -180,14 +173,14 @@ on the n8n instance as the source of truth.
## The Kafka credential is created by hand ## The Kafka credential is created by hand
`kafka-classic-models` in n8n holds the SCRAM password of the `kafka-classic-models` in n8n holds the SCRAM password of the
`classic-models-agent-approvisionnement` KafkaUser and the Strimzi cluster CA. `classic-models-agent-supply` KafkaUser and the Strimzi cluster CA.
It is not in this repo and must not be: n8n encrypts stored credentials with It is not in this repo and must not be: n8n encrypts stored credentials with
`N8N_ENCRYPTION_KEY`, and the committed workflow JSON references it by id only. `N8N_ENCRYPTION_KEY`, and the committed workflow JSON references it by id only.
To recreate it after a rebuild: To recreate it after a rebuild:
``` ```
kubectl -n kafka get secret classic-models-agent-approvisionnement \ kubectl -n kafka get secret classic-models-agent-supply \
-o jsonpath='{.data.password}' | base64 -d -o jsonpath='{.data.password}' | base64 -d
kubectl -n kafka get secret kafka-cluster-ca-cert \ kubectl -n kafka get secret kafka-cluster-ca-cert \
-o jsonpath='{.data.ca\.crt}' | base64 -d -o jsonpath='{.data.ca\.crt}' | base64 -d
+20 -1
View File
@@ -32,7 +32,7 @@ completed task is the wrong shape for "a human has to look at this".
| | | | | |
|---|---| |---|---|
| `ADAPTER_AGENT_NAME` | `approvisionnement` | | `ADAPTER_AGENT_NAME` | `supply` |
| `ADAPTER_AGENT_DESCRIPTION` | what the card says it is | | `ADAPTER_AGENT_DESCRIPTION` | what the card says it is |
| `ADAPTER_SKILL_ID` | `check_availability` | | `ADAPTER_SKILL_ID` | `check_availability` |
| `ADAPTER_SKILL_NAME`, `_DESCRIPTION`, `_EXAMPLES` | the card's one skill; examples are newline-separated | | `ADAPTER_SKILL_NAME`, `_DESCRIPTION`, `_EXAMPLES` | the card's one skill; examples are newline-separated |
@@ -42,3 +42,22 @@ completed task is the wrong shape for "a human has to look at this".
One skill per instance, deliberately. A second one is a card change, which is One skill per instance, deliberately. A second one is a card change, which is
the moment to ask whether the agent has quietly become a data proxy. the moment to ask whether the agent has quietly become a data proxy.
## Four outcomes, and only one of them completes
HTTP 200 from n8n does not mean the workflow answered. It returns 200 with an
**empty body** when a node throws before the Respond node runs — a refused token
exchange is the common way in. The adapter used to read that as a completed task
carrying "(the workflow returned nothing)", which told the caller the task had
succeeded: the one thing certainly false.
| `Outcome` | A2A task state |
|---|---|
| `ANSWERED` — the contract, `escalate: false` | `completed` |
| `ESCALATED` — the contract, `escalate: true` | `failed` |
| `SILENT` — 200 with an empty body | `failed` |
| `OFF_CONTRACT` — a body that is not `{"answer", "escalate"}` | `failed` |
Off contract is returned **raw** so a workflow that has drifted is visible — but
never as agreement. Escalation is `failed` for the older reason: an autonomous
agent meeting a rule that needs approval must fail cleanly, never decide.
+2 -2
View File
@@ -2,7 +2,7 @@
Both n8n agents get the same image and differ only by environment. That is the Both n8n agents get the same image and differ only by environment. That is the
point: the A2A surface is identical for every agent in the estate, and what point: the A2A surface is identical for every agent in the estate, and what
distinguishes Approvisionnement from Recouvrement is the workflow behind the distinguishes the supply agent from the collections agent is the workflow behind the
webhook, not the protocol in front of it. webhook, not the protocol in front of it.
Nothing here is a secret. The adapter forwards the end user's token and holds Nothing here is a secret. The adapter forwards the end user's token and holds
@@ -21,7 +21,7 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="ADAPTER_", env_file=".env") model_config = SettingsConfigDict(env_prefix="ADAPTER_", env_file=".env")
# --- who this instance is ------------------------------------------------ # --- who this instance is ------------------------------------------------
agent_name: str = Field(..., description="e.g. approvisionnement") agent_name: str = Field(..., description="e.g. supply")
agent_description: str = Field(...) agent_description: str = Field(...)
agent_version: str = "0.1.0" agent_version: str = "0.1.0"
+40 -14
View File
@@ -27,6 +27,7 @@ from __future__ import annotations
import contextlib import contextlib
import logging import logging
from enum import Enum, auto
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
import httpx import httpx
@@ -152,12 +153,14 @@ class WorkflowExecutor(AgentExecutor):
) )
return return
answer, escalate = _read(response) answer, outcome = _read(response)
if escalate: if outcome is not Outcome.ANSWERED:
# An autonomous agent that meets a rule needing approval must fail # Three different failures, one honest shape. An agent that met a
# cleanly and escalate, never decide. The caller sees a task that # rule needing approval must escalate rather than decide; a workflow
# did not complete, which is the honest shape for "a human has to # that answered nothing did not answer; a workflow that answered off
# look at this". # contract cannot be read as agreement. `completed` on any of them
# tells the caller the task succeeded, which is the one thing that is
# certainly false.
await updater.failed(message=updater.new_agent_message(parts=[Part(text=answer)])) await updater.failed(message=updater.new_agent_message(parts=[Part(text=answer)]))
return return
@@ -167,23 +170,46 @@ class WorkflowExecutor(AgentExecutor):
await TaskUpdater(event_queue, context.task_id, context.context_id).cancel() await TaskUpdater(event_queue, context.task_id, context.context_id).cancel()
def _read(response: httpx.Response) -> tuple[str, bool]: class Outcome(Enum):
"""What the workflow said, and whether it is escalating. """What came back, which is not the same question as whether HTTP worked."""
A workflow answers with `{"answer": "...", "escalate": false}`. Anything else #: The contract was honoured and the workflow is standing behind its answer.
is returned as raw text rather than guessed at — a workflow that has drifted ANSWERED = auto()
from the contract should be visible, not silently reinterpreted. #: The workflow answered, and is handing the decision to a human.
ESCALATED = auto()
#: HTTP 200 with an empty body. n8n does that when a node throws before the
#: Respond node runs — a failed token exchange is the common way in. It is a
#: failure that looks exactly like a success from the status code alone.
SILENT = auto()
#: A body that is not the contract. Returned raw rather than guessed at: a
#: workflow that has drifted should be visible, not reinterpreted.
OFF_CONTRACT = auto()
def _read(response: httpx.Response) -> tuple[str, Outcome]:
"""What the workflow said, and what kind of thing it said.
A workflow answers with `{"answer": "...", "escalate": false}`.
""" """
body = response.text.strip()
if not body:
return (
"The workflow produced no answer. It most often means a node threw "
"before the reply was built — a refused token exchange, usually.",
Outcome.SILENT,
)
try: try:
payload = response.json() payload = response.json()
except ValueError: except ValueError:
return response.text.strip() or "(the workflow returned nothing)", False return body, Outcome.OFF_CONTRACT
if isinstance(payload, list) and payload: if isinstance(payload, list) and payload:
# n8n returns an array when the workflow's last node emits items. # n8n returns an array when the workflow's last node emits items.
payload = payload[0] payload = payload[0]
if isinstance(payload, dict) and "answer" in payload: if isinstance(payload, dict) and "answer" in payload:
return str(payload["answer"]), bool(payload.get("escalate", False)) answer = str(payload["answer"])
return response.text.strip() or "(the workflow returned nothing)", False return answer, Outcome.ESCALATED if payload.get("escalate", False) else Outcome.ANSWERED
return body, Outcome.OFF_CONTRACT
def _subject_token(context: RequestContext) -> str | None: def _subject_token(context: RequestContext) -> str | None:
+18 -10
View File
@@ -11,12 +11,12 @@ import httpx
import pytest import pytest
from a2a_n8n_adapter.config import Settings from a2a_n8n_adapter.config import Settings
from a2a_n8n_adapter.server import _read, build_agent_card from a2a_n8n_adapter.server import Outcome, _read, build_agent_card
def settings(**over) -> Settings: def settings(**over) -> Settings:
base = dict( base = dict(
agent_name="approvisionnement", agent_name="supply",
agent_description="Supply decisions.", agent_description="Supply decisions.",
skill_id="check_availability", skill_id="check_availability",
skill_name="Check availability", skill_name="Check availability",
@@ -35,27 +35,35 @@ def response(payload, status=200) -> httpx.Response:
class TestReadingTheWorkflow: class TestReadingTheWorkflow:
def test_the_contracted_shape(self) -> None: def test_the_contracted_shape(self) -> None:
assert _read(response({"answer": "yes", "escalate": False})) == ("yes", False) assert _read(response({"answer": "yes", "escalate": False})) == ("yes", Outcome.ANSWERED)
def test_n8n_wraps_its_last_node_in_an_array(self) -> None: def test_n8n_wraps_its_last_node_in_an_array(self) -> None:
assert _read(response([{"answer": "yes"}])) == ("yes", False) assert _read(response([{"answer": "yes"}])) == ("yes", Outcome.ANSWERED)
def test_escalation_is_carried_not_swallowed(self) -> None: def test_escalation_is_carried_not_swallowed(self) -> None:
# An autonomous agent meeting a rule that needs approval must escalate. # An autonomous agent meeting a rule that needs approval must escalate.
# Losing this flag would turn "a human must look" into "done". # Losing this flag would turn "a human must look" into "done".
assert _read(response({"answer": "needs approval", "escalate": True})) == ( assert _read(response({"answer": "needs approval", "escalate": True})) == (
"needs approval", "needs approval",
True, Outcome.ESCALATED,
) )
def test_an_off_contract_reply_is_returned_raw(self) -> None: def test_an_off_contract_reply_is_returned_raw(self) -> None:
# Visible rather than reinterpreted: a workflow that has drifted should # Visible rather than reinterpreted: a workflow that has drifted should
# look wrong, not be quietly guessed at. # look wrong, not be quietly guessed at.
text, escalate = _read(response("plain text")) # Off contract is not agreement. Returned raw so the drift is visible,
assert text == "plain text" and escalate is False # but never as a completed answer.
text, outcome = _read(response("plain text"))
assert text == "plain text" and outcome is Outcome.OFF_CONTRACT
def test_an_empty_reply_says_so(self) -> None: def test_an_empty_reply_says_so(self) -> None:
assert _read(response("")) == ("(the workflow returned nothing)", False) # HTTP 200 with an empty body is what n8n returns when a node throws
# before the reply is built — a refused token exchange, usually. Reading
# it as a completed answer told the caller the task succeeded, which was
# the one thing certainly false.
text, outcome = _read(response(""))
assert outcome is Outcome.SILENT
assert "no answer" in text
class TestAgentCard: class TestAgentCard:
@@ -72,8 +80,8 @@ class TestAgentCard:
assert len(card.skills[0].examples) == 2 assert len(card.skills[0].examples) == 2
def test_the_card_advertises_the_public_url_not_the_bind_address(self) -> None: def test_the_card_advertises_the_public_url_not_the_bind_address(self) -> None:
card = build_agent_card(settings(public_url="http://approvisionnement.agents:8080/")) card = build_agent_card(settings(public_url="http://supply-agent.agents:8080/"))
assert card.supported_interfaces[0].url == "http://approvisionnement.agents:8080/" assert card.supported_interfaces[0].url == "http://supply-agent.agents:8080/"
class TestSubjectToken: class TestSubjectToken:
+2 -2
View File
@@ -18,7 +18,7 @@ spec:
target: target:
name: n8n-agent-secrets name: n8n-agent-secrets
data: data:
- secretKey: APPROVISIONNEMENT_CLIENT_SECRET - secretKey: SUPPLY_CLIENT_SECRET
remoteRef: remoteRef:
key: keycloak/classic-models-realm key: keycloak/classic-models-realm
property: KC_SECRET_CLASSIC_MODELS_AGENT_APPROVISIONNEMENT property: KC_SECRET_CLASSIC_MODELS_AGENT_SUPPLY
+70
View File
@@ -0,0 +1,70 @@
# Reaching this agent from outside the cluster.
#
# Until now nothing did: an agent answered only what could call its Service, so
# every conversation with it went through `kubectl run curl`. An orchestrator
# nobody can talk to is not finished.
#
# Peer traffic does **not** come through here — supply calls, and is called by,
# other agents on their in-cluster Service names. This is the human door.
#
# ## It is not an open door
#
# A2A carries the end user's token in `Authorization`, and this agent forwards it
# and exchanges it; a forged one fails at the realm. But "fails downstream" is
# not the same as "refused", and an unauthenticated caller could still make the
# agent do work — inference costs money and a request that reaches an LLM is a
# prompt-injection surface. So the same authentication subrequest that guards the
# n8n endpoints guards this one: Keycloak's userinfo answers 200 for a valid
# bearer in the realm and 401 otherwise, which is the auth-request contract
# exactly.
#
# It authenticates, it does not authorise. What the caller may actually reach is
# decided further down, by the exchange and by the roles in their token.
#
# **The agent card is behind it too.** A2A discovery is normally public; here it
# is not, because a client that cannot send an `Authorization` header cannot use
# the agent anyway, and the card names the skills.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: supply-agent-tls
namespace: agents
spec:
secretName: supply-agent-tls
dnsNames:
- supply-agent.sttlab.local
issuerRef:
name: sttlab-local-ca
kind: ClusterIssuer
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: supply-agent
namespace: agents
annotations:
nginx.ingress.kubernetes.io/auth-url: http://keycloak.keycloak.svc.cluster.local:8080/realms/classic-models/protocol/openid-connect/userinfo
# A2A streams a task's progress; buffering it defeats that.
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
# An agent turn is a model call and several hops. The nginx default of 60s
# cuts a working answer off mid-flight.
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
spec:
ingressClassName: nginx
tls:
- hosts:
- supply-agent.sttlab.local
secretName: supply-agent-tls
rules:
- host: supply-agent.sttlab.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: supply-agent
port:
number: 8080
+2 -1
View File
@@ -1,4 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization kind: Kustomization
resources: resources:
- approvisionnement.yaml - supply.yaml
- ingress.yaml
@@ -1,10 +1,10 @@
# The A2A facade for Approvisionnement. It lives in the `agents` namespace with # The A2A facade for Supply. It lives in the `agents` namespace with
# the other agents, not with n8n: what it is, is an agent — the workflow behind # the other agents, not with n8n: what it is, is an agent — the workflow behind
# it is an implementation detail its callers never see. # it is an implementation detail its callers never see.
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: approvisionnement-agent name: supply-agent
namespace: agents namespace: agents
spec: spec:
# The adapter is stateless — every task is one webhook call — so this is a # The adapter is stateless — every task is one webhook call — so this is a
@@ -12,11 +12,11 @@ spec:
replicas: 1 replicas: 1
selector: selector:
matchLabels: matchLabels:
app: approvisionnement-agent app: supply-agent
template: template:
metadata: metadata:
labels: labels:
app: approvisionnement-agent app: supply-agent
spec: spec:
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
@@ -25,13 +25,13 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: adapter - name: adapter
image: gitea.sttlab.eu/stt/a2a-n8n-adapter:v0.1.0 image: gitea.sttlab.eu/stt/a2a-n8n-adapter:v0.2.0
ports: ports:
- name: http - name: http
containerPort: 8080 containerPort: 8080
env: env:
- name: ADAPTER_AGENT_NAME - name: ADAPTER_AGENT_NAME
value: approvisionnement value: supply
- name: ADAPTER_AGENT_DESCRIPTION - name: ADAPTER_AGENT_DESCRIPTION
value: >- value: >-
Supply decisions for the Classic Models catalog. Answers whether a Supply decisions for the Classic Models catalog. Answers whether a
@@ -53,7 +53,7 @@ spec:
- name: ADAPTER_WEBHOOK_URL - name: ADAPTER_WEBHOOK_URL
value: http://n8n.n8n.svc.cluster.local/webhook/check-availability value: http://n8n.n8n.svc.cluster.local/webhook/check-availability
- name: ADAPTER_PUBLIC_URL - name: ADAPTER_PUBLIC_URL
value: http://approvisionnement-agent.agents.svc.cluster.local:8080/ value: http://supply-agent.agents.svc.cluster.local:8080/
- name: ADAPTER_CA_BUNDLE - name: ADAPTER_CA_BUNDLE
value: /etc/sttlab-ca/ca.crt value: /etc/sttlab-ca/ca.crt
volumeMounts: volumeMounts:
@@ -92,11 +92,11 @@ spec:
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: approvisionnement-agent name: supply-agent
namespace: agents namespace: agents
spec: spec:
selector: selector:
app: approvisionnement-agent app: supply-agent
ports: ports:
- name: http - name: http
port: 8080 port: 8080
+6 -2
View File
@@ -51,5 +51,9 @@ spec:
# "false" so a workflow can read $env.<AGENT>_CLIENT_SECRET. It means any # "false" so a workflow can read $env.<AGENT>_CLIENT_SECRET. It means any
# workflow on this instance can read any variable in the pod — acceptable # workflow on this instance can read any variable in the pod — acceptable
# while it runs only this estate's agents, and not a moment longer. # while it runs only this estate's agents, and not a moment longer.
BLOCK_ENV_ACCESS: "false" BLOCK_ENV_ACCESS: '"false"'
EXECUTIONS_MAX_AGE_HOURS: "336" # Quoted inside the value on purpose: these land in a ConfigMap, whose
# values must be strings, and the template cannot force it — kustomize
# strips quotes before substitution runs. Without the inner quotes the
# apply fails on the type.
EXECUTIONS_MAX_AGE_HOURS: '"336"'
+184 -4
View File
@@ -21,7 +21,82 @@ POST /rest/workflows/<id>/activate {"versionId": "<current>"}
`PATCH {"active": true}` returns 200 and leaves the workflow inactive, silently: `PATCH {"active": true}` returns 200 and leaves the workflow inactive, silently:
activation has its own endpoint. activation has its own endpoint.
## approvisionnement-check-availability ## supply-procurement
The service's own supplier, simulated. Reacts to `reorder.requested` (emitted
by the periodic run below, once it finds a reference under threshold),
decides — a fixed lead time, or a 15% chance the supplier cannot fulfil —
answers with `reorder.confirmed`/`reorder.rejected`, and on the confirmed
delivery date writes the stock itself.
```
reorder.requested {productcode, quantity, requested_at}
→ reorder.confirmed {deliver_on} — 85% of the time
→ reorder.rejected {reason: "supplier stockout"} — 15%
```
No purchase-order entity, no supplier entity, no negotiation — the whole model
is one probability and one lead time, on purpose: this simulates *that*
fulfilment can fail, not *why* a given supplier would.
### One service, two capacities — not two identities
This is still the supply agent, not a separate "procurement" component. It
both requests (the periodic run) and answers its own request (this workflow),
under the same Kafka account and the same Keycloak client — the 2026-08-31
decision that réapprovisionnement is one service, not a split into a demand
side and a supply side.
### The write goes through the front door
On the delivery date, this workflow reads the product's current stock and
PATCHes it — through Kong, through the same `patch-product` MCP tool a human
product manager would use, under a **new, narrow role**: `restocker` may PATCH
a product and nothing else, not create, not replace, not delete. It sends
`X-Stock-Change-Reason: delivery:<reorder-id>`, which `classic-models-api`
only honours in that exact shape; anything else falls back to
`manual-adjustment`. The resulting `product.stock_changed` event is the *only*
stock-update event — there is deliberately no second, n8n-emitted fact
claiming a delivery happened, because nothing would certify it against the
one that actually changed the database. See `classic-models-api`'s commit
"Accept a delivery reason on a stock PATCH" for the backend side.
### Two triggers, one shared state
`$getWorkflowStaticData('global').pendingDeliveries`, keyed by reorder id —
the same pattern the stock-watch workflow already uses for its watchlist, now
shared across **two** trigger chains in this one workflow: the Kafka-triggered
decision writes an entry when the supplier confirms, and a daily schedule
(06:15, deliberately after the periodic run's 06:00) reads what is due and
applies it. A pending entry is deleted only once the PATCH is confirmed by the
API's own response — a failed write leaves it in place so tomorrow's run
retries it, rather than losing a delivery nobody applied.
### A bug this shipped with, and how it was found
`Apply delivery` sits between `Read current stock` and `Update stock`, and its
first version wrote `{ ...$json, new_quantity }` — but `$json` at that point is
`Read current stock`'s own output, not the due-delivery record two nodes
earlier. `reorder_id`/`productcode`/`quantity` were silently dropped, and
`Update stock` failed two nodes later with `"path_productcode" is a required
property` — a symptom far from its cause. Fixed by naming the source
explicitly: `$('Due deliveries').item.json` for what the delivery *is*, `$json`
for what the catalog *says now*. The same rule `check_availability` already
follows for `Recent movement` — an implicit `$json` is only ever the
*immediately preceding* node, never "whichever upstream node has the field I
want."
### Manual test runs do not persist static data
Verified the hard way: triggering this workflow via `/rest/workflows/{id}/run`
executes for real (the write happens, the event is genuine) but the
`pendingDeliveries` mutation is **not** saved back to the workflow — the entry
reappears on the next check. Only a genuine trigger firing (a real Kafka
message, the real schedule) persists it. A manual run is fine for proving the
logic; it is not fine for proving the state machine, and the two were verified
separately here.
## supply-check-availability
Working. `POST /webhook/check-availability` with the end user's token in Working. `POST /webhook/check-availability` with the end user's token in
`X-Subject-Token`: `X-Subject-Token`:
@@ -89,7 +164,74 @@ 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 ### Deducting open orders would double-count
The answer used to end with *"quantities committed on open orders are not
deducted"*. That caveat is now **wrong**, and acting on it would be worse than
leaving it: placing an order line decrements stock in the same transaction, so
an open order has already taken what it needs. Subtracting it again counts it
twice.
The API gained the filters that would have made the deduction possible —
`list-order-details` takes `productcode` and `ordernumber__status` — and the
right conclusion was that the deduction is no longer the thing to build.
What remains true, and is said once: the order lines that came with the seed
data predate the stock movement and never decremented anything. They are
history, not commitments.
### 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 stale read once made it contradict itself
The agent published this, in one sentence:
```
"68 in stock, above the reorder threshold of 50 … It crossed the threshold, from 68."
```
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.
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: a `PATCH` to 300 followed three seconds later by the same
question answers **300**.
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.
## supply-periodic-reorder
The periodic half. A schedule trigger at 06:00, deliberately periodic rather The periodic half. A schedule trigger at 06:00, deliberately periodic rather
than a batch standing in for events: grouping references into a purchase than a batch standing in for events: grouping references into a purchase
@@ -106,6 +248,44 @@ scanned 110 of 110, complete
escalate: true escalate: true
``` ```
### It answers with a date
The other half of what filtering unlocked. `check_availability` now consults
the sales domain too: `list-order-details` filtered by product **and**
status (`In Process`, `On Hold`) — `Orderdetail` carries the order's
`requireddate` and `order_status` denormalised on the line itself, so this is
one call per status, not one per order.
```
"Peut-on promettre S18_1749 ?"
→ "Yes … 2720 in stock, above the reorder threshold of 50. Basis: stock on
hand, already net of order lines placed through the API. 2 open order(s)
already commit to this reference; the nearest is due 2005-05-04
(order 10407, On Hold)."
```
This is **not** the deduction that turned out to be wrong two sessions ago —
that would have subtracted these quantities from stock a second time. This is
additive context only: what already-open orders expect, and by when. Silent
when there is none; never blocks the answer if the sales domain cannot be
reached.
### Two token exchanges, on purpose
Catalog and sales are different audiences, so this workflow now holds two
narrow tokens rather than widening the one it already had — the same reason
every agent in this estate exchanges per callee rather than requesting a
broad token once.
### A straight chain, not two branches joining
The obvious shape was two parallel paths — catalog and sales — converging on
`Answer`. Not built that way: whether a node with two incoming connections
waits for both before running is exactly the kind of thing worth being certain
of rather than assuming, and this endpoint answers rarely enough that a few
hundred extra milliseconds from running sequentially is free. One straight
chain, ten nodes, nothing to get wrong about ordering.
### It acts under its own machine identity ### It acts under its own machine identity
`client_credentials`, not a mandate. An agent running on its own account acts `client_credentials`, not a mandate. An agent running on its own account acts
@@ -148,7 +328,7 @@ 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. read-volume cap this repo already lists as mandatory for autonomous agents.
## approvisionnement-stock-watch ## supply-stock-watch
The reactive half, and the one the design had been waiting on. It is woken by 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 `product.stock_changed` on `classic-models.catalog` rather than by a clock, so a
@@ -207,7 +387,7 @@ better answer when it knows X crossed the threshold at 14:32.
explicitly and succeeds. A workflow that threw on an uninteresting event would explicitly and succeeds. A workflow that threw on an uninteresting event would
replay it forever. replay it forever.
- The consumer has its **own** SCRAM account, - The consumer has its **own** SCRAM account,
`classic-models-agent-approvisionnement`: Read on that one topic and that one `classic-models-agent-supply`: Read on that one topic and that one
consumer-group prefix. It cannot read sales or payments, and it cannot write — 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. so a compromised workflow can raise no event another agent would act on.
@@ -1,258 +0,0 @@
{
"name": "Approvisionnement \u2014 check_availability",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "check-availability",
"responseMode": "responseNode",
"options": {}
},
"id": "webhook",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
0
],
"webhookId": "check-availability"
},
{
"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": "={{ $('Webhook').item.json.headers['x-subject-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": [
220,
0
]
},
{
"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\", \"version\": \"0.1.0\"}}}",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"id": "mcp-initialize",
"name": "MCP initialize",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
440,
0
]
},
{
"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": "={{ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'get-product', arguments: { path_productcode: $('Decide the product').item.json.productcode } } }) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"id": "read-stock",
"name": "Read stock",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
660,
0
],
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "// Pull the product code out of the question.\n//\n// Deliberately loose on the part after the underscore: a reference that\n// looks like one but does not exist must reach the catalog and come back\n// as \"no such product\". Rejecting it here would answer \"you named no\n// product\", which is false and sends the caller looking in the wrong place.\n//\n// A regex rather than a model: this agent's critical path is rules, and putting\n// an LLM on it would make a nightly job both slower and less predictable. If the\n// question does not name a product, the agent escalates instead of guessing \u2014\n// an autonomous agent that meets an ambiguity must hand it back, never resolve\n// it on its own.\nconst question = $('Webhook').first().json.body?.question ?? '';\nconst match = question.match(/\\bS\\d+_[A-Za-z0-9]+\\b/);\nif (!match) {\n return [{ json: { productcode: null, question } }];\n}\nreturn [{ json: { productcode: match[0], question } }];\n"
},
"id": "decide-the-product",
"name": "Decide the product",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
180
]
},
{
"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\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",
"name": "Answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
0
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify($json) }}",
"options": {}
},
"id": "respond",
"name": "Respond",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1100,
0
]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Decide the product",
"type": "main",
"index": 0
}
]
]
},
"Decide the product": {
"main": [
[
{
"node": "Exchange token",
"type": "main",
"index": 0
}
]
]
},
"Exchange token": {
"main": [
[
{
"node": "MCP initialize",
"type": "main",
"index": 0
}
]
]
},
"MCP initialize": {
"main": [
[
{
"node": "Read stock",
"type": "main",
"index": 0
}
]
]
},
"Read stock": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Respond",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -1,115 +0,0 @@
{
"name": "Approvisionnement \u2014 stock watch",
"nodes": [
{
"parameters": {
"topic": "classic-models.catalog",
"groupId": "approvisionnement-stock-watch",
"resolveOffset": "onCompletion",
"allowedStatuses": [
"success"
],
"options": {
"jsonParseMessage": true,
"onlyMessage": true,
"fromBeginning": false
}
},
"type": "n8n-nodes-base.kafkaTrigger",
"typeVersion": 2,
"position": [
0,
0
],
"id": "kafka-trigger-stock-watch",
"name": "Catalog events",
"credentials": {
"kafka": {
"id": "8qNZXLzvdx5rWQ0V",
"name": "kafka-classic-models"
}
}
},
{
"parameters": {
"jsCode": "// A stock movement, judged the moment it happens.\n//\n// This is the reactive half of Approvisionnement. The 06:00 run proposes\n// purchase orders on a buying cycle, which is genuinely periodic; noticing that\n// a reference is about to run out is not, and a nightly sweep would report at\n// 06:00 what was true at 14:32 the previous afternoon.\n//\n// What it reacts to is a fact the API published, not a row it went looking for.\nconst THRESHOLD = 50;\nconst COVER = 3; // a proposal covers three times the threshold\nconst STOCK_CHANGED = 'com.sttlab.classicmodels.product.stock_changed.v1';\n\nconst event = $json ?? {};\n\n// The topic carries every catalog event, so most messages are not ours. Saying\n// so explicitly keeps the offset moving: a workflow that threw on an\n// uninteresting event would replay it forever.\nif (event.type !== STOCK_CHANGED) {\n return [{ json: { considered: false, type: event.type ?? 'unreadable' } }];\n}\n\nconst data = event.data ?? {};\nconst before = Number(data.before?.quantityinstock);\nconst after = Number(data.after?.quantityinstock);\nif (!Number.isFinite(before) || !Number.isFinite(after)) {\n return [{ json: {\n considered: false,\n escalate: true,\n reason: `${event.subject}: the movement carried no readable quantities.`,\n }}];\n}\n\n// Only a *crossing* is news. Without this, every further order line on an\n// already-low reference raises the same alarm again, and an agent that repeats\n// itself is one a human learns to ignore.\nconst crossed = before >= THRESHOLD && after < THRESHOLD;\n// A stock-out is a different fact from a low stock, even when the reference was\n// already below the threshold \u2014 nothing can be promised at all.\nconst emptied = after === 0 && before > 0;\n\n// `order:` means demand; anything else is someone correcting the books. Both\n// justify a purchase order, but only demand is a signal about the future.\nconst fromDemand = String(data.reason ?? '').startsWith('order:');\n\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\nif (!crossed && !emptied) {\n // Not news, but if this reference is already being watched its figures have\n // just moved. Refresh them rather than leaving a human reading the number it\n // held at the moment of detection, which is the number least likely to still\n // be true.\n const watched = store.watchlist[event.subject];\n if (watched) {\n watched.on_hand = after;\n watched.propose_order = THRESHOLD * COVER - after;\n watched.last_movement_at = event.time ?? new Date().toISOString();\n }\n return [{ json: {\n considered: true,\n detected: false,\n watching: Boolean(watched),\n productcode: event.subject,\n on_hand: after,\n }}];\n}\n\nconst state = { detected: true, escalate: true,\n productcode: event.subject,\n on_hand: after,\n was: before,\n threshold: THRESHOLD,\n stock_out: after === 0,\n driver: fromDemand ? 'demand' : 'correction',\n movement: data.reason ?? null,\n propose_order: THRESHOLD * COVER - after,\n observed_at: event.time ?? new Date().toISOString(),\n // Kept so this detection can be joined to the request that caused it. It is\n // the only link back \u2014 the event deliberately names no actor.\n traceparent: event.traceparent ?? null,\n};\n\nstate.reason = state.stock_out\n ? `${state.productcode} is out of stock (was ${before}). Nothing can be promised.`\n : `${state.productcode} fell from ${before} to ${after}, below the threshold of ${THRESHOLD}.`;\n\n// The watchlist is the destination, and it is a real one: the proposal still\n// has nowhere to be written \u2014 the domain has no purchase order and no supplier\n// entity \u2014 so a detection that only logged would be lost between two nightly\n// runs. Here it survives, keyed by reference so a reference cannot appear twice.\nstore.watchlist[state.productcode] = state;\n\nreturn [{ json: state }];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
],
"id": "detect-crossing",
"name": "Judge the movement"
},
{
"parameters": {
"httpMethod": [
"GET",
"POST"
],
"path": "stock-watchlist",
"responseMode": "lastNode",
"options": {},
"multipleMethods": true
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
200
],
"id": "watchlist-read",
"name": "Watchlist",
"webhookId": "stock-watchlist-get"
},
{
"parameters": {
"jsCode": "// What has been detected and not yet dealt with.\n//\n// A detection that goes only into an execution log is one nobody reads. This\n// makes the watchlist readable \u2014 by the periodic run, which can start from what\n// was already noticed rather than rediscovering it, and by a human.\n//\n// GET /webhook/stock-watchlist \u2192 the current list\n// POST /webhook/stock-watchlist {\"clear\": [\"S24_2000\"]} \u2192 drop entries\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\n// GET carries no body; the node still runs, and clear is then empty.\nconst body = $json.body ?? {};\nconst clear = Array.isArray(body.clear) ? body.clear : [];\nfor (const code of clear) delete store.watchlist[code];\n\nconst entries = Object.values(store.watchlist);\nreturn [{ json: {\n watching: entries.length,\n cleared: clear,\n // Worst first: an out-of-stock reference is not the same problem as one that\n // merely crossed the threshold.\n entries: entries.sort((a, b) => a.on_hand - b.on_hand),\n}}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
200
],
"id": "watchlist-render",
"name": "Render the watchlist"
}
],
"connections": {
"Catalog events": {
"main": [
[
{
"node": "Judge the movement",
"type": "main",
"index": 0
}
]
]
},
"Watchlist": {
"main": [
[
{
"node": "Render the watchlist",
"type": "main",
"index": 0
}
],
[
{
"node": "Render the watchlist",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
{ {
"name": "Approvisionnement \u2014 periodic reorder proposal", "name": "Supply \u2014 periodic reorder proposal",
"nodes": [ "nodes": [
{ {
"parameters": { "parameters": {
@@ -35,11 +35,11 @@
}, },
{ {
"name": "client_id", "name": "client_id",
"value": "classic-models-agent-approvisionnement" "value": "classic-models-agent-supply"
}, },
{ {
"name": "client_secret", "name": "client_secret",
"value": "={{ $env.APPROVISIONNEMENT_CLIENT_SECRET }}" "value": "={{ $env.SUPPLY_CLIENT_SECRET }}"
}, },
{ {
"name": "scope", "name": "scope",
@@ -77,7 +77,7 @@
}, },
"sendBody": true, "sendBody": true,
"specifyBody": "json", "specifyBody": "json",
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"approvisionnement-periodic\", \"version\": \"0.1.0\"}}}", "jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"supply-periodic\", \"version\": \"0.1.0\"}}}",
"options": { "options": {
"response": { "response": {
"response": { "response": {
@@ -139,7 +139,7 @@
}, },
{ {
"parameters": { "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" "jsCode": "// Which references need a purchase order, and why.\n//\n// A proposal becomes a request event, not a write: the domain still has no\n// purchase-order entity, and this run does not decide a delivery date or a\n// supplier's answer \u2014 that is the procurement half of this same service,\n// reacting to reorder.requested on its own schedule.\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\n// Each proposal becomes a request, not a purchase order \u2014 the domain still has\n// no such entity, and this run is still not the one that decides a delivery\n// date or a supplier's answer. It raises a fact and hands the decision to the\n// same service's own procurement half, over Kafka, exactly the way any other\n// consumer would be handed it.\nconst now = new Date().toISOString();\nconst requests = proposals.map((p) => ({\n json: {\n reorder_id: `${Date.now().toString(36)}-${p.productcode}-${Math.random().toString(36).slice(2, 8)}`,\n productcode: p.productcode,\n quantity: p.propose_order,\n requested_at: now,\n },\n}));\n\n// Nothing to request is a normal outcome, not a diagnostic \u2014 this workflow has\n// no reader downstream of an \"escalate\" field the way check_availability's\n// webhook does, so there is nowhere for a warning to usefully go beyond n8n's\n// own execution log. A partial scan (scanned < total) is visible there:\n// `results.length` on the raw page ends up smaller than `count`.\nreturn requests;\n"
}, },
"id": "propose-reorders", "id": "propose-reorders",
"name": "Propose reorders", "name": "Propose reorders",
@@ -149,6 +149,30 @@
880, 880,
0 0
] ]
},
{
"parameters": {
"topic": "classic-models.procurement",
"sendInputData": false,
"message": "={{ JSON.stringify({ specversion: '1.0', type: 'com.sttlab.classicmodels.reorder.requested.v1', source: '/supply-agent/procurement', subject: $json.reorder_id, id: $json.reorder_id, time: $json.requested_at, datacontenttype: 'application/json', data: { reorder_id: $json.reorder_id, productcode: $json.productcode, quantity: $json.quantity, requested_at: $json.requested_at } }) }}",
"useKey": true,
"key": "={{ $json.reorder_id }}",
"options": {}
},
"id": "emit-reorder-requests",
"name": "Emit reorder requests",
"type": "n8n-nodes-base.kafka",
"typeVersion": 2,
"position": [
1100,
0
],
"credentials": {
"kafka": {
"id": "8qNZXLzvdx5rWQ0V",
"name": "kafka-classic-models"
}
}
} }
], ],
"connections": { "connections": {
@@ -195,6 +219,17 @@
} }
] ]
] ]
},
"Propose reorders": {
"main": [
[
{
"node": "Emit reorder requests",
"type": "main",
"index": 0
}
]
]
} }
}, },
"settings": { "settings": {
+459
View File
@@ -0,0 +1,459 @@
{
"name": "Supply \u2014 procurement",
"nodes": [
{
"parameters": {
"topic": "classic-models.procurement",
"groupId": "supply-procurement",
"resolveOffset": "onCompletion",
"allowedStatuses": [
"success"
],
"options": {
"jsonParseMessage": true,
"onlyMessage": true,
"fromBeginning": false
}
},
"type": "n8n-nodes-base.kafkaTrigger",
"typeVersion": 2,
"position": [
0,
0
],
"id": "procurement-events",
"name": "Procurement events",
"credentials": {
"kafka": {
"id": "8qNZXLzvdx5rWQ0V",
"name": "kafka-classic-models"
}
}
},
{
"parameters": {
"jsCode": "// Every fact on this topic passes through here \u2014 requests, and this\n// service's own answers to them. Only a request is this node's job; an\n// answer re-arriving (a replay, or this consumer's own message) is not\n// something to decide on a second time.\nif ($json.type !== 'com.sttlab.classicmodels.reorder.requested.v1') return [];\nreturn [{ json: $json.data ?? {} }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
],
"id": "filter-requested",
"name": "Only requests"
},
{
"parameters": {
"jsCode": "// The supplier's answer to one request \u2014 decided once, here, never revisited.\n//\n// A random stockout is the honest way to simulate \"sometimes a supplier\n// cannot fulfil a request\" without pretending to model a real one: no lead\n// time table, no supplier entity, no negotiation. `STOCKOUT_CHANCE` is the\n// whole model.\nconst LEAD_TIME_DAYS = 7;\nconst STOCKOUT_CHANCE = 0.15;\n\nconst req = $json;\nif (!req.reorder_id || !req.productcode) {\n // Not our envelope, or unreadable. Every message on this topic is either a\n // request or an answer to one \u2014 this node only runs on requests (filtered\n // upstream), so a shape this far off the contract is not this run's job.\n return [];\n}\n\nconst rejected = Math.random() < STOCKOUT_CHANCE;\nconst now = new Date();\n\nif (rejected) {\n return [{ json: {\n outcome: 'rejected',\n reorder_id: req.reorder_id,\n productcode: req.productcode,\n quantity: req.quantity,\n decided_at: now.toISOString(),\n reason: 'supplier stockout',\n }}];\n}\n\nconst deliverOn = new Date(now);\ndeliverOn.setUTCDate(deliverOn.getUTCDate() + LEAD_TIME_DAYS);\n\nreturn [{ json: {\n outcome: 'confirmed',\n reorder_id: req.reorder_id,\n productcode: req.productcode,\n quantity: req.quantity,\n decided_at: now.toISOString(),\n deliver_on: deliverOn.toISOString().slice(0, 10),\n}}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
0
],
"id": "decide-request",
"name": "Decide the request"
},
{
"parameters": {
"jsCode": "// The envelope for whichever answer \"Decide the request\" produced. One node\n// covers both event types on purpose: they share every field except the ones\n// that differ (deliver_on vs reason), and a caller distinguishes them by\n// `type`, the way any consumer here already does.\nconst d = $json;\nconst type = d.outcome === 'confirmed'\n ? 'com.sttlab.classicmodels.reorder.confirmed.v1'\n : 'com.sttlab.classicmodels.reorder.rejected.v1';\n\nconst data = d.outcome === 'confirmed'\n ? { reorder_id: d.reorder_id, productcode: d.productcode, quantity: d.quantity, deliver_on: d.deliver_on }\n : { reorder_id: d.reorder_id, productcode: d.productcode, quantity: d.quantity, reason: d.reason };\n\nreturn [{ json: {\n envelope: JSON.stringify({\n specversion: '1.0',\n type,\n source: '/supply-agent/procurement',\n subject: d.reorder_id,\n id: `${d.reorder_id}-${d.outcome}`,\n time: d.decided_at,\n datacontenttype: 'application/json',\n data,\n }),\n reorder_id: d.reorder_id,\n}}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
-100
],
"id": "build-answer",
"name": "Build the answer"
},
{
"parameters": {
"topic": "classic-models.procurement",
"sendInputData": false,
"message": "={{ $json.envelope }}",
"useKey": true,
"key": "={{ $json.reorder_id }}",
"options": {}
},
"type": "n8n-nodes-base.kafka",
"typeVersion": 2,
"position": [
880,
-100
],
"id": "publish-answer",
"name": "Publish the answer",
"credentials": {
"kafka": {
"id": "8qNZXLzvdx5rWQ0V",
"name": "kafka-classic-models"
}
}
},
{
"parameters": {
"jsCode": "// Remembering what to deliver, and when \u2014 the same staticData pattern the\n// stock-watch workflow already uses for its own pending state, so this is not\n// a second mechanism to reason about, just a second use of one.\nif ($json.outcome !== 'confirmed') {\n // A rejection has nothing further to do: no delivery, nothing to schedule.\n return [];\n}\nconst store = $getWorkflowStaticData('global');\nstore.pendingDeliveries = store.pendingDeliveries ?? {};\nstore.pendingDeliveries[$json.reorder_id] = {\n productcode: $json.productcode,\n quantity: $json.quantity,\n deliver_on: $json.deliver_on,\n};\nreturn [{ json: { scheduled: true, reorder_id: $json.reorder_id } }];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
100
],
"id": "schedule-delivery",
"name": "Schedule the delivery"
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "days",
"triggerAtHour": 6,
"triggerAtMinute": 15
}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
300
],
"id": "every-morning-delivery",
"name": "Every morning (delivery)"
},
{
"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": "client_credentials"
},
{
"name": "client_id",
"value": "classic-models-agent-supply"
},
{
"name": "client_secret",
"value": "={{ $env.SUPPLY_CLIENT_SECRET }}"
},
{
"name": "scope",
"value": "openid mcp-catalog-audience token-exchange-broker-audience"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
220,
300
],
"id": "machine-token-delivery",
"name": "Machine token"
},
{
"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\": \"supply-procurement\", \"version\": \"0.1.0\"}}}",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
440,
300
],
"id": "mcp-initialize-delivery",
"name": "MCP initialize"
},
{
"parameters": {
"jsCode": "// What is due today or earlier \u2014 anything scheduled for a date already past\n// is delivered on the next run rather than lost, since this only runs once a\n// day and a missed day must not mean a missed delivery.\nconst store = $getWorkflowStaticData('global');\nstore.pendingDeliveries = store.pendingDeliveries ?? {};\n\nconst today = new Date().toISOString().slice(0, 10);\nconst due = Object.entries(store.pendingDeliveries)\n .filter(([, d]) => d.deliver_on <= today)\n .map(([reorder_id, d]) => ({ json: { reorder_id, ...d } }));\n\nreturn due;\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
300
],
"id": "due-deliveries",
"name": "Due deliveries"
},
{
"parameters": {
"method": "POST",
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $('Machine token').item.json.access_token }}"
},
{
"name": "Accept",
"value": "application/json, text/event-stream"
},
{
"name": "mcp-session-id",
"value": "={{ $('MCP initialize').item.json.headers['mcp-session-id'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'get-product', arguments: { path_productcode: $json.productcode } } }) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
880,
300
],
"id": "read-current-stock",
"name": "Read current stock",
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "// The new stock figure, read fresh rather than trusted from whenever this was\n// scheduled \u2014 a delivery adds to whatever is on hand *now*, not to a number\n// that may be a week stale.\n//\n// Two upstream nodes matter here, not one: \"Due deliveries\" carries what this\n// delivery IS (reorder_id, productcode, quantity), \"Read current stock\" carries\n// what the catalog says right now. `$json` at this point is the LATTER \u2014 using\n// it for the former was the bug that shipped first: it silently dropped\n// reorder_id/productcode/quantity, and the write two nodes later failed on\n// \"path_productcode is a required property\" with no due-delivery context left\n// to explain why.\nconst due = $('Due deliveries').item.json;\n\nconst raw = $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) return [{ json: { ...due, applied: false, why: 'unreadable catalog reply' } }];\n\nlet rpc;\ntry { rpc = JSON.parse(line.slice(5).trim()); }\ncatch (e) { return [{ json: { ...due, applied: false, why: 'unreadable catalog reply' } }]; }\nif (rpc.error || rpc.result?.isError) {\n return [{ json: { ...due, applied: false, why: 'read failed' } }];\n}\n\nconst product = JSON.parse(rpc.result.content[0].text);\nconst newQuantity = Number(product.quantityinstock) + Number(due.quantity);\n\nreturn [{ json: { ...due, new_quantity: newQuantity } }];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
300
],
"id": "apply-delivery",
"name": "Apply delivery"
},
{
"parameters": {
"method": "POST",
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $('Machine token').item.json.access_token }}"
},
{
"name": "Accept",
"value": "application/json, text/event-stream"
},
{
"name": "mcp-session-id",
"value": "={{ $('MCP initialize').item.json.headers['mcp-session-id'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'patch-product', arguments: { path_productcode: $json.productcode, ['header_X-Stock-Change-Reason']: 'delivery:' + $json.reorder_id, body: { quantityinstock: $json.new_quantity } } } }) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1320,
300
],
"id": "update-stock",
"name": "Update stock",
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "// Only clear the pending entry once the write is actually confirmed \u2014 the\n// classic-models-api response is the proof, never an assumption. A failed\n// PATCH here must leave the entry in place so tomorrow's run retries it,\n// rather than silently losing a delivery nobody applied.\nconst raw = $json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\n\nlet ok = false;\ntry {\n const rpc = JSON.parse(line.slice(5).trim());\n ok = !rpc.error && !rpc.result?.isError;\n} catch (e) {\n ok = false;\n}\n\nconst pending = $('Apply delivery').item.json;\nif (!ok) {\n return [{ json: { delivered: false, reorder_id: pending.reorder_id, why: 'the write did not confirm; retrying tomorrow' } }];\n}\n\nconst store = $getWorkflowStaticData('global');\nif (store.pendingDeliveries) delete store.pendingDeliveries[pending.reorder_id];\nreturn [{ json: { delivered: true, reorder_id: pending.reorder_id, productcode: pending.productcode, new_quantity: pending.new_quantity } }];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1540,
300
],
"id": "clear-delivery",
"name": "Confirm delivery"
}
],
"connections": {
"Procurement events": {
"main": [
[
{
"node": "Only requests",
"type": "main",
"index": 0
}
]
]
},
"Only requests": {
"main": [
[
{
"node": "Decide the request",
"type": "main",
"index": 0
}
]
]
},
"Decide the request": {
"main": [
[
{
"node": "Build the answer",
"type": "main",
"index": 0
},
{
"node": "Schedule the delivery",
"type": "main",
"index": 0
}
]
]
},
"Build the answer": {
"main": [
[
{
"node": "Publish the answer",
"type": "main",
"index": 0
}
]
]
},
"Every morning (delivery)": {
"main": [
[
{
"node": "Machine token",
"type": "main",
"index": 0
}
]
]
},
"Machine token": {
"main": [
[
{
"node": "MCP initialize",
"type": "main",
"index": 0
}
]
]
},
"MCP initialize": {
"main": [
[
{
"node": "Due deliveries",
"type": "main",
"index": 0
}
]
]
},
"Due deliveries": {
"main": [
[
{
"node": "Read current stock",
"type": "main",
"index": 0
}
]
]
},
"Read current stock": {
"main": [
[
{
"node": "Apply delivery",
"type": "main",
"index": 0
}
]
]
},
"Apply delivery": {
"main": [
[
{
"node": "Update stock",
"type": "main",
"index": 0
}
]
]
},
"Update stock": {
"main": [
[
{
"node": "Confirm delivery",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+115
View File
@@ -0,0 +1,115 @@
{
"name": "Supply \u2014 stock watch",
"nodes": [
{
"parameters": {
"topic": "classic-models.catalog",
"groupId": "supply-stock-watch",
"resolveOffset": "onCompletion",
"allowedStatuses": [
"success"
],
"options": {
"jsonParseMessage": true,
"onlyMessage": true,
"fromBeginning": false
}
},
"type": "n8n-nodes-base.kafkaTrigger",
"typeVersion": 2,
"position": [
0,
0
],
"id": "kafka-trigger-stock-watch",
"name": "Catalog events",
"credentials": {
"kafka": {
"id": "8qNZXLzvdx5rWQ0V",
"name": "kafka-classic-models"
}
}
},
{
"parameters": {
"jsCode": "// A stock movement, judged the moment it happens.\n//\n// This is the reactive half of Supply. The 06:00 run proposes\n// purchase orders on a buying cycle, which is genuinely periodic; noticing that\n// a reference is about to run out is not, and a nightly sweep would report at\n// 06:00 what was true at 14:32 the previous afternoon.\n//\n// What it reacts to is a fact the API published, not a row it went looking for.\nconst THRESHOLD = 50;\nconst COVER = 3; // a proposal covers three times the threshold\nconst STOCK_CHANGED = 'com.sttlab.classicmodels.product.stock_changed.v1';\n\nconst event = $json ?? {};\n\n// The topic carries every catalog event, so most messages are not ours. Saying\n// so explicitly keeps the offset moving: a workflow that threw on an\n// uninteresting event would replay it forever.\nif (event.type !== STOCK_CHANGED) {\n return [{ json: { considered: false, type: event.type ?? 'unreadable' } }];\n}\n\nconst data = event.data ?? {};\nconst before = Number(data.before?.quantityinstock);\nconst after = Number(data.after?.quantityinstock);\nif (!Number.isFinite(before) || !Number.isFinite(after)) {\n return [{ json: {\n considered: false,\n escalate: true,\n reason: `${event.subject}: the movement carried no readable quantities.`,\n }}];\n}\n\n// Only a *crossing* is news. Without this, every further order line on an\n// already-low reference raises the same alarm again, and an agent that repeats\n// itself is one a human learns to ignore.\nconst crossed = before >= THRESHOLD && after < THRESHOLD;\n// A stock-out is a different fact from a low stock, even when the reference was\n// already below the threshold \u2014 nothing can be promised at all.\nconst emptied = after === 0 && before > 0;\n\n// `order:` means demand; anything else is someone correcting the books. Both\n// justify a purchase order, but only demand is a signal about the future.\nconst fromDemand = String(data.reason ?? '').startsWith('order:');\n\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\nif (!crossed && !emptied) {\n // Not news, but if this reference is already being watched its figures have\n // just moved. Refresh them rather than leaving a human reading the number it\n // held at the moment of detection, which is the number least likely to still\n // be true.\n const watched = store.watchlist[event.subject];\n if (watched) {\n watched.on_hand = after;\n watched.propose_order = THRESHOLD * COVER - after;\n watched.last_movement_at = event.time ?? new Date().toISOString();\n }\n return [{ json: {\n considered: true,\n detected: false,\n watching: Boolean(watched),\n productcode: event.subject,\n on_hand: after,\n }}];\n}\n\nconst state = { detected: true, escalate: true,\n productcode: event.subject,\n on_hand: after,\n was: before,\n threshold: THRESHOLD,\n stock_out: after === 0,\n driver: fromDemand ? 'demand' : 'correction',\n movement: data.reason ?? null,\n propose_order: THRESHOLD * COVER - after,\n observed_at: event.time ?? new Date().toISOString(),\n // Kept so this detection can be joined to the request that caused it. It is\n // the only link back \u2014 the event deliberately names no actor.\n traceparent: event.traceparent ?? null,\n};\n\nstate.reason = state.stock_out\n ? `${state.productcode} is out of stock (was ${before}). Nothing can be promised.`\n : `${state.productcode} fell from ${before} to ${after}, below the threshold of ${THRESHOLD}.`;\n\n// The watchlist is the destination, and it is a real one: the proposal still\n// has nowhere to be written \u2014 the domain has no purchase order and no supplier\n// entity \u2014 so a detection that only logged would be lost between two nightly\n// runs. Here it survives, keyed by reference so a reference cannot appear twice.\nstore.watchlist[state.productcode] = state;\n\nreturn [{ json: state }];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
],
"id": "detect-crossing",
"name": "Judge the movement"
},
{
"parameters": {
"httpMethod": [
"GET",
"POST"
],
"path": "stock-watchlist",
"responseMode": "lastNode",
"options": {},
"multipleMethods": true
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
200
],
"id": "watchlist-read",
"name": "Watchlist",
"webhookId": "stock-watchlist-get"
},
{
"parameters": {
"jsCode": "// What has been detected and not yet dealt with.\n//\n// A detection that goes only into an execution log is one nobody reads. This\n// makes the watchlist readable \u2014 by the periodic run, which can start from what\n// was already noticed rather than rediscovering it, and by a human.\n//\n// GET /webhook/stock-watchlist \u2192 the current list\n// POST /webhook/stock-watchlist {\"clear\": [\"S24_2000\"]} \u2192 drop entries\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\n// GET carries no body; the node still runs, and clear is then empty.\nconst body = $json.body ?? {};\nconst clear = Array.isArray(body.clear) ? body.clear : [];\nfor (const code of clear) delete store.watchlist[code];\n\nconst entries = Object.values(store.watchlist);\nreturn [{ json: {\n watching: entries.length,\n cleared: clear,\n // Worst first: an out-of-stock reference is not the same problem as one that\n // merely crossed the threshold.\n entries: entries.sort((a, b) => a.on_hand - b.on_hand),\n}}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
200
],
"id": "watchlist-render",
"name": "Render the watchlist"
}
],
"connections": {
"Catalog events": {
"main": [
[
{
"node": "Judge the movement",
"type": "main",
"index": 0
}
]
]
},
"Watchlist": {
"main": [
[
{
"node": "Render the watchlist",
"type": "main",
"index": 0
}
],
[
{
"node": "Render the watchlist",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}