Contract changelog¶
What changed for anyone who wrote code against funduq, newest first.
The question this answers is "does my transport still work" — which commit
subjects, however well written, cannot. Each entry names one contract
revision, recorded in
contract-vectors.json's contract block together
with a fingerprint of the surface at that revision. A test recomputes the
fingerprint, so a change to the surface cannot land without a bump and an
entry here: writing the line is a condition of a green suite rather than a
courtesy someone remembers.
The surface is what an outside implementation would have to change its
own code to keep up with: the vectors themselves, CoreSettings fields and
whether each is required, the provider link's verbs, every crossing shape
funduq-contract defines with its field aliases, the A2A protocol version
and transport bindings, the AG-UI protocol version, whether each package ships its PEP 561 marker, and
every public name funduq_contract exports with the shape it exports it
as. Prose and internals are not in it — see funduq/tests/contract_surface.py, which
reads every part of it live rather than from a copy.
This started before anything was published, which is the point: it is a
record from the beginning rather than one begun after the first person was
hurt. Revisions 1–5 predate any release. From funduq-contract 0.0.1
onwards a stranger can install a version and be wrong about it, so the
entries below say what to change and not only what moved.
Revision 25 — 2026-10-12¶
An unanswered offer fails the run (#288). A provider that misses the delivery
window (deliver_timeout_seconds, 5s by default) used to have the run taken back
and offered again. Now the run fails with provider_did_not_answer, and the miss
still counts against the provider's unanswered. The same applies when handing
the offer over raises. An interjection follows the same path, and a run already
asked to cancel still ends cancelled.
Migration for a provider: if you took a run and lost your answer, you used to get the run offered again and could accept it again. That no longer happens. Answer inside the window, which is an intake decision and never waits on running anything. A declined offer is unchanged: it still goes back to the queue.
Revision 24 — 2026-10-12¶
AG-UI 1.0 (#290). All three packages now require ag-ui-protocol>=1.0,<2.
Before this they had a lower bound and no upper bound, so a fresh install
already resolved 1.0. The protocol a run input speaks was never in the surface,
so nothing recorded that change. The AG-UI protocol version is in the surface
now, beside A2A's.
What an agent provider receives changes in two ways:
protocolVersion: "1.0"is on every run input, and on theRUN_STARTEDfunduq writes for a run nobody is serving. 1.0 makes it a producer's MUST, and funduq produces both. TheRUN_STARTEDan agent writes is the agent's to version.- An optional field with no value is absent, never
null. That is 1.0's rule, and the 1.0 models already serialise that way. ARunAgentInputwith noforwardedPropsarrives without the key. AG-UI 0.1's model required that key, so a provider still validating against 0.1 refuses the run.
An AG-UI caller may now leave out tools and context, which 1.0 made
optional. The AG-UI door used to fail on such an input.
Migration for a provider on funduq-provider-sdk: upgrade it, and AG-UI 1.0
comes with it. A provider implementing the link itself moves to AG-UI 1.0. Note
that 1.0 removed THINKING_* outright, so an agent still emitting those events
must switch to REASONING_*.
Revision 23 — 2026-09-11¶
The vectors publish only what a signature covers (#282). contract-vectors.json
had three kinds of entry and one was not like the others: the six payload
families and the actor-chain hop form are bytes a signature is computed over,
where being wrong by one colon means a signature does not verify — and two
wire entries, delivered-run and delivered-completion, which nothing
signs. What those pinned was how a model is spelled on a wire, down to the
capitalisation of its field names.
That is framing, and revision 11 is where framing stopped being this package's: the frame vocabulary and its codec left on the reasoning that a transport carries these and a transport decides how. The two envelopes were the part of that decision that did not leave.
They had also drifted from the models they claimed to publish. Shape
configures the way in symmetrically and the way out not at all, so
DeliveredRun(...).model_dump() returns run_id while the published frame
said runId. A reader who trusted the vector reached for by_alias=True to
make its own model agree — and passing dump flags by hand is how the wrong
one gets added: exclude_none=True strips forwardedProps, which is
legitimately null and required, turning a good run into a permanent
refusal rather than a loud error.
- The
wiresection is gone. Nothing else in the file moved. The sixvectorsand the onechainsentry are unchanged, byte for byte. - The rule the file keeps now: an entry exists because getting it wrong fails a signature check. A test asserts the file's own top-level shape, so an unsigned section cannot quietly return.
- Field aliases are still contract. They are declared on the crossing
shapes, exported from
funduq-contract, and still part of the fingerprinted surface. What changed is that they are offered rather than mandated: the models validate under either spelling, and no published artefact says which one a wire must use.
We did not take the other available fix. Setting serialize_by_alias=True
on Shape would have made model_dump() match the old vectors in one line
and spared every downstream a flag — and would have settled naming for every
transport there will ever be, from a package that gave that decision away
two years of revisions ago.
Migration: nothing, unless you were reading the wire section. If you were,
your wire's field naming is yours to state; model_validate accepts both
spellings, and model_dump(by_alias=True) still produces the camelCase form
if that is what your transport carries — now because your transport says so,
not because a vector did.
Revision 22 — 2026-09-08¶
Core does not decide who may read (#275). Revision 21 named the line in
its own entry below — "Reads take a key, acts take a signature … cancel
and resolution keep their signed proofs: they are acts on one run" — and
then had reads answer to the record of acts. A chain records that something
happened and who answers for it. A read makes nothing happen and adds no
hop, so it is not the chain's to rule on.
Who may look is an authorisation policy, and authorisation sits at the wire,
in a deployment of any size owned by a different team from the one embedding
this library. Core keeping a second copy meant one rule with two owners, and
core's won silently: a denied read returned [] or None, which is what
"there is nothing there" also looks like, so a serving layer that widened
its own rule was overruled without a signal. There was no way to widen it
either — the only lever a door held was which key it passed, so the one
available extension was to pass a key already on the chain, which is to
impersonate a party.
Funduq.as_readerand theReaderclass are gone. The record's own reads are the surface:get_thread_messages,get_run,get_run_events,lineage, and nowroot_runs,task_messagesandsubscribe, which existed only onReaderbefore. None of them filters.Funduq.parties_of(thread_id)is the answer. The head, the provider serving the agent, every key on the thread's runs' chains — orNonefor a thread nobody bound, which names no parties at all. Deriving it stays core's alone: the chains are here and only here are they verified. What follows from it is the transport's.reader=is gone from the A2A door.get_task,resubscribe_taskandlist_tasksno longer take it, andA2ARequestHandlerno longer feedspresenter_key_ofinto reads.presenter_keystays on the write path, where it is checked against a chain's last hop — an act.list_tasksstill needs acontextId, unchanged: holding the id is what makes a thread's tasks addressable. What changed is that naming them is now all this door decides.
Migration: a serving layer that wants revision 21's rule keeps it, in one
place of its own — parties_of returns the same set the gate used to
compute. A deployment that had no seat in front of its doors now serves
reads to whoever reaches them, so the seat is not optional any more; #275
records the argument and what it costs.
Revision 21 — 2026-09-07¶
Reading the record is one surface, and its reader is a key (#264).
Several reads were one mechanism at different doors with the check applied
at some and not others: GetTask demanded a view proof, the provider link's
thread_messages and the facade checked nobody, so a provider holding any
thread id read any thread, bound or not.
Now core exposes the read once — Funduq.as_reader(key) gives a Reader
with thread_messages, run, events, lineage, task_messages,
subscribe — under one rule: a thread nobody bound is readable by whoever
holds its id; a bound thread by its parties (the head, the provider serving
its agent, every key on its runs' chains); to anyone else it does not exist.
Every entrance translates into that surface. How the transport established
the key is its business.
FunduqLink.thread_messagesis gone. The provider link is the provider's job — handshake, registration, offer, verdict, reports, finish. Reading the record is every party's, so it does not ride the provider's private channel. In process the provider callsas_reader(its key)on theFunduqit shares; over the wire the serving layer exposes the read surface as an endpoint authenticated with the link's key. Transports implementing the link drop the method;InProcessLinkalready has.- Reads take a key, acts take a signature.
GetTaskandSubscribeToTasktakereader— the key the transport authenticated — in place of a view proof; the A2A handler'sview_metadata_ofhook is gone,presenter_key_ofserves reads and writes alike.verify_viewandauthorize_viewleave core;view_payloadstays in the contract as a payload a transport may have a reader sign to establish its key for one read.cancelandresolutionkeep their signed proofs: they are acts on one run, bound to it against replay. - A chain needs an authenticated presenter. A door handed
actorChainwith no presenter key refuses withPresenterRequired— a new error, distinct fromInvalidChain: nothing the caller sent is wrong, the transport has not said who is at the door. A deployment that authenticates callers changes nothing; one that does not has declared it does not accept chains, and nothing half-works. Map it to "authentication required", not "bad request". Task.historyis the task's. The lineage's own messages, not the whole context's.RunHandle.cancel()isasyncand goes throughcancel_run.
Migration: transports drop thread_messages from their link and hand a key
to presenter_key_of for reads; callers that presented chains anonymously
must be authenticated by their transport.
Revision 20 — 2026-09-07¶
Metadata levels correspond. Revision 19 translated A2A's Message.metadata
into AG-UI's forwardedProps — a message-level bag into a request-level
one. The levels line up differently: A2A's SendMessageRequest.metadata is
the request's bag, as forwardedProps is on RunAgentInput; A2A's
Message.metadata is the message's, as AG-UI's newer revisions put a
metadata field on every message.
- The caller's bag — its declarations to funduq (
actorChain,kyok,resolution, the interjection target) — is request-level on both doors:forwardedPropson AG-UI, the request'smetadataon A2A. funduq reads nothing from a message's own metadata. - A2A
Message.metadatabecomes the AG-UI message'smetadata, carried to the agent on that message and stored with it in the thread. AG-UI 0.1.20's models keep it as an extra field; the field is in AG-UI's current spec. - The interjection extension's key (
<uri>/addressedRunId) moves from the message's metadata to the request's, with the rest of the bag.
Migration for an A2A caller: put chain, KYOK opt-in, resolution proof and
interjection target in the request's metadata, not the message's. An
AG-UI caller changes nothing.
Revision 19 — 2026-09-07¶
A run is an AG-UI run (#259). funduq had two models of a run stacked
on each other: AG-UI's, where every RunAgentInput is a run, and A2A's
task, which pauses in input-required and continues under the same id
when the answer arrives. The run followed the task — a paused run was
reopened with the answer's input, on both doors — and the runs table
followed neither cleanly: the delivered input as one blob beside columns
duplicating it, a metadata bag holding the caller's words and funduq's
observations together, a protocol label saying which door opened it.
Now there is one model, AG-UI's. Every input is a run. A run that
finishes asking — an interrupt outcome, a tool call nobody answered — is
completed, as AG-UI says; the answer is the next run, with parentRunId
naming the one that asked. Nothing is reopened; input-required is not a
run status any more. Whether a thread is waiting is read from its latest
run's events. An A2A task is the lineage those parentRunId links form:
the task id is the root run's id, its state is the tail run's, and every
event of a later run reaches the A2A client labelled with the task's id.
What a caller does differently
- AG-UI: declarations ride in
forwardedProps.actorChain,kyok(opt-in and context),resolution— the things a caller says to funduq — go in the run input'sforwardedProps, which is AG-UI's own field for a caller's bag. ThemetadataextensionRunAgentInputnever had is gone. The bag is relayed to the agent as it was sent, with funduq's one key (forwardedProps.funduq) added; so an agent now also sees the caller's declarations, verbatim, beside funduq's. - A2A:
Message.metadatais that same bag, translated toforwardedPropsone-to-one. Nothing else changes on the wire. The resolution proof is signed over the task id (the lineage's root) and the open ask ids, which is what a caller holds; ask ids are new for every pause, so the binding is still to one ask. - A message naming a task (A2A §3.1.1, §3.4.3): answers it when the
task is waiting for input — the one follow-up the spec defines; is
refused with
UnsupportedOperationErrorwhen the task has ended, as the spec requires; and is refused likewise when the task is still working, where the spec defines nothing and funduq no longer invents an ordinary next run. Continue a conversation withcontextIdand notaskId. - Cancelling a task that is waiting for input now lands: nobody is
working on it, so the cancel closes the wait — the task reads
CANCELED, funduq records who asked, and no answer lands on it afterwards. It used to be refused as not cancelable. - Subscribing to a task that has ended is refused (
UnsupportedOperationError, §3.1.6);GetTaskis the read for its outcome.historyLength: 0now means no history, as §3.2.4 says (the field's presence is read). agent_offlinenow also leaves a terminalRUN_ERRORin the run's events, not only on the caller's stream.
What a provider sees differently
DeliveredRun and the link are unchanged. An answer arrives as a new run
whose parentRunId names the run that asked, under the answerer's own
chain (forwardedProps.funduq.actorChain), not the asking run's; the
caller's declarations are in forwardedProps beside funduq's key. The
delivered-run frame in the vectors is unchanged in shape.
The record. runs is RunAgentInput one column per field plus funduq's
state (status, the agent, actor_chain, cancel_requested_by,
timestamps); input_json, metadata, head_key and protocol are gone,
and so is the input-required status. thread_messages says who said each
message (origin: caller or agent). RunRecord follows. Pause details
and failure reasons are read from run_events; answeredBy is the answer
run's own row. Alembic revision a1f4c9d27e3b; rows are not migrated
(nothing is published, and a restart already voids held runs).
Revision 18 — 2026-09-07¶
One key for what funduq adds. funduq used to scatter its own
additions across the namespaces it writes into: kyok, actorChain and
addressedRunId at the top of forwardedProps, beside the caller's
keys; interrupts, pendingToolCalls and failureReason at the top of
a run's metadata, with answeredBy / cancelRequestedBy already under
funduq; agui_event, agui_events, interrupts and
funduq/cancelRequested at the top of an A2A task's or status update's
metadata. Three surfaces, three conventions, and a reserved-key list
that had to be maintained by hand — and a caller who typed
forwardedProps.addressedRunId themselves was indistinguishable from
funduq having verified one (found on #256).
Now there is one key, funduq, on every surface, and it is funduq's
unconditionally: written when funduq has something to say, absent when
it does not, never the caller's value.
forwardedProps.funduqcarrieskyok,actorChain,addressedRunId. A caller's ownforwardedProps.funduqis never relayed; every other key reaches the agent untouched.- Run metadata
funduqcarriesinterrupts,pendingToolCalls,failureReason,answeredBy,cancelRequestedBy. It is the only caller-supplied key the doors strip (RESERVED_METADATA_KEYSshrank to it);failureReasonoutside it is the caller's own word and stays. - A2A
metadata.funduqon tasks and status updates carriesagui_event,agui_events,interrupts,cancelRequested(previouslyfunduq/cancelRequested).
Migration for an agent: read the grant at
forwardedProps.funduq.kyok, the chain at forwardedProps.funduq.actorChain,
the declared interjection target at forwardedProps.funduq.addressedRunId
(funduq_provider_sdk.KyokForwardedProps is unchanged; the SDK runtime
reads the new location). For an A2A client reading funduq's metadata:
one level down, under funduq. The delivered-run wire frame in the
vectors shows the new layout; DeliveredRun and every other shape are
unchanged.
Revision 17 — 2026-09-03¶
A verdict cannot be outrun (#249). A provider that accepted an offer
and streamed its first events in the same breath had those events refused
by funduq's own door: the verdict rode deliver's return value while the
events walked in through report_event, and nothing ordered the two
roads — core released its "yes" to the provider before recording it for
itself. Revision 11's rationale for withdrawing the protocol machines
("over a wire the provider-initiated calls are plain request/response,
so there is nothing left for a machine to order") missed exactly this
happens-before, which lives in no frame.
The fix removes the second road instead of patching the race: everything a connection says about a run — the verdict included — enters through a door and is judged by the run's one owner in arrival order.
FunduqLink.deliver(and core'sConnectedProvider.deliver) narrows to handing the offer over: its return no longer carries the verdict.- The verdict goes through a new door,
answer_offer(run_id, answer, provider_key=...)—True/False/ aRefusal, the unchanged vocabulary — called in the same handler that learned it, before the frames behind it are processed. A transport never waits, retries, or knows when funduq's bookkeeping lands. report_event/finish_runnow mean "attributed and posted": they returnFalseonly for an unknown run. Whether the reporting key holds the run is judged by the run's owner; a misattributed report is dropped and logged, not answered synchronously.
The wire frames are untouched — Offer and Verdict cross exactly as
before; what moved is where the verdict enters core. Migration for a
transport: in the handler that reads the verdict frame, call
answer_offer instead of resolving deliver's return, and delete any
retry you carried for the "nobody holds" window — the window is gone.
Revision 16 — 2026-09-03¶
A resolve proof signs the ask it answers (#236). resolve_payload
takes the paused run's outstanding ask ids (its interrupt / tool-call
ids) instead of a timestamp; the signed bytes are
funduq-resolve:{run_id}:{sha256 of the ids, sorted and NUL-joined},
with canonicalization inside the builder and nowhere else. The wire
proof shrinks to {publicKey, signature} — no timestamp rides in it and
the 60-second freshness window no longer applies to resolve, because
instance binding replaces the clock: a later pause has new ids, so the
proof never verifies against any ask but the one it was signed for, and
replay against that ask is consumed by the status-guarded reopen.
Why this and not a content hash: message-content integrity is the channel's job everywhere in this system (utterances carry no signature at all), so signing one message kind's content would buy an inconsistent guarantee at the price of cross-transport canonicalization. Binding the instance is the authorization-level fix, and ids are opaque strings — nothing to canonicalize but order.
Migration: a signer now signs what it is answering —
sign_resolution(run_id, ask_ids) in the provider SDK, where ask_ids
is everything the pause said it was waiting on. Cancel and view are
unchanged (still the timestamp family). Resolve was the one act where
replay changed what happened; it is now the one act that cannot be
replayed at all.
Revision 15 — 2026-09-03¶
The session delegation certificate is removed (#237). delegation_payload
leaves funduq_contract (the funduq-delegate tag with it), sign_delegation
leaves the provider SDK, and core no longer reads metadata.delegation
anywhere: a chain's head is the key that signed hop zero, a
cancel/resolve/view proof counts for exactly the key that signed it, and
nothing is relayed under forwardedProps.delegation any more.
Why: this repo does no wire, and the wire side is where policy lives. Everything else in core's identity machinery records facts and demands proof from the keys those facts name; the certificate was the one piece that manufactured authority ("B counts as A until T") — an unscoped grant, which is policy, and any delegation policy is implementable entirely at the authenticating seat, which holds the keys and decides which one signs. Nothing in any production flow ever issued one.
An external implementation that sent metadata.delegation must now sign
with the durable key directly (or have its gateway do so). One that never
sent it changes nothing. The dead SESSION_TOKEN_TTL_SECONDS constant
left funduq.identity in the same sweep.
Revision 14 — 2026-09-02¶
The broker's three waits are CoreSettings fields (the last live item
of #181's adopter review). unserved_timeout_seconds (45.0),
deliver_timeout_seconds (5.0) and undelivered_window_seconds (1800.0)
join settings, and RunBroker's keyword defaults are the same single
definitions, so a broker built bare and one built by Funduq agree.
Defaults unchanged; an embedder that never touches them sees no difference.
CoreSettings.from_env is removed, and with it the FUNDUQ_*
environment surface. Configuration is an argument: core reads no
environment at all, and a deployment that keeps configuration there reads
it itself and constructs CoreSettings with the values. An embedder that
called from_env now writes that read out — the test suite's own
conftest.py is the pattern.
Revision 13 — 2026-09-04¶
Reading a bound run requires a view proof (closes #148's exposure).
view_payload(run_id, timestamp) joins the singular-act family under its
own tag, funduq-view — same shape as a cancel or resolve proof:
{publicKey, timestamp, signature}, the 60s freshness window, delegation
honoured. The A2A door's get_task and resubscribe_task demand one for
any run whose thread is bound to a chain, and answer its absence as "not
found" — existence is part of what is guarded. Unbound runs stay as public
as their funduq-minted ids.
The read circle is wider than the act circle: every actor on the run's
chain may sign a view — the parties responsibility flowed through may look
— while cancel and resolve stay with the head and the serving provider.
A2A's read requests carry no caller data, so the proof rides the transport
(a header, typically); A2ARequestHandler accepts it through the
view_metadata_of hook.
Revision 12 — 2026-09-02¶
A provider's interjection capability is discoverable (closes #146, unblocked
by #166's fix in revision 11). Registration gains takesInterjections,
derived by the runtime from the presence of its interject_stream hook —
never typed by an author, so the declaration cannot lie. funduq writes it
onto the stored agent card as the interjection extension's URI, and the A2A
card announces it in the standard slot: capabilities.extensions, one
AgentExtension with
https://github.com/hukaichun/funduq/ext/interjection/v1.
The card declares understanding, not acceptance: whether an interjection is taken is still the verdict's call at delivery time. A registration that omits the field behaves exactly as before.
Revision 11 — 2026-09-02¶
Every crossing shape now lives in funduq-contract, defined once, and the frame vocabulary, both sans-io state machines and their codec are withdrawn. The provider link is the settled design this implements.
If you implemented against revision 10:
- Import the shapes from
funduq_contract:Connect/ConnectOk/ConnectErr(the opening exchange),OfferandVerdict,CancelandAck,Complete/Chunk/CompletionEnd/CompletionFailed,DeliveredRun,DeliveredCompletion,Registration,Refusal.funduq_provider_sdk.protocolandfunduq_provider_sdk.llm.protocolare gone, machines and all — over a wire the provider-initiated calls are plain request/response, so there is nothing left for a machine to order. FunduqLinkchanged:delivernow receives the publishedDeliveredRunitself (the claimed-run translation is gone, and so isofferas a separate verb), andcancelisasync, returning an acknowledgement that the ask arrived — never an outcome.ConnectedLLMProvider.completereceivesDeliveredCompletion; core'sCompletionRequestand the SDK'sfrom_requesttranslation are gone. A completion body is validated as OpenAI's own request shape, with extension keys allowed through verbatim.register_agentstakesRegistrationmodels end to end and returns a plain{name: AgentRef}mapping, mirroringregister_llm_providers.- The verdict vocabulary is unchanged (accepted / declined / refused),
but a late answer is no longer a path:
accept_late_ackand theanswered_latecounter are gone. Missing the delivery window is breakage, answered by a fresh offer of the same run. - The conversation gate moved from claim to finish. One thread has one active run; the next utterance is offered when the turn ends. The only bypass is a declared interjection whose named run is the thread's claimed head; naming anything else is rejected at the door, and a declaration whose target settles before delivery degrades to an ordinary next turn.
- Field-list constants are withdrawn (
DELIVERED_RUN_FIELDS,REGISTRATION_FIELDS,CONNECTED_PROVIDER_ATTRS,LINK_REPORT_METHODS,LINK_QUERY_METHODSand the llm equivalents): the models are the single definition, so there is nothing left for the lists to police. funduq-contractnow depends onpydantic,ag-ui-protocolandopenai— types, never a client, and still no I/O anywhere.
Revision 10 — 2026-08-27¶
abandon is withdrawn, one revision after arriving. It is the only change.
Revision 9 added a frame for funduq telling an LLM provider that its caller
had stopped consuming. Nothing asked for it. Core has no such verb —
ConnectedLLMProvider is public_key and complete, with no cancel — so it
served a case no caller could reach, and the only code that spoke it was the
test written alongside it.
If you implemented against revision 9: stop sending abandon, and expect
never to receive one. Nothing else moved, and a completion still ends in
exactly one of completion.end, completion.failed or a lost link.
The gap it was aimed at is real and stays open, recorded in #220 rather than answered here: a provider whose caller has gone keeps producing into nobody. That belongs with reconnecting, replaying, and relaying a cancel that arrived during an outage — one question about a party that is no longer there, not four, which is exactly why answering the last of them alone produced a frame that had to be taken back.
The lesson, since this is what the file is for. A protocol verb with no caller is not harmless surface. It ships, it goes in the fingerprint, it costs a revision to add and another to remove, and for the window in between an implementer in another language has to decide whether to speak it.
Revision 9 — 2026-08-27¶
The completion half gets the same treatment, and the shared opening is shared rather than restated.
-
funduq_provider_sdk.llm.protocolcarriesFunduqLlmSideandProviderLlmSide. Both kinds of work are answered with a stream — a run's answer is its events and then its finish — so that is not what separates them. Two things do. A run is admitted first: an offer is answered three ways before any output exists and the next utterance of that conversation waits until the run is claimed, while a completion is assumed taken and can only fail afterwards, so this half has no three-valued ack and no delivery deadline. And a run outlives any one offer of it, which is why its output is addressed byrunIdwhile a completion's chunks are addressed by the request's own id. A completion is open until exactly one ofcompletion.end,completion.failedor a lost link. -
abandonis new, and it closes a hole that had no name. When a KYOK caller stops consuming, an in-process handler hearsGeneratorExit; over a wire nothing reached the provider at all, so it went on generating into a consumer that had gone. A driver sendsabandonwhen its stream closes. Core needs no new verb for it —ConnectedLLMProviderhas none to add. -
Two frames revision 8 shipped as loose dicts are now typed, and both are breaks against revision 8. They were the same mistake the machines exist to remove, made one level down: a shape whose field names were already published —
REGISTRATION_FIELDS,LINK_QUERY_METHODS— with nothing checking them.register.agentsis a list ofRegistration(name,description,agentCardExtra,metadata) withextra="forbid". A misspelt key used to travel intact and be dropped in silence by core, whose reader cannot tell a missing field from a typo (agent.get("description", "")).REGISTRATION_FIELDSis now read off the model rather than typed out beside it.AgentHandle.as_registration()still returns a dict and still works — it is validated on the way into the frame.queryis gone; the one query it carried is its own frame,query.thread_messageswiththreadIdandlimit. Amethodstring and a bag ofargsmeant a transport writingargs["thread_id"]by hand, which is how a field name goes wrong silently. A second query kind is a second frame, and adding one moves this fingerprint — which is the right price.
-
Registration is per link kind, not shared.
register.llmcarriesnamesand onemetadatadocument; the agent link'sregistercarries agent records. They looked like one frame until the LLM roster's metadata had nowhere to travel. If you built against revision 8'sregisterfor an LLM link, sendregister.llminstead — nothing else about it moved. -
The handshake is now stated once for both link kinds, in
FunduqLinkMachineandProviderLinkMachine. Nothing about it changed; what changed is that a fix to it can no longer land in one copy and not the other. Deleting and querying stay shared too, because those shapes really are the same.
For an implementation in any other language: the agent link is untouched.
The LLM link's frames above are new surface, and register.llm is the one to
notice if you had assumed the two links published their rosters the same way.
Revision 8 — 2026-08-27¶
The link's state machine ships as code, in funduq_provider_sdk.protocol.
Nothing already on the wire changed shape; what is new is that the orderings
around it are no longer only prose.
-
A published frame vocabulary. Four classes — handshake (
Connect,ConnectOk,ConnectErr), request carrying anid(Offer,Query,Register,Delete), reply carrying the request'sid(Ok,Err), and notify carrying none (Report,Finish,Cancel). Every frame is a pydantic model with camelCase aliases, so the mapping is written once rather than once per transport, andOffer.runnests the delivered-run envelope unchanged. -
ConnectcarriesmaxConcurrentRuns. It had nowhere to travel before: core needs the figure to schedule against, in-process read it off the runtime, and the vocabulary had no field for it. Declared at the open rather than with a registration, because it is a property of the party on the other end and not of any agent it publishes. -
Two sans-io machines,
FunduqSideandProviderSide: frames in, frames and events out, no I/O and no clock — time enters asnowand leaves asnext_deadline(). What each one refuses is now a transition rather than a paragraph: a first frame that is notConnect, a secondConnecton an open link, a second answer to one offer, an answer to an offer never made. -
The dump rule, stated once because it is two rules. A frame is dumped
by_alias=Truewithoutexclude_none, and a typed AG-UI event is dumped with it. Both halves are load-bearing and they pull opposite ways:RunAgentInputhas required fields that are legitimatelynull—state,forwardedProps— so stripping nulls from a frame produces arunInputthe far side cannot rebuild, and a perfectly good run comes back as a permanent refusal; while leaving them in an event injectstimestamp: nullandrawEvent: nullinto a caller's stream. They lived in different paragraphs ofwriting-a-transport.mdand never met until one function had to do both. -
An offer is legal before a registration has been answered, on both sides. Neither machine holds a registration state, which is not an allowance but an absence: core's roster goes live and nudges the broker before
register_agentsdoes its write and commit, which on Postgres is a network round trip.
For an implementation in any other language: nothing you already send or parse has changed, and no vector moved. The frames above are new surface you may adopt or ignore — the machines are a Python convenience, and the wire they speak is now written down. If you do adopt them, the two dump rules are the part to copy exactly.
For a Python transport: FunduqLink is unchanged and still supported. It
remains the right surface for provider authors; a transport can now mount
ProviderSide instead of subclassing it.
Revision 7 — 2026-08-26¶
The types reach the signatures, and the surface reaches the Python API that had been breaking outside it.
-
dispatch_hoptakes aDispatchTarget. It used to takeprovider_keyandagent_name— two adjacent, same-typed, interchangeable strings. Handing them over backwards signed happily and verified happily; what broke was the honest provider extending the chain, refused with a message naming the innocent successor.DispatchTargetalready existed and stopped six lines short of the function people call. Changedispatch_hop(key, chain, provider_key, name)todispatch_hop(key, chain, DispatchTarget(provider_key=…, name=…)). -
verify_chainhands back the hops it parsed.ChainResult.hopsis a list ofHop, each withactor_public_key,prev_hashand a typeddispatched_to;actor_public_keysis now derived from it and every existing use of it,headandpresenteris unchanged. This is what makes the checkmechanisms/actor-chain.mddescribes — pin funduq's key, require a hop of funduq's on the path — writable without decoding a JWT by hand. Ten call sites in this repository were doing exactly that, which is not an inconvenience: it is the posture that produced the revision 6 bug. -
Hop.is_witnessis gone, one revision after arriving. It was never used, and its own docstring warned against the reading it invites. Whether a hop is a witness's is decided byactor_public_key— a key the reader pinned — so the honest predicate reads two fields, and a lone bool answers the question that must never be asked alone. Writehop.dispatched_to is not None and hop.actor_public_key == pinned_key. -
A witness appears in a chain only as a witness. Revision 6 allowed the hop after a dispatch to be signed by the dispatching key, for a witness re-offering work nobody took — and re-offering is another dispatch, but the code accepted any hop that key signed, including a plain one. Nothing outside funduq can reach that (it needs funduq's key), so this closes no hole; it moves the code's edge back to where the sentence always said it was. A chain your implementation used to accept may now be refused: a plain hop signed by the key that dispatched immediately before it.
-
funduq_contract's exported signatures are in the surface now. They were not, which is why the break below shipped described as no break at all. Wire compatibility is not the whole of the contract when one of the four distributions is the contract.
Revision 6 broke Python callers and said it did not. Its entry reads "chains you were building are unaffected", which is true of every byte on the wire and false of anyone's code:
sign_hop's third parameter went fromdict[str, str] | NonetoDispatchTarget | Nonein 0.0.3, so a call written against 0.0.2 raisesAttributeError: 'dict' object has no attribute 'provider_key'from inside a function the caller never wrote. Corrected here rather than edited into that entry, and the surface widened above so the next one fails a test instead of a stranger.
For an implementation in any other language: one rule changed — a plain hop signed by the key that dispatched before it is now refused. The vectors are otherwise untouched, and every other item above is Python only.
Revision 6 — 2026-08-26¶
The rule revision 5 announced is now the rule that runs. 0.0.2 shipped it bypassable, and this is the correction.
- A hop cannot excuse itself from the dispatch check. As shipped, the
check skipped itself whenever the next hop carried a
dispatchedToof its own — meant to allow one dispatch following another, but the field is written by the party being checked, so the rule was opt-out. A branching party added the field and passed. A malformed value did worse: it slipped the check and cleared the pending dispatch, so the hop after it went unchecked too.
The rule now reads: the hop after a dispatch must be signed by the provider it named, or by the same key that signed the dispatch. A witness may offer the same work onward because it is the witness — whose key signed the hop — not because the hop says so about itself. Both bypass shapes are pinned by tests, and a chain ending at a dispatch nobody answered stays legal.
If you implemented against 0.0.2: chains you were building are unaffected
unless you were relying on the escape, and a verifier that accepted a party
hop carrying its own dispatchedTo after somebody else's dispatch was
accepting a rewritten chain.
Revision 5 — 2026-08-26¶
A chain that was rewritten to leave someone out is now refused, and the number an installed package answers with is the number the vectors record.
verify_chainreadsdispatchedTo. funduq signs one hop per dispatch naming the agent it sent the run to, and an agent is(provider_key, name)— so that provider key is exactly the key that signs the next hop when the provider extends honestly. The field has existed for as long as the hop has and nothing read it, so the property it gives (a rebuilt chain contradicting itself) was available rather than enforced: a probe performed the comparison and the verifier did not. A chain your implementation used to get away with may now be refused — specifically one where a party hop follows a dispatch hop naming somebody else.
Two shapes stay legal and are pinned by tests: a chain that ends at a dispatch nobody answered (the named party declined to extend, which is a break rather than a defect), and a dispatch following a dispatch (the same work offered onward without the first party signing).
This rule did not hold as shipped — see revision 6. The second allowance was implemented by skipping the check whenever the successor carried a
dispatchedTo, which the party being checked writes. Anyone who wanted past it added one. If you implemented against 0.0.2, the refusal described above is not what you got.
-
The chain funduq stores is the chain it dispatched. It used to store what the caller presented while the agent received that plus funduq's own hop, so funduq's records could not tell a run it had dispatched from one that reached it having passed no witness at all. Nothing on the wire changes for a provider; what changes is that the record now says which of the two it was.
-
A resume relays the run's own chain. It used to relay the answering party's, with a fresh dispatch hop signed over it — so a provider that resolved its own agent's ask sent that agent a chain headed by the provider itself on the second round. One delegation now has one witness signature, and what an agent verifies does not change because somebody else answered its pause.
-
CONTRACT_REVISIONwas 3 while the vectors said 4. Revision 4 is the entry that introduced the constant and it left the constant behind, and no test compared the two: the constant's value is in the fingerprint, so changing it forces a bump, but nothing required the bump to land on the same number. An installed package therefore answered one behind the vectors it was written against — the exact question the constant exists to answer. Both are 5 now, and a test holds them together. -
The recorded pin was wrong in this file. Revision 4 says
funduq-contractis pinned>=0.1.0,<0.2; the declared bound is and was>=0.0.1,<0.1. The line is corrected below rather than quietly, because a changelog that misquotes a dependency bound is worse than one that omits it.
For an implementation in any language: no byte on the wire changes and the vectors are untouched. What changes is that a verifier which accepted a dispatch hop followed by the wrong signer was not implementing this contract, and now has a test to say so.
Revision 4 — 2026-08-25¶
An installed package can now say which revision it implements, and the distributions carry the metadata a stranger needs.
-
funduq_contract.CONTRACT_REVISIONis a constant. Package versions and contract revisions answer different questions — a version says which release of one distribution you have, a revision says which set of bytes, settings and ports all of them agree on — and until now nothing installed could answer the second. It is part of the surface, so cutting a revision cannot forget it. -
funduq-contractis pinned rather than named.funduqandfunduq-provider-sdkasked for it with no bounds at all, which would let a future incompatible release install itself under an old dependant. It is the one distribution both sides depend on, so every version skew this project can have runs through it. Now>=0.0.1,<0.1— this line said>=0.1.0,<0.2, which was never the declared bound; see revision 5. -
License, readme, classifiers and project urls are declared on all three. Without them PyPI shows a blank page and "License: UNKNOWN", which for an enterprise reader is where evaluation stops.
For an implementation in any language: nothing here changes a byte on the wire. The vectors are untouched.
Revision 3 — 2026-08-25¶
funduq-llm-provider-sdk is gone; serving completions is the llm extra
of funduq-provider-sdk.
What to change: pip install funduq-provider-sdk[llm] instead of
funduq-llm-provider-sdk, and import from funduq_provider_sdk.llm instead
of funduq_llm_provider_sdk. Nothing else moved — the same classes, the same
names, the same wire shapes.
The two were 685 lines and 197, and the smaller imported nothing from the
larger but ProviderIdentity: identity is identity, whichever kind of
provider holds it. What kept them apart was dependency weight, which is the
same argument that shaped everything else here — and an extra answers it
better than a distribution does. openai drags httpx, anyio and a handful
more, and an agent provider still does not pay for any of it: the extra is
opt-in, and funduq_provider_sdk.__init__ does not import the subpackage, so
the import cost is opt-in too.
It also settles a naming problem rather than solving one. With two packages, the agent one silently owned the unqualified word "provider" and a reader had to infer that. With one, "the provider SDK" covers both kinds honestly, and the distributions line up by role: contract, core, provider, and — when it exists — caller, which are the codebase's own words for the two sides of a door.
This does not touch funduq's own separation of the two rosters. An LLM provider still registers separately and is judged separately; that is core's business, and this is a client library.
Revision 2 — 2026-08-25¶
A fourth package, funduq-contract, holds the bytes both sides sign.
Core and the provider SDK each carried their own copy of the six signing
payloads, the actor-chain format and signature verification — the same
concepts under two sets of names (resolve_payload on one side,
resolve_payload on the other). There is one implementation now, and both
depend on it.
For an implementation in Python: import the payload builders and
verify_chain from funduq_contract. The six *_signing_payload names are
gone, along with verify_actor_chain, new_actor_chain,
extend_actor_chain and InvalidActorChain — the SDK's shorter names won,
because in a package called contract the word "signing" was saying what
"payload" already said. Core and the SDKs re-export the survivors, so
from funduq.identity import resolve_payload still works.
For an implementation in any other language: nothing changes. The vectors are unchanged, and they are still the authority.
The duplication had a recorded justification: it once caught a payload
change 219 green tests had missed. That win is real and it is historical —
it happened six hours before contract-vectors.json existed, and a frozen
vector catches that same class against a single shared implementation.
Checked rather than assumed, by changing a domain tag in the one
implementation and watching test_every_published_vector_is_what_this_
implementation_computes go red.
funduq-contract is now part of the contract surface, which is why this
revision exists at all: adding a package that implementers depend on is a
contract change, and the fingerprint said so before anyone had to remember
to.
Revision 1 — 2026-08-25¶
The first recorded revision. It is not a description of an empty starting point: three contract changes landed just before it, and the reason this file exists is that they were exactly the kind an adopter could not track.
-
A chain hop carries no time.
iatandexpare gone from an actor-chain hop, which is now{actorPublicKey, prevHash}and nothing else, and neither verifier checks an expiry. Both the chain vector and thedelivered-runwire frame were regenerated. An implementation that signs hops must stop stamping time; one that verifies must stop enforcing it. (#180) -
A dispatched chain carries one more hop. funduq signs the dispatch it makes and names where it went, as
dispatchedTo: {providerKey, name}on its own hop. A provider therefore receives its caller's hops unmodified plus funduq's, so a chain arriving at an agent is one longer than the one the caller sent. An implementation that compared the received chain to the sent one for equality must compare a prefix instead. (#184) -
identity_private_keyis required. It had no default before only in the sense of being optional; a funduq without one now fails to construct. A deployment must supplyFUNDUQ_IDENTITY_PRIVATE_KEY— and the same key across restarts and across every process of one funduq, because providers pin it. (#184)
Also in this revision, changing nothing for an existing implementation but worth knowing:
- A door can be told who is presenting.
presenter_keyon every door, defaulting toNone; when supplied it must equal the chain's last-hop signer. Omitting it changes nothing, which is why this is not a break. (#184) - The packages ship their types. A PEP 561 marker in each, so an
integrator's type checker stops seeing
Any. (#187) - The metadata passthrough is stated as a promise: everything outside the reserved keys is relayed verbatim. Behaviour unchanged; it was true before and now cannot quietly stop being. (#185)