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>
This commit is contained in:
Stéphane Tailland
2026-08-31 14:39:03 +02:00
parent 78456c7902
commit 6eb77d862f
5 changed files with 85 additions and 58 deletions
+30 -37
View File
@@ -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
that case.
## Backlog
## Events over Kafka — built, and one rule about them
**Events over Kafka, emitted by `classic-models-api`.** The target for the
reactive half. The estate emits no domain event today — the API has no signals
and no outbound webhook, and there is no bus in the cluster — which is what made
a nightly sweep look like the only option.
Built 2026-08-31. `classic-models-api` emits domain events through a
transactional outbox; a relay publishes them; `approvisionnement-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.
The emission belongs **in the API**, not in a database CDC and not in a gateway
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.
**An event carries no actor, and that is deliberate.**
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
`api/v1/classicmodels/views.py`. One hook there covers products, orders,
payments and the rest — Django signals scattered across models would be the
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.
> *"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."*
An outbox is the part that matters: emitting from a viewset after `save()` drops
the event if the process dies between the two, and an agent that never hears
about a stock movement is worse than one that hears late.
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.
Until this exists, detection stays unbuilt rather than being approximated by a
periodic scan: a batch that pretends to be reactive is worse than an absent
feature, because it looks finished.
The periodic ordering run does not wait on any of 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
They were deferrable with a human in the loop. They are not, once an agent
loops on its own.
Both **built** 2026-08-31, before the first agent looped unattended.
- **Read volume caps per `(azp, sub)`**, enforced on Kong. An agent reads a
thousand records in seconds where a human clicks ten; a scope authorises the
read but says nothing about volume.
- **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".
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.
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