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>