bc83df3ed0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
195 lines
8.5 KiB
Markdown
195 lines
8.5 KiB
Markdown
# Flows — the autonomous agents
|
|
|
|
**Runtime:** n8n · **Mode:** autonomous · Two agents in one repo.
|
|
|
|
| Agent | Client id | Trigger |
|
|
|---|---|---|
|
|
| the supply agent | `classic-models-agent-supply` | event-driven, plus a periodic ordering run |
|
|
| Recouvrement | `classic-models-agent-recouvrement` | weekly, Monday |
|
|
|
|
They share a repo because they are not code: n8n workflow exports, same tool,
|
|
same owner. Splitting them would buy nothing.
|
|
|
|
## Why low-code for these two
|
|
|
|
Their work is rules, thresholds and due dates — a sequence of conditions and
|
|
calls, which a visual editor expresses better than code. The LLM is optional on
|
|
their critical path, which makes them cheaper and more predictable than the
|
|
conversational agents.
|
|
|
|
They remain full A2A agents: they publish an Agent Card, accept tasks, answer.
|
|
|
|
## Supply
|
|
|
|
| | |
|
|
|---|---|
|
|
| MCP | catalog, sales |
|
|
| Scopes | `catalog:read`, `catalog:write`, `sales:read` |
|
|
|
|
Detect coming stock-outs, propose reorders — **two jobs with two different
|
|
triggers**, which an earlier revision of this file collapsed into one nightly
|
|
run.
|
|
|
|
*Detection is reactive.* It depends on something happening: an order placed, a
|
|
stock level crossing a threshold. Finding that out up to a day later is latency
|
|
chosen for nothing, and a nightly sweep re-reads the whole catalog to rediscover
|
|
what the domain already knew at the moment it changed.
|
|
|
|
*Proposing a purchase order is periodic.* Nobody reorders on every unit sold;
|
|
orders are grouped by supplier lead time and buying cycle. A clock is the right
|
|
trigger there, and that run is periodic on purpose rather than a batch standing
|
|
in for events.
|
|
|
|
Worth naming, because it changes what has to be built: **the autonomous mandate
|
|
is a cost of the trigger, not of the business need.** `offline_access`, a
|
|
refresh token stored encrypted and an exchange at trigger time are required only
|
|
because nobody is present at 3am. An agent woken by a domain event runs moments
|
|
after a human acted.
|
|
|
|
**Exposes** `check_availability` — called by Ventes before promising a delivery
|
|
date, and by Catalogue before discontinuing a reference. Returns a decision and a
|
|
date, never the stock table.
|
|
|
|
## Recouvrement
|
|
|
|
| | |
|
|
|---|---|
|
|
| MCP | customers, payments |
|
|
| Scopes | `customers:read`, `payments:read`, `payments:write` |
|
|
|
|
Weekly: unpaid invoices past 30 days, dunning, escalation.
|
|
|
|
**Exposes** `check_creditworthiness` — called by Ventes before accepting a large
|
|
order.
|
|
|
|
**Dunning is prepared automatically and sent only after human validation.**
|
|
Sending a letter to a customer is an outward-facing action; a rule bug would
|
|
otherwise write to the entire customer base in one run.
|
|
|
|
## The A2A server gap
|
|
|
|
The available n8n community nodes ([n8n-nodes-a2a](https://github.com/agentic-layer/n8n-nodes-a2a),
|
|
[n8n-nodes-agent2agent](https://github.com/pjawz/n8n-nodes-agent2agent)) make n8n
|
|
an A2A **client**: `Discover Agent`, `Send Task`.
|
|
|
|
But both agents here must be **servers** — Ventes calls them. That needs an
|
|
Agent Card on `/.well-known/agent.json` and a JSON-RPC endpoint with the task
|
|
lifecycle. n8n webhooks can do it by hand, at which point it stops being no-code.
|
|
|
|
Preferred approach: **Kong in front of n8n**, exactly as it fronts every other
|
|
resource. Kong serves the Agent Card statically and routes JSON-RPC to the
|
|
webhook, and token verification lands where it already lives. No new component.
|
|
|
|
Alternative: a small FastA2A adapter, written once for both — see
|
|
`classic-models-agent-catalogue`, which uses FastA2A already.
|
|
|
|
## An autonomous run is a machine identity
|
|
|
|
**Superseded, 2026-08-29.** An earlier revision had these agents act under an
|
|
`offline_access` mandate: a user authorises the agent, their refresh token is
|
|
stored encrypted, and it is redeemed at trigger time so `sub` stays the person
|
|
who mandated.
|
|
|
|
That was built and then withdrawn, because it is not true. **An agent running on
|
|
its own account acts for itself.** Nobody asked for the 06:00 run; naming a
|
|
person as its principal is a fiction, and choosing which person is arbitrary —
|
|
the first implementation picked whichever test user was to hand.
|
|
|
|
It also cost more than it was worth. An offline token has no `exp`; it dies only
|
|
after 30 days unused, is not rotated on use (`revokeRefreshToken: false`), and
|
|
is revocable only by killing that user's offline session. A permanent bearer
|
|
credential, standing in for a consent nobody gave.
|
|
|
|
So: `client_credentials`, and the service account carries the realm role the job
|
|
needs. For the periodic reorder run that is **`read_only`** — so `create-product`
|
|
is not merely unused, it is **invisible** to that identity. Least privilege
|
|
enforced by the realm and the gateway rather than by the workflow behaving
|
|
itself. Verified: `tools/list` under the machine identity returns `get-product`
|
|
and `list-products`, nothing more.
|
|
|
|
What does *not* change: `azp` still names the agent, the audiences are still
|
|
per-server, and the MCP server still derives the consumer group from the token's
|
|
roles. Downstream policy still needs no knowledge of the trigger mode — it reads
|
|
roles, and a service account has roles like anyone else.
|
|
|
|
**Where a mandate would still be right:** when a person's own authority is
|
|
genuinely in play — an action they are accountable for, or data scoped to them.
|
|
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
|
|
that case.
|
|
|
|
## Events over Kafka — built, and one rule about them
|
|
|
|
Built 2026-08-31. `classic-models-api` emits domain events through a
|
|
transactional outbox; a relay publishes them; `supply-stock-watch`
|
|
consumes `product.stock_changed` and is the reactive half this repo had been
|
|
waiting on. The contract is `classic-models-api/docs/DOMAIN_EVENTS.md` — read it
|
|
before writing a consumer.
|
|
|
|
**An event carries no actor, and that is deliberate.**
|
|
|
|
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:
|
|
|
|
> *"Nous gérons ici des événements, pas des 'commandes'. L'agent s'exécute avec
|
|
> sa propre identité machine, on se fout de savoir qui est à l'origine de
|
|
> l'émission de l'événement."*
|
|
|
|
The reasoning, kept because it generalises: an event is a **fact**, not a
|
|
command. A consumer acts under its own identity. An actor field would have had
|
|
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.
|
|
|
|
The emission still belongs **in the API** rather than in a database CDC or a
|
|
gateway plugin: only the API knows which change is a business event rather than
|
|
a row update.
|
|
|
|
## Two controls that become mandatory here
|
|
|
|
Both **built** 2026-08-31, before the first agent looped unattended.
|
|
|
|
- **Read volume caps per `(azp, sub)`** — a scope authorises a read and says
|
|
nothing about volume; an agent reads a thousand records where a human clicks
|
|
ten.
|
|
- **Authorisation on tool-call parameters**, not just the tool name. `write`
|
|
authorises writing, not "this €40k transfer".
|
|
|
|
The rules that stay true whatever is built next: an autonomous agent hitting a
|
|
rule that requires approval must **fail cleanly and escalate**, never decide.
|
|
And any approval screen must be rendered from the structured tool-call
|
|
parameters — never from a sentence the model produced, or a prompt injection
|
|
writes what the human validates.
|
|
|
|
## Before writing workflows
|
|
|
|
Read `classic-models-agent-platform/CLAUDE.md` for the identity model, the
|
|
endpoints and the known blockers.
|
|
|
|
Deliverables in English. Export workflows as JSON and commit them; do not rely
|
|
on the n8n instance as the source of truth.
|
|
|
|
## The Kafka credential is created by hand
|
|
|
|
`kafka-classic-models` in n8n holds the SCRAM password of the
|
|
`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
|
|
`N8N_ENCRYPTION_KEY`, and the committed workflow JSON references it by id only.
|
|
|
|
To recreate it after a rebuild:
|
|
|
|
```
|
|
kubectl -n kafka get secret classic-models-agent-supply \
|
|
-o jsonpath='{.data.password}' | base64 -d
|
|
kubectl -n kafka get secret kafka-cluster-ca-cert \
|
|
-o jsonpath='{.data.ca\.crt}' | base64 -d
|
|
```
|
|
|
|
then POST to `/rest/credentials` with type `kafka`, `ssl: true`, the CA in the
|
|
`ca` field, `saslMechanism: scram-sha-512`, brokers
|
|
`kafka-kafka-bootstrap.kafka.svc.cluster.local:9093`. The workflow's
|
|
`credentials.kafka.id` then needs the new id.
|
|
|
|
The KafkaUser itself lives in `infra-kafka/kafka-users.yaml`.
|