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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user