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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Every webhook on this instance was answering unauthenticated requests, the
stock watchlist included — it returned references and stock levels and let
anyone clear entries. The A2A facade in front of these workflows enforces a
bearer, but the workflows stayed reachable directly, facade bypassed.
Keycloak's userinfo is the authenticator: 200 for a valid bearer in the realm,
401 otherwise, which is the auth-request contract exactly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reactive half of the agent. It consumes product.stock_changed from the
catalog topic and reports only a crossing of the threshold — every further
order line on an already-low reference would otherwise raise the same alarm,
and an agent that repeats itself is one a human learns to ignore. A stock-out
is treated as its own fact even when the reference was already low.
The detection lands on a watchlist readable over a webhook, because a proposal
that reached only an execution log would be lost between two nightly runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Withdrawn: the offline_access mandate built an hour ago. An agent running on its
own account acts for itself. Nobody asked for the 06:00 run, so naming a person
as its principal was a fiction — and choosing which person was arbitrary, as the
first implementation showed by picking whichever test user was to hand.
It also cost more than it was worth. An offline token has no exp, dies only
after 30 days unused, is not rotated on use, and is revocable only by killing
that user's offline session: a permanent bearer credential standing in for a
consent nobody gave.
Now client_credentials, with the service account carrying read_only — the least
privilege the job needs. That is not a convention the workflow observes: the MCP
server maps the role to a consumer group at discovery, so tools/list under this
identity returns get-product and list-products and nothing else. create-product
is invisible rather than merely unused.
Nothing downstream changes. azp still names the agent, audiences are still
per-server, and policy still reads roles — a service account has roles like
anyone else.
The mandate client stays in the realm, unused, for the case where a person's own
authority genuinely is in play: Recouvrement's dunning, which is human-validated
already.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The standing authority the autonomous runs act under: an offline refresh token
issued by a new classic-models-mandate client for the user who authorised the
agent, stored in Vault, redeemed at trigger time.
The mandate client holds no domain audience. It cannot reach data; it can only
say who authorised whom. Direct access grants on it is a lab shortcut, noted as
such — mandating an agent is a decision a person takes, and a password grant
records no consent where the authorization code flow would.
Verified end to end: refresh gives sub=pm-user azp=classic-models-mandate with
the agent in the audience, the agent exchanges it to azp=itself and
aud=mcp-catalog, and the MCP call answers 200. sub stays the person who
mandated, azp says which agent acted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The periodic half of Approvisionnement: a 06:00 schedule that reads the catalog
and proposes purchase quantities for anything below the reorder threshold.
Verified on live data — 110 of 110 references scanned, S24_2000 at 15 on hand,
135 proposed.
Three things it deliberately does not do, each recorded rather than worked
around:
It halts without a mandate. No user session exists at 06:00, and the
offline_access mandate the design calls for does not exist yet, so the first
node stops before any token exchange and before reading anything. An agent
without an identity does nothing rather than inventing an authority.
It writes nothing. The schema has no purchase order and no supplier entity, and
the catalog's only write tool is create-product, which belongs to another agent.
So catalog:write was not granted, contrary to the design's scope list: a scope
with no reachable target is an authorisation waiting for a use.
It scans one page and says so. complete: false escalates, because a proposal set
computed from part of the catalog looks exactly like one computed from all of
it.
Also: check_availability now states its basis. Deducting quantities committed on
open orders would need the sales API to be filterable by product code — it is
not, so the honest alternative was paging 2996 rows per question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recording where the work lands and the two facts about that codebase that
shape it: every write passes through one BaseModelViewSet, so one hook covers
every domain; and the models are managed = False, so a transactional outbox
introduces the estate's first Django-managed table beside the pre-existing
schema.
Emission belongs in the API rather than a database CDC or a gateway plugin,
because only the API knows which change is a business event and only it holds
the sub/azp that the event must carry for a downstream agent to act under the
same authority.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An earlier revision collapsed detection and ordering into one nightly run.
They are not the same job: detecting a coming stock-out depends on something
happening and should be woken by it, while proposing a purchase order is
genuinely periodic — grouped by supplier lead time, not by unit sold.
Recorded because it changes what has to be built: the autonomous mandate is a
cost of choosing a clock, not of the business need.
Kafka is the target for the reactive half. Nothing in the estate emits a domain
event today, which is what made a nightly sweep look inevitable. Detection stays
unbuilt until then rather than being approximated by a periodic scan — a batch
pretending to be reactive is worse than an absent feature, because it looks
finished.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MCP does not report a tool failure as a JSON-RPC error. It reports it as a
successful result carrying isError: true, with the reason as text. The node
only checked `error`, so it read the failure as data and went on to JSON.parse
the sentence "HTTP call failed with status 404" as a product. The throw is what
made the webhook answer nothing at all.
Now checks result.isError, distinguishes a missing product from any other tool
failure, and guards every parse of a payload it did not construct. Through the
facade, escalate: true becomes a task in state failed rather than a completed
one.
Also corrects this file: the earlier note said the run completed in success
with every node executing. It did not — that was the wrong execution. n8n's
executions list did not return them newest-first as assumed, and the conclusion
drawn from it sent the search to the wrong place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The adapter runs in the agents namespace with the other agents, not with n8n:
what it is, is an agent, and the workflow behind it is an implementation detail
its callers never see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things were wrong, and the second hid the first.
n8n does not re-register an active workflow's definition on PATCH: the running
webhook kept executing the version it was activated with. So the corrected
subject_token expression was never running, and the Keycloak V1 NullPointer it
kept producing was the old empty-token failure — including through the attempt
to capture the request with an echo service, which the old definition never
called. Deactivate, apply, reactivate; written down in workflows/README.md
because it costs an afternoon otherwise.
The token reference itself: $json is the previous node's output, not the
request, so the subject token has to come from $('Webhook').
The regex is now loose after the underscore, so a reference that looks valid but
does not exist reaches the catalog instead of being answered with 'you named no
product', which was false.
Working for the main path and for a question naming no product. A nonexistent
code still returns an empty body — recorded as a known gap, not as acceptable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The values that infra-n8n deliberately does not carry: our hostname, issuer,
Vault path and images, plus the two things that are ours alone — the lab CA the
workflows need to verify internal TLS, and the agents' Keycloak client secrets,
read from the realm's own Vault entry so there is one source per secret.
Self-managing after three applies, like the rest of the estate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The adapter is written once and parameterised, so both n8n agents get the same
A2A surface and differ only in the workflow behind the webhook. It owns the
protocol and nothing else: no Keycloak client, no credentials, and the end
user's token forwarded in a header so a workflow that logs its input does not
log the token with it. escalate: true fails the task rather than completing it,
because an autonomous agent meeting a rule that needs approval must hand it
back, not decide.
Serving this from the gateway was the repo's stated preference and does not
reach: A2A returns a Task with a lifecycle, and a proxy cannot synthesise that
from a webhook's single reply.
The check_availability workflow does the token exchange itself, using the
agent's client secret from the pod environment so the committed JSON holds no
secret, then talks MCP in two calls — initialize to obtain the session id from
a response header, then tools/call. It reads the product code with a regex
rather than a model: this agent's critical path is rules, and a question naming
no product escalates instead of being guessed at.
MCP rather than the REST route, which would have been one HTTP call instead of
two: the per-tool ACL by consumer group lives on the MCP route only. Going
through REST would give an autonomous agent broader access than a conversational
one, which is exactly backwards.
Status: the adapter is tested (13 tests) and the identity chain is verified by
hand, but the workflow fails at its token exchange and is not yet working —
see the note in workflows/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design agreed in discussion, nothing built yet. Records the agent's role, its
scopes and MCP servers, its A2A surface, and the framework choice with its
reason — so the next session starts from the decisions rather than re-deriving
them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>