Commit Graph

28 Commits

Author SHA1 Message Date
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
Stéphane Tailland 0847e11713 Gate the n8n trigger endpoints on Keycloak
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>
2026-08-31 07:56:13 +02:00
Stéphane Tailland 06d0ab5e2d Wake Approvisionnement on a stock event instead of a clock
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>
2026-08-31 07:46:24 +02:00
Stéphane Tailland 2bb6faa121 refactor: an autonomous run is a machine identity, not a mandate
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>
2026-08-29 17:51:18 +02:00
Stéphane Tailland 8f1f0245da feat: the offline_access mandate
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>
2026-08-29 17:42:33 +02:00
Stéphane Tailland 06c102fb72 feat: the periodic reorder proposal, and what it cannot do
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>
2026-08-29 17:33:54 +02:00
Stéphane Tailland 95b6e3a1c9 docs: the events are emitted by the API, and what that implies
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>
2026-08-29 13:49:18 +02:00
Stéphane Tailland 18a8bf38a7 docs: separate the two triggers, and put Kafka in the backlog
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>
2026-08-29 13:46:49 +02:00
Stéphane Tailland f49f3ec360 fix(workflow): answer when the product does not exist
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>
2026-08-29 13:40:19 +02:00
Stéphane Tailland 666735b0eb feat: deploy the Approvisionnement facade
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>
2026-08-29 13:25:35 +02:00
Stéphane Tailland 562987cd69 fix(workflow): reach the catalog, and answer
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>
2026-08-29 13:24:44 +02:00
Stéphane Tailland f9511fd15d feat: this estate's n8n deployment
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>
2026-08-29 13:10:10 +02:00
Stéphane Tailland d9c376facf feat: an A2A facade for the n8n agents, and Approvisionnement's first workflow
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>
2026-08-29 13:07:02 +02:00
Stéphane Tailland 411086b0b6 Add implementation instructions
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>
2026-08-26 15:14:41 +02:00