Skip to content

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 the RUN_STARTED funduq writes for a run nobody is serving. 1.0 makes it a producer's MUST, and funduq produces both. The RUN_STARTED an 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. A RunAgentInput with no forwardedProps arrives 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 wire section is gone. Nothing else in the file moved. The six vectors and the one chains entry 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_reader and the Reader class are gone. The record's own reads are the surface: get_thread_messages, get_run, get_run_events, lineage, and now root_runs, task_messages and subscribe, which existed only on Reader before. 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 — or None for 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_task and list_tasks no longer take it, and A2ARequestHandler no longer feeds presenter_key_of into reads. presenter_key stays on the write path, where it is checked against a chain's last hop — an act.
  • list_tasks still needs a contextId, 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_messages is 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 calls as_reader(its key) on the Funduq it 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; InProcessLink already has.
  • Reads take a key, acts take a signature. GetTask and SubscribeToTask take reader — the key the transport authenticated — in place of a view proof; the A2A handler's view_metadata_of hook is gone, presenter_key_of serves reads and writes alike. verify_view and authorize_view leave core; view_payload stays in the contract as a payload a transport may have a reader sign to establish its key for one read. cancel and resolution keep their signed proofs: they are acts on one run, bound to it against replay.
  • A chain needs an authenticated presenter. A door handed actorChain with no presenter key refuses with PresenterRequired — a new error, distinct from InvalidChain: 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.history is the task's. The lineage's own messages, not the whole context's.
  • RunHandle.cancel() is async and goes through cancel_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: forwardedProps on AG-UI, the request's metadata on A2A. funduq reads nothing from a message's own metadata.
  • A2A Message.metadata becomes the AG-UI message's metadata, 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's forwardedProps, which is AG-UI's own field for a caller's bag. The metadata extension RunAgentInput never 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.metadata is that same bag, translated to forwardedProps one-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 UnsupportedOperationError when 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 with contextId and no taskId.
  • 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); GetTask is the read for its outcome. historyLength: 0 now means no history, as §3.2.4 says (the field's presence is read).
  • agent_offline now also leaves a terminal RUN_ERROR in 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.funduq carries kyok, actorChain, addressedRunId. A caller's own forwardedProps.funduq is never relayed; every other key reaches the agent untouched.
  • Run metadata funduq carries interrupts, pendingToolCalls, failureReason, answeredBy, cancelRequestedBy. It is the only caller-supplied key the doors strip (RESERVED_METADATA_KEYS shrank to it); failureReason outside it is the caller's own word and stays.
  • A2A metadata.funduq on tasks and status updates carries agui_event, agui_events, interrupts, cancelRequested (previously funduq/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's ConnectedProvider.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 / a Refusal, 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_run now mean "attributed and posted": they return False only 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), Offer and Verdict, Cancel and Ack, Complete / Chunk / CompletionEnd / CompletionFailed, DeliveredRun, DeliveredCompletion, Registration, Refusal. funduq_provider_sdk.protocol and funduq_provider_sdk.llm.protocol are 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.
  • FunduqLink changed: deliver now receives the published DeliveredRun itself (the claimed-run translation is gone, and so is offer as a separate verb), and cancel is async, returning an acknowledgement that the ask arrived — never an outcome.
  • ConnectedLLMProvider.complete receives DeliveredCompletion; core's CompletionRequest and the SDK's from_request translation are gone. A completion body is validated as OpenAI's own request shape, with extension keys allowed through verbatim.
  • register_agents takes Registration models end to end and returns a plain {name: AgentRef} mapping, mirroring register_llm_providers.
  • The verdict vocabulary is unchanged (accepted / declined / refused), but a late answer is no longer a path: accept_late_ack and the answered_late counter 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_METHODS and the llm equivalents): the models are the single definition, so there is nothing left for the lists to police.
  • funduq-contract now depends on pydantic, ag-ui-protocol and openai — 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.protocol carries FunduqLlmSide and ProviderLlmSide. 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 by runId while a completion's chunks are addressed by the request's own id. A completion is open until exactly one of completion.end, completion.failed or a lost link.

  • abandon is new, and it closes a hole that had no name. When a KYOK caller stops consuming, an in-process handler hears GeneratorExit; over a wire nothing reached the provider at all, so it went on generating into a consumer that had gone. A driver sends abandon when its stream closes. Core needs no new verb for it — ConnectedLLMProvider has 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.agents is a list of Registration (name, description, agentCardExtra, metadata) with extra="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_FIELDS is 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.
    • query is gone; the one query it carried is its own frame, query.thread_messages with threadId and limit. A method string and a bag of args meant a transport writing args["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.llm carries names and one metadata document; the agent link's register carries agent records. They looked like one frame until the LLM roster's metadata had nowhere to travel. If you built against revision 8's register for an LLM link, send register.llm instead — nothing else about it moved.

  • The handshake is now stated once for both link kinds, in FunduqLinkMachine and ProviderLinkMachine. 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 an id (Offer, Query, Register, Delete), reply carrying the request's id (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, and Offer.run nests the delivered-run envelope unchanged.

  • Connect carries maxConcurrentRuns. 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, FunduqSide and ProviderSide: frames in, frames and events out, no I/O and no clock — time enters as now and leaves as next_deadline(). What each one refuses is now a transition rather than a paragraph: a first frame that is not Connect, a second Connect on 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=True without exclude_none, and a typed AG-UI event is dumped with it. Both halves are load-bearing and they pull opposite ways: RunAgentInput has required fields that are legitimately null — state, forwardedProps — so stripping nulls from a frame produces a runInput the far side cannot rebuild, and a perfectly good run comes back as a permanent refusal; while leaving them in an event injects timestamp: null and rawEvent: null into a caller's stream. They lived in different paragraphs of writing-a-transport.md and 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_agents does 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_hop takes a DispatchTarget. It used to take provider_key and agent_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. DispatchTarget already existed and stopped six lines short of the function people call. Change dispatch_hop(key, chain, provider_key, name) to dispatch_hop(key, chain, DispatchTarget(provider_key=…, name=…)).

  • verify_chain hands back the hops it parsed. ChainResult.hops is a list of Hop, each with actor_public_key, prev_hash and a typed dispatched_to; actor_public_keys is now derived from it and every existing use of it, head and presenter is unchanged. This is what makes the check mechanisms/actor-chain.md describes — 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_witness is 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 by actor_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. Write hop.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 from dict[str, str] | None to DispatchTarget | None in 0.0.3, so a call written against 0.0.2 raises AttributeError: '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 dispatchedTo of 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_chain reads dispatchedTo. 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_REVISION was 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-contract is 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_REVISION is 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-contract is pinned rather than named. funduq and funduq-provider-sdk asked 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. iat and exp are 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 the delivered-run wire 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_key is required. It had no default before only in the sense of being optional; a funduq without one now fails to construct. A deployment must supply FUNDUQ_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_key on every door, defaulting to None; 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)