Skip to content

Design records

The other four chapters say what funduq does. This one says why it is shaped that way — including the shapes it had first and stopped having.

Every entry here was argued from something that happened: a probe that returned the wrong answer, a bug that reached a caller, a measurement taken before a rewrite. This page is the record now: the design/ working notes it was distilled from have been removed from the tree, and each entry links to the section it came from at the commit before the removal. Those links are pinned to a commit, so they keep resolving; nothing new will be written there.

Four kinds of record: rules that shipped and whose reasoning is easy to undo by accident; assumptions funduq rests on and cannot enforce; designs settled but not built; and decisions that were made, measured, and reversed.

Rules that shipped

A silent hop is priced, not compelled

A provider that forwards an actor chain without extending it produces a chain that still verifies — it has only erased itself from the path. funduq does not force anyone to sign, because funduq never decides on a provider's behalf. Enforcement belongs to the chain's consumer, whose policy knows the expected call graph. In KYOK that consumer controls the money: an agent whose chain does not match gets no completions. Signing is not compelled, it is priced.

The condition in that sentence is load-bearing and was read as universal for too long. A price needs a consumer whose policy knows the expected call graph and who controls something worth withholding. KYOK has one. An ordinary delegation tree does not: a receiving agent that sees [A] cannot tell whether a hop is missing, so there is no price, and silence is free. Two things narrow it further. The general case is not self-erasure — a party can erase someone else between the head and itself, and the result reads exactly like a chain that party was never on (see A chain proves origin, not possession). And under the presenter check, forwarding a chain whose tail is not yours stops being available at all: not priced, gone. The honest options — extend, or break — both stay free.

See Actor chainfull record

The verifier chooses the freshness

Registration and deletion sign over a timestamp checked against a 60s window, which is enough for operations that are idempotent or singular. Connect authentication is deliberately not in that family. A signature whose only liveness is a self-chosen timestamp is replayable for the whole window by anyone who observed it, and observers are not exotic — enterprise proxies terminate TLS on the path, which is also why channel binding was ruled out. This exact hole shipped twice: once in funduq's own early gateway (#44), once in an integrator's transport built from the only worked example then visible (#75). Hence the challenge-response: the verifier contributes the nonce.

See Identityfull record

A chain hop carries no time

An actor-chain hop used to carry iat/exp, with expiry enforced on the last hop (300s). That single field was doing two incompatible jobs — bounding replay of a presented chain, and bounding how long an authorization stays true — so every value was wrong for one of them: lengthen it and a captured chain stays usable, shorten it and work that pauses on a human dies. The field is removed rather than split.

The reason it can be removed is not that something else took over the window. It is that core cannot answer the question the window was pretending to answer. A verifier sees bytes; it cannot tell a signature made now from one replayed by whoever held the chain — and providers hold it by design, since the chain reaches them verbatim in forwardedProps. Liveness needs a live channel, so it belongs to the authenticating seat in front of the door, never to core. A timestamp at core's layer is core pretending to check something it cannot see. This is the same lesson as The verifier chooses the freshness, applied one layer up: there the fix was to let the verifier contribute a nonce; here the verifier has no live channel at all, so it stops claiming to check.

Consequences, stated plainly so nothing rests on a misreading: a chain does not expire, and possession of one is not evidence its presenter is its head. A chain proves origin, not possession. Core's caller doors are therefore not independently safe — a deployment must put an authenticating seat in front of them (see the operational limits page), and removing the TTL did not open that hole, it stopped disguising it: a provider replaying a caller's chain was already unstoppable inside the window, and the window's only real effect was to make the gap look guarded.

Two things also fall out: the hop format loses its last non-key field, so "the chain is only keys" is now literally true; and funduq signing its own dispatches — parked because funduq's hop would have been the stale last hop — is unblocked.

See Actor chain

A chain proves origin, not possession

A chain proves the head's key signed hop zero. It does not prove that whoever presents it now holds that key, and the two were treated as one thing for as long as a door accepted a chain on the strength of its signatures alone.

The gap is not theoretical and not narrow: funduq relays the chain to the serving provider verbatim (forwardedProps.actorChain) so the agent can verify for itself, which means the provider holds, in full, the thing a door reads to decide authority. It can present it back for work the caller never asked for; the run is accepted under the caller's head key, and the two rows are identical in every field that carries authority (probe_a_provider_can_speak_as_the_caller.py, red on purpose).

The hop expiry removed in A chain hop carries no time did not open this. A provider inside the old 300s window could always do it; the window's only real effect was to make the gap look guarded — which is why removing it was the honest move even though the hole stayed.

The fix is a comparison, not an authentication. funduq cannot authenticate a presenter — a door receives bytes, not a connection — but the seat in front of it can, and hands in the key it authenticated; funduq refuses a chain whose last hop was signed by anyone else. Authentication needs a live channel and stays outside. The comparison belongs in core, tested once and pinned by vectors rather than reimplemented per deployment: comparing the chain's head instead of its last hop passes this exact attack, and an integrator building from the only worked example has reproduced a replay hole here before (#75).

Nothing is compelled by it. No key handed in means no head recorded and an unbound run, which is what a run carrying no chain already is; a provider that extends honestly still passes, because it signed the tail. What is refused is a claim the presenter cannot back. The standing consequence is a deployment invariant, not a setting: core's caller doors are not independently safe.

A second gap sits beside it and this fix does not touch it: verification proves nobody was inserted, reordered, or spliced in, never that nobody was removed. A party holding caller → A → B can rebuild it as caller → B from hop zero's own token, forging nothing (probe_a_chain_can_be_branched.py). A branching party does not lie about who it is, so the presenter check passes it. Only funduq signing each dispatch with the agent it dispatched to makes a presented chain contradict itself: an agent is (provider_key, name), and that provider key is exactly the key that signs the next hop when the provider extends, so funduq's hop and its successor can be checked against each other. And funduq keeps no chain today, only the head key, so the erasure is also unnoticeable afterwards.

See Actor chain

Wrapping an unknown event in RawEvent is quiet corruption

Measured against the installed ag-ui-protocol: an unknown event type is rejected (the Event union is discriminated on an enum), an unknown field is preserved (extra='allow'), a default dump injects timestamp: null and rawEvent: null, and a dump with exclude_none=True is byte-identical to the input. So the risk is narrower than "reparsing rewrites things", and only the first row is a real hazard: a provider running a newer AG-UI than funduq must not have its run broken.

RawEvent cannot paper over it. Its type is a hard-coded Literal[EventType.RAW], so wrapping an unrecognized event changes what the caller sees from the real new event type to RAW — not faithful relaying, and worse than passing the event through untouched. That rejection is the durable part of this record: the obvious mitigation makes funduq lie about what the provider said.

What shipped is narrower. handlers._handle_relay validates each event against the discriminated union and forwards cmd.event — the original mapping — rather than a re-dumped model, so no timestamp: null is ever injected into a caller's stream, and funduq reads only the fields it decides on (type, and the interrupt outcome for pause detection).

The first row is closed (#116), by a three-way rule. An event whose type funduq's pinned AG-UI knows is validated strictly, and a failure still ends the run — as does an event with no type string at all: both are malformation, not version skew. An event whose type is a string funduq does not recognise is relayed untouched — stored and forwarded, never branched on — because whether to skip it is the caller's decision (AG-UI's fail-open rule; A2A's spec likewise says implementations SHOULD ignore unrecognized fields), never the relay's. The precedent is protobuf's own reversal: proto3 shipped with unknown fields dropped and 3.5 reversed it, because dropping silently destroyed data passing through every intermediary that parses and re-serializes — exactly the seat funduq occupies. Unknown enum values and unknown oneof branches are preserved through a protobuf relay too, and an unknown event type is the same shape. The discipline that keeps this from becoming dict soup: funduq never branches on an unrecognized event's content — it may only store and forward it; peeking is reserved for strictly-validated known types.

See The dispatch trunkfull record

What A2A cannot say lands in one named place, exhaustively

AG-UI says more than A2A can hear. Tool calls, reasoning, state patches, step boundaries — a third of AG-UI's event types have no A2A representation at all, because A2A's agents are black boxes by construction and those events describe an inside.

A translator therefore has to decide what happens to them, and there is no neutral answer available: dropping them is a disclosure decision, and forwarding them is a disclosure decision. The official @ag-ui/a2a converter drops silently (ag-ui#1938, #2091); funduq forwarded verbatim. Neither is a bug — both are guesses standing in for a declaration that neither protocol has a field for. AG-UI never needed one (one owner, so there is no "to whom"); A2A never needed one (nothing gets out).

funduq does not make that decision either, and the reason is the invariant: who may read what through funduq is enforced outside funduq, by whoever actually holds that responsibility, and what funduq owes them is a record they can attach to. So the rule is not a policy but a location: every event with no A2A representation is carried verbatim under one metadata key — agui_event on a status update, agui_events on a whole task — and nowhere else.

The load-bearing property is that the seam is exhaustive, because a filter can only attach to something it knows is complete. A route that leaks by another path, or drops instead, is silently invisible to the layer that is supposed to be deciding. test_every_ag_ui_event_type_is_mapped_or_reaches_the_overflow_seam walks EventType and fails when a new AG-UI type escapes both routes.

Two paths had disagreed. The live stream carried the overflow; GetTask merged text deltas and dropped everything else, so the auditing reader saw less than the live subscriber — backwards, for the one reader whose whole purpose is the record. build_task now carries the same overflow the stream does.

A provider is its key, and has no other id

Registration once carried an sdk_client_id, a string the client picked for itself. Two things were measured before removing it. Two unrelated keypairs picking the same string were both accepted, and the second one's session token claimed the first one's run, received its input, and could report events into it. And two processes of one real identity could not share their own work, because the SDK mints a fresh string per process and registration overwrites the column.

It was neither an identity nor a usable per-process label. What genuinely needs "which connection" — delivering a cancel to a live stream — needs no id in the protocol at all: every connection of that provider is asked, and the one without the run ignores it.

See Identityfull record

Liveness stopped being an inference

Under the claiming model, asking for work was the liveness fact, and there was deliberately no heartbeat. Nothing asks now, and funduq does not need it to: it holds the provider object, so RunBroker.serving(agent) is a fact rather than a deduction. This was measured before it was changed — with online still derived from last_seen_at, an attached provider that had just completed a run was reported offline sixty seconds after attaching.

See The dispatch trunkfull record

Silence about a verdict funduq has reached is a bug

failed used to be recorded and never told to anyone: a provider whose run_stream raised produced an HTTP 200 whose event stream closed in 0.1s having emitted nothing, which a client cannot distinguish from an agent with nothing to say. funduq now emits a terminal RUN_ERROR in exactly that case, persisted as well as relayed.

It is neither a protocol deviation nor a decision on anyone's behalf: RUN_ERROR is AG-UI's own terminal event, the verdict is already funduq's and already in the database, and an agent that reported its own failure is left alone. cancelled still gets nothing — there is no cancelled event to send, and the only party who would read it is the one who asked for it.

The rule reaches runs the broker no longer tracks, too (#127): a stale pause reaped as paused_no_resume, or an orphan reaped at startup as orphaned_by_funduq_restart, has no pipeline left to push a Fail through — so the same terminal RUN_ERROR is appended to the record directly. There is no subscriber to relay it to (the paused round closed normally; the orphan's subscriber died with the previous process), which is exactly why the persisted record owes the verdict: it is the only place a later reader can learn how the run died, and an event stream that ends on a question while the database says failed is the silence this record exists to forbid.

See Runs and cancels are requestsfull record

Conversation naming rights wait for a caller to own them

The two caller doors answer an unknown thread id differently — AG-UI mints a new thread under funduq's own id, A2A raises ThreadNotFound — and the asymmetry turned out to be each protocol's own grammar: A2A's spec assigns contextId server-side, while AG-UI's threadId is required and client-chosen. What is not protocol grammar is the decision underneath, and it comes from ownership (#117).

AG-UI's client-minted threadId is safe in AG-UI's native habitat because the whole loop has one owner: the same party runs the frontend and the agent backend, so a client naming a thread is an owner naming its own thing. funduq breaks that premise — caller and provider are two different owners with a relay between them. Adopting a caller-chosen name into the agent's global thread namespace would let one party write names into another's space: guessable, collidable, and today a thread id still acts as a capability (the open contradiction below), so a caller who picks "test" has minted a skeleton key others can guess.

The rule that shipped: funduq mints every thread id, on both doors, and the id in funduq's reply is authoritative — it rides on every returned event, so the substitution is visible, not silent. The caller's naming rights are real, but a name needs something to scope it to, and funduq has no caller identity yet (an acknowledged open front). The clean end state, once it exists: caller-chosen ids become caller-scoped aliases — your trip-planning and mine are two threads, collision and hijack impossible by construction, and rule zero holds because the alias names without authorizing. Until then, adoption into the global namespace is refused as borrowing safety that isn't there; ThreadOwnershipMismatch (a thread belongs to its agent) stays the only cross-party guard. The same decision fixed create_if_missing silently dropping parent_thread_id.

See The integration contract → issue #117

Every seam has exactly two entrances: an utterance, or a result

At the user–agent seam the inputs are a human's words or a deferred tool call's result; at the agent–agent seam they are an agent's words or a deferred tool call's result. The seams are symmetric — only the speaker differs — which is why funduq's machinery never needs to know whether the far side is human: what needs to distinguish humans is the responsibility layer (who may supply a result), not the mechanism.

The deferred call is where human-in-the-loop lives. When an agent needs a person, the protocol shape is an ask left pending (input-required is that state; A2A's elicitation drafts are the same thing named) — and "whose human may answer, and what proves it" is exactly where a responsibility chain binds. The taxonomy sorts everything this page already records:

  • A plain message and a declared interjection are both utterances — the interjection flag is timing intent on an utterance, not a third entrance.
  • The reply lane delivers a result: it lands on the thread's pending ask (status-guarded, so concurrent replies resolve to one winner), and funduq never judges whether it satisfies the ask — the asker does.
  • The buffer bound (thread_queue_limit) applies to utterances only, and the reply lane's exemption stops being a special case: results drain a pending ask; they never pile new input, so refusing one would strand the ask it answers.
  • A result with no pending ask is refused (the AG-UI door returns the thread's state), never repackaged as an utterance. This clause was earned by a probe: a resume payload sent at a thread with nothing paused used to slip in as a fresh run, handing the agent an answer to a question it never asked. Over A2A the case cannot arise — a result there is a plain message plus addressing, so when it lands on no ask it simply is an utterance, and degrading it is honest.
  • Cancel is outside the taxonomy by intent: it is a control request about a run, not input to anyone's reasoning, and must not be cited as precedent for a third entrance.

Current granularity: one pending ask per run, addressed by the run's own id. If asks ever need to be several-at-once (the shape A2A's elicitation draft sketches), results address individual ask ids — a widening of the result lane, not a new entrance.

The taxonomy is also the razor for reading upstream proposals: anything that would create a third entrance is suspect. So far nothing does — elicitations are deferred calls, timeline mid-task messages are utterances.

Interjection is a declared intent, and its target's agent is the judge

An interjection is a run that asks to join another run's turn already in flight. Two things about that sentence are load-bearing and each was learned the hard way.

It is declared, never inferred. Continuation ("this follows that, next turn" — AG-UI's own parentRunId, relayed untouched) and interjection ("this wants into that turn now") are different verbs, and the target's liveness must not be used to guess which one the caller meant — a continuation sent while its target happens to still be running is not an interjection. The caller states the intent explicitly: over A2A via the interjection extension (A2A's own extension convention — the target's id in the message's metadata under https://github.com/hukaichun/funduq/ext/interjection/v1/addressedRunId; v1.0's only mid-task verb is CancelTask, the v1.1 drafts cover only solicited input, and this key yields to whatever official carrier lands), over AG-UI by writing forwardedProps.addressedRunId directly. funduq copies the declaration into forwardedProps.addressedRunId and does nothing else with it.

Its target's agent is the judge. Whether the named run still has a turn to join is a fact only the party running it holds; a relay-side answer is a stale copy by construction — funduq#136 was exactly that staleness caught on the wire, and the fix-of-the-fix kept shrinking until the honest shape appeared: funduq holds no opinion. The agent compares one string against its own in-flight loop: still open → absorb if it chooses; already ended → an ordinary next turn. Degradation needs no machinery, no capability signal, and no re-offer protocol. (The previous shape — an envelope annotation, a broker in-flight predicate, a one-shot decline flag, and strip-on-fallback — went down with the thread gate; see the reversal record.)

An agent that absorbs can mark the absorption point in its own stream with an event of its own naming — the unknown-event rule (store and forward, never branch) relays it verbatim, which is exactly the provider-authored timestamp a coherent cross-party history needs and the relay could never honestly write itself.

Quoting a third-party package is the shortcut to staying current, so the defense forbids verbs, not nouns

Every protocol funduq ever hand-wrote rotted silently: it was still answering tasks/send and emitting {"type": "text"} parts two spec renames after both had moved, and nothing failed until a real client got -32601. The cure was importing the protocol's own package — method names read off the A2AService descriptor, shapes from a2a.types — so a rename breaks at import instead of in production. Quoting the package is the shortcut that keeps funduq current with the protocol.

The old defense taxed exactly that shortcut. It banned dependency nouns — seven package names in an import scan, plus a pyproject rule that no transport be "even installable" — which was fiction from the day a2a-sdk was installed: its base wheel ships httpx unconditionally, so the tree was never network-free, and the rule's only real effect was making the SDK's pure vocabulary (error tables, apply_history_length, the extension header convention) feel out of bounds when it is precisely the part that must be imported rather than transcribed.

What the invariant actually protects is a verb: this repo implements no transport — funduq's own code neither listens nor dials. Measured before rewriting: importing a2a.utils.errors, a2a.utils.task and a2a.extensions.common performs zero socket operations and pulls in zero transport modules; even a2a.server.tasks.task_manager (useful as a conformance oracle in tests) imports httpx into sys.modules without a single socket verb. A noun in a wheel is not a verb in our code.

So the defense now has three layers, each aimed at the verb (tests/test_core_is_network_free.py):

  • Behavioral — a subprocess imports every funduq module under a sys.addaudithook watching socket.connect/bind/listen/ getaddrinfo/…; any network verb during import fails the suite, whoever's code performed it.
  • Static, ours only — funduq's modules may not import a transport or a protocol SDK's I/O half (a2a.client, a2a.server.routes, a2a.server.request_handlers, bare openai); the vocabulary half (a2a.types, a2a.utils, a2a.extensions, openai.types) is welcome. The line falls where the SDKs themselves draw it.
  • Intent — no serving framework as a direct dependency, and no serving extra (a2a-sdk[http-server] etc.) requested: a framework in our pyproject would say this repo intends to listen.

This is a strengthening, not a loosening: the noun scan could never see a dependency dialing out on its own, the audit hook can.

Four signature families were re-proving what the handshake already established

Adding a tenth signed payload family prompted an inventory of the nine already there, and two of them turned out to sign less than they looked like they did: a registration covered the names and not the agent card, so a captured signature could re-register the same names with a card of the attacker's choosing.

But the interesting question was why those families existed. Because there was no session — every roster operation re-proved I hold this key with a self-chosen timestamp, since nothing remembered it was already proved. It was already proved: attach ran a real handshake with a funduq-issued, single-use, consumed nonce and then trusted the connection object for its whole life. That is a session in everything but name, used only for serving runs.

And the two halves were already one act split in two. attach refused names that were not registered ("register before attaching, in-process or not"), so the order was forced; register already had a live side effect, withdrawing names omitted from the batch, so it was already half an attach.

What replaced them:

  1. A ticket, over a channel that is not the link, naming the key it admits. Issuing is the admission decision — a key with no ticket cannot connect at all — which funduq had no equivalent of. It cannot be fetched over the link: that would mean the link existed before anything authorised it, so core keeps the verb off the link's operation set.
  2. The handshake spends the ticket — matching the named key before destroying it. The old code popped the nonce before checking the signature, so anyone who had merely seen a live challenge could burn it with garbage and leave the provider unable to connect. That needs no key at all.
  3. funduq answers with its own signature, unchanged. It is the half that protects the provider, and the reason funduq has a keypair.
  4. Registering and deleting are operations on the open link, unsigned. Nothing signs the card because there is no detached signature left to splice one onto.
  5. Not registered is offline. A link serves exactly what it last published.
  6. An agent with a thread behind it cannot be deleted — one guard where there were three. "A provider is serving it" cannot block when the caller is that provider.

Ten families became six; the identity and roster code lost 49 statements net. The names field left the connect payload too: it was there so a captured proof could not be replayed to serve a different agent, and a single-use ticket naming one key cannot be replayed at all.

The cost is stated rather than hidden: the ticket store and the live roster are both one process's memory. That was already true; this makes it total and explicit, and sharing it is the horizontal-scaling question, not this one.

funduq#170.

Stopping someone else's run was the one right nobody had to prove

Writing a bound thread became a membership act and answering its ask took a signature, and cancel was left outside both. The reason it was left outside is worth keeping, because it was a good reason applied to the wrong question: the two-entrance taxonomy deliberately puts cancel outside, since it is neither an utterance nor a deferred result. But that is about entrances to a conversation, not about rights. A stranger stopping your run is the same family as a stranger writing into your thread, and the run id — an identifier, and never a credential — was the only thing it took.

The authority set is the one an ask on the same run would have: its segment head and the agent's own provider. Not a new rule, the same question asked twice — who does this run's segment answer to? The proof is a signature over funduq-cancel:{run_id}:{timestamp}, its own tag so a resolution can never be spent as a cancel, and possession of a private key rather than a chain hop anyone downstream could replay.

Two things checked rather than assumed. The issue said A2A's cancel had nowhere to carry a proof, having no message; the descriptor says CancelTaskRequest carries metadata, so a standard client has the slot and nothing is invented. And the guard sits where the resolution guard sits — at the door, shared — so Funduq.cancel_run asks the same question as the A2A adapter rather than being the way around it. That made the facade method async, which is the honest cost of it having a question to ask.

An unbound run stays anyone's to stop. The mechanism is opt-in by carrying a chain, like every other part of it: a thread that named no authority at birth has none to check against, and inventing one would make funduq the authority instead of the caller.

funduq#151.

Saying it in a vocabulary the other side has is not the same as lying

funduq will not record an outcome it has not observed, and A2A has no state for "asked to stop, still running" — because in A2A's own server the agent is in the same process and can simply be killed (ActiveTask.cancel() cancels the producer, calls AgentExecutor.cancel(), waits for the task to finish, and forces CANCELED on anything still non-terminal). funduq's provider is on somebody else's laptop.

The first answer to that mismatch was to say nothing: cancelling had no entry in the status table, so it fell through to TASK_STATE_UNSPECIFIED and serialised as an empty status. That was read as modesty and it was not. A client got neither "your request landed" nor "something is still running" — and a2a-python's own bug report for the equivalent case (a2aproject/a2a-python#1170) puts the complaint exactly: callers are left polling something that never resolves.

working is what funduq says now, plus a metadata marker naming the pending request. working is true — the run has not stopped — and it is a word A2A has. Translating into the other side's vocabulary is not lying; asserting CANCELED would be, because it names an outcome funduq has not seen and the provider's own output may contradict. Sending nothing is not a smaller claim than working; it is an unreadable one.

Two details the shape turns on. The marker rides on the request, not on the record: the run's own lane writes cancelling a moment after the door asks, and a caller answered inside that window would read a plain working and learn nothing. And a run that has already ended raises TaskNotCancelableError rather than returning the finished task, which is the same unreadable answer in a different disguise.

funduq#149.

Assumptions funduq cannot enforce

An offer's answer is a receipt, and arrives promptly

funduq holds the next utterance of one conversation until the previous one's answer lands. That is the only thing that can say which of two offers came first: an offer is an independent call carrying no position, the transport contract promises no ordering, and funduq could not define an order to promise anyway — two offers it issues concurrently reach the wire in whatever order their own work finishes.

That is affordable only if the answer is a receipt: whether the run arrived, whether there is room for it, whether its input is valid. All three are known the moment it lands and none is a question for the agent. The provider SDK's runtime answers exactly that way — ProviderRuntime.deliver has no await in it at all, pinned by a test that drives the coroutine one step — but a third-party transport is free to do otherwise, and funduq cannot make it.

So this is an assumption, stated rather than enforced. It is written into FunduqLink.offer and into the transport guide; nothing checks it across a wire funduq does not own.

What a violation costs, exactly: a transport that answers only once the agent has started delays the next utterance of that one conversation by the agent's startup time. Nothing else — other threads, other agents and other providers hand over meanwhile. The blast radius being one conversation is why the assumption is acceptable rather than reckless.

What notices: an answer that never arrives inside the delivery timeout (5s) counts unanswered against the provider, and the quality allowance eventually withdraws it. Between "instant" and that timeout there is a band where a transport answering from the agent degrades one conversation and nothing complains. Closing that band means a second, much tighter clock, and this repository has been wrong about clocks before — silence was read as death — so no clock has been added without a measurement asking for one. The lever, if one is ever wanted, is the delivery timeout, which today is sized for unreachable rather than for receipt.

Designed, not built

Rule zero: identifiers are never credentials

Identifiers are immutable. thread_id is woven into history, lineage links and A2A task references, and can never change. Credentials must be rotatable, revocable, replaceable. Therefore nothing whose only quality is being known may authorize anything: a leaked thread_id would be a permanent skeleton key with no remediation path, not even a bad one. Under this rule thread_id becomes a pure name that may appear in logs, trees and task references, because knowing it grants nothing.

Today's funduq does not yet work this way — see Open contradictions below.

See Responsibility chainsfull record

Anonymity means the key is unlinked, not that there is no key

Three binding tiers share one mechanism — signature verification against a key registered at thread creation — and differ only in what the key is bound to. An identity: a long-lived keypair carried down every extend-edge. A thread and nothing else: the client SDK generates a throwaway keypair per thread and registers the public half automatically, which rotates (old key signs new), revokes, and cannot be correlated across threads. Nothing: a bare standard client registers no key and core treats the thread as public.

Presenting a key stays opt-in, so no standard client is forced to deviate.

See Responsibility chainsfull record

One question per delegation edge decides the whole tree

Before wiring its agent to another agent, a provider owes itself one question: if the sub agent gets stuck or fails, can I carry on without it? Yes breaks the chain, and that is the default — an undeclared edge breaks it. The subtree becomes the provider's own implementation behind its A2A opacity: its human resolves the subtree's interrupts, its KYOK offering funds them, the subtree is invisible in the caller's thread tree, and if the subtree fails the provider's run fails in the provider's name. Suppliers are trade secrets and their failures are your failures, both from the same declaration. No extends the chain, and the escalation path stays connected.

Bundling intervention rights, cost and visibility is what makes the bit incorruptible. Authority alone, an agent would claim opacity while spending the caller's key; cost alone, it would pass the bill while hiding the work. "The user pays but may not look" and "the provider looks but does not pay" are both structurally unexpressible. And because miscalibrated confidence is billed — declare absorb and fail, and the failure lands on your run, your stall record, your invoice — the resulting break-point topology is an honest map of every provider's declared competence boundary.

Later refinement (2026-08-21): the declaration dissolved into the act. Asking why a break edge would carry signatures onward — what would that guarantee? — showed the answer is nothing: everything extension buys (escalation path, answering rights, visibility) is exactly what break refuses, and carrying the chain through a break would also advertise the user's head to subcontractors the user never chose. So there is no per-edge flag to declare: extending the chain is the extend declaration, and silence is the break — the default falls out for free, and segment boundaries are derived from where signatures actually reached rather than registered anywhere. Funding was split off into its own topic and is no longer part of the bundle this record describes.

See Responsibility chainsfull record

A user is a position, and a chain is only keys

The question that blocked responsibility chains longest was "how is a user's identity defined?" — and the settled answer is that it isn't, separately: funduq has one identity primitive (a keypair, no accounts, existing by being seen) and "user" is that primitive standing at the head of a responsibility segment. The position is symmetric: a main agent signing the first hop of a fresh chain when it delegates heads that sub-segment by the same machinery a human heads theirs — the mechanism never needs to know whether a head is a person, which is the responsibility-layer face of the two-entrances symmetry. Custody (a key file, a passkey, an SSO-backed gateway) is deployment, bridged by one new signed payload: the session delegation certificate, a durable key naming an ephemeral key with an expiry — needed because chain extension is provenance anyone may perform, so delegating authority to a named key takes an explicit statement.

Three subtractions fell out of the same conversation:

  • subject goes. It was the signer's own unverifiable claim about whom the key represents — an assertion wearing the record's clothes. Authorization becomes pure keys; disclosure becomes a separate opt-in voucher signed by a party who actually knows, making "authorization is not disclosure" a file format instead of a policy.
  • forwardedProps.caller goes with it. funduq's verification digest for the agent had one real payload — the raw chain, which is the caller's utterance, not funduq's. It now rides to the agent verbatim (the same two-slot pattern as any caller-declared data), the agent verifies for itself, and with no digest there is no digest to forge — the verifiedActorChain reserved-key defense retires. funduq's part is four verbs: verify, copy the head, relay, refuse.
  • No challenge round-trip for resolutions. An ask's funduq-minted single-use id is already the nonce: a resolution signs over the ask id and the answer's hash, and a replay hits an ask that no longer exists.

The lock that comes with identity is deliberately half a lock: a thread whose first run carries a chain binds {segment head, provider} at birth, immutably, and writing is membership — members interject freely, non-members cannot speak — but reading a known id stays open at the core: a door-level read lock would gate confidentiality the core cannot actually deliver (its operator reads the database) while breaking every standard client's unauthenticated reads. The core enforces integrity with signatures; confidentiality belongs to the deployment's gateway; and discovery queries respect segment boundaries, so a broken-off subtree is simply never enumerated upstream — invisibility honest about being non-enumeration, not secrecy.

Authorization is not disclosure

funduq verifies the segment head's signature, and the provider learns only that the head resolved. Whether the provider learns who the head is stays a separate, caller-controlled switch — the same two-layer split KYOK's context relay already implements, where the context is relayed in memory, never persisted, never volunteered. These are two switches by design: do not print the head key to the provider as a convenience.

See Responsibility chainsfull record

The two lanes

Most protocol confusion about multi-turn conversation comes from conflating two lanes. The queue lane carries ordinary utterances and is state-independent: a caller may speak while the provider is working, while a run is paused, or on a quiet thread; delivery is always accepted, and handling is scheduled by the provider. The reply lane carries an utterance addressed to a specific paused run's input-required question — and "addressed to" is the whole condition: funduq resumes the paused run with whatever the caller said and never checks that it answers the question. "Forget the passport, book the train instead" rides this lane as legitimately as the passport number does; the provider reads the thread's shape — its own question is in the history, the interrupt is in the run's metadata — and decides for itself whether it was answered, redirected, or overruled. Whether an utterance answers a question is a semantic judgment, and funduq does not make those. (This is also funduq's answer to the upstream "the human doesn't want to answer, they want to keep talking" gap: address the paused task and say anything.)

input-required is an explicit pause marker governing only the reply lane. It says nothing about whether the queue lane is open, because the queue lane is always open.

On funduq's A2A surface the reply lane's marker, until A2A v1.1's elicitationId lands, is a message whose taskId names the thread's paused task: that resumes it. Any other message is the queue lane.

full record

Queueing makes "may I speak?" always answerable with yes

Every caller utterance is accepted at any moment and queued as a run on the thread, and the provider decides when or whether to claim it. Wire behavior becomes deterministic with no "not accepting input" error, and mid-execution steering needs no protocol flag — an agent that drains its queue eagerly is a steerable agent. Timing is the provider's will, not the protocol's ruling and not funduq's.

funduq never merges a queued message into an active run. Inferring which messages belong together is the batch-correlation swamp that killed an earlier design; membership is only ever declared by the caller and absorbed by the provider, never assumed by the relay.

This shipped for both doors (before it, the A2A door answered a mid-run message with the in-flight task and silently discarded the message — not queued, not appended, invisible to the caller — and the AG-UI door refused a busy thread with a snapshot). The dispatch half is arrival order and nothing else: a thread's runs are offered in the order they came, without waiting for the previous turn to end — funduq's own turn-pacing was tried and retired (see the reversal record).

The queue is a run queue, and every entry stays a run: each is dispatched whole and never changes shape. A reading that tempted us and was rejected — "one buffer, two consumers", where interjection would be the provider reaching into the same queue and reinterpreting an entry — puts an entry's meaning in the hands of whoever picks it up: the batch-correlation swamp reborn. What survives instead: an entry's verb is declared by the caller at birth and never inferred, and an interjection-intended utterance is still a run, its intent declared at the door (see the interjection record above); the declaration changes nothing in dispatch. Nothing is ever dumped: absent that annotation the provider receives one run per turn (whose input carries the history folded at its arrival, with thread_messages as the authoritative read).

The pending depth is bounded (thread_queue_limit, default 8, None opts out): a live conversation holds a few unconsumed utterances, not dozens — a depth in the tens is a runaway loop, not a caller. At the limit the door refuses loudly (ThreadQueueFull: the message was NOT accepted) — a resource guard, not a judgment, and honest refusal beats accepting into a queue that will only rot. The reply lane is never subject to it: answering the question is how the thread drains. The count-then-create at the door is deliberately unlocked; a rare concurrent overshoot by one is cheaper than locking a guard that is not accounting. Two deliberate narrowings. Enqueue order is arrival order — truly concurrent messages have no canonical order to preserve. And an AG-UI run arriving behind another holds its event stream open, silent, until its provider starts producing: AG-UI has no "accepted, not yet worked on" vocabulary to answer with (that is A2A's submitted), so the silence is chosen debt, guarded by the use case — an AG-UI client holds one session per thread and rarely opens a second run mid-turn; funduq accepts the unusual one rather than refusing it.

full record

Tried, measured, reversed

The thread gate is retired — funduq does not pace a provider's conversation

What shipped first (the queueing and interjection work): a broker-side thread gate — one turn per thread at a time, a paused input-required run holding its thread, and an interjection exemption through the gate with the provider's ack as the negotiation.

What killed it was a probe, then a chain of diagnoses that each went one level deeper. funduq#136 caught the exemption's annotation naming a run that had already ended: the gate opened in mark_run_status a beat before the run left the broker's tracking in forget, and the sweep read the two half-updated views in between. The first fix aligned one reader with the gate and was rightly rejected as a workaround — the window stayed open for every other reader. The second proposal closed the window by doing both updates in one synchronous section, and was rejected for the better reason: correctness resting on single-event-loop scheduling is a design that horizontal scaling must throw away.

The real defects, in the order they were found:

  1. The run-in-handler discipline never covered thread scope. Runs had one owner and an ordered command queue from day one; the gate — thread-scope state added later — was a bare dict mutated from four call sites, with the sweep reading and writing outside any queue. A state scope added later must inherit the single-owner discipline on the day it is born.
  2. The status machine had no order. mark_run_status was an unguarded UPDATE — any string, from any state, at any time. It now carries the legal-transition table in the UPDATE's own WHERE clause, so the database arbitrates racing writers and the loser's write matches zero rows: ordering that holds in one process or many.
  3. Deepest: the gate itself was funduq deciding when a provider drains its conversation — the exact thing our own A2A #1992 comment says is "the provider's own decision". Every special case (the exemption, the one-shot flag, the annotation stripping, #136 itself) was this contradiction paying interest.

What replaced it: offers go head-of-queue in arrival order — per-thread order holds by construction, and a sibling is offered while the previous turn still runs; the SDK runtime hands every run to the agent author's code and answers nothing on its behalf, with serialize_per_thread as the one-line opt-in for authors who want turn-taking back; the buffer bound is unchanged but now measures what it should — runs the provider has not taken — so backpressure flows provider → buffer → loud refusal at the door. The principle the whole episode distilled: correctness comes only from guarded transitions on the one ordered ledger; caches and shadows may buy efficiency, never correctness; and funduq asserts no fact whose owner is someone else.

Silence was read as death, and the party that had done nothing wrong was blamed

A claimed run with no event for run_stall_timeout_seconds (120s) was failed stalled_no_activity. It is gone, and nothing replaces the clock.

An agent's loop is silent for most of its life by construction: the model call it is waiting on is the segment nothing can be injected into, and funduq cannot see inside it. So the timeout measured a proxy for something funduq had already decided it could not observe, and the verdict landed on whoever was merely slow. That is the same failure the inter-chunk timeout was removed for — the record protected the LLM provider from being blamed, and this clock went on blaming the agent for the same wait, from the other end.

Worse, funduq blamed a run for silence it was itself causing: while a KYOK completion was in flight, funduq held the reason the agent had nothing to say, and failed the run for saying nothing. Probed, both halves:

  • provider detaches mid-run → is_serving is False at once, the run sits running for the whole window, then is recorded stalled_no_activity — a reason funduq did not observe, while the one it did observe went unrecorded;
  • LLM provider holds a completion → the agent's run is failed under it, and the money is still spent when the completion returns, on work already thrown away.

The rule that replaced it was already written down for the queued lane: liveness is a fact funduq holds, not a deduction from a timestamp (liveness stopped being an inference). expire_queued asks whether the agent is served; the claimed lane asked whether the run had spoken lately. Now both ask the same question. A provider that stops serving while still holding a run has taken work and never ended it: the run fails at once as provider_left_holding_it, and the same fact records abandoned — so the judgment about the provider and the verdict about the work come from one observation instead of two clocks.

How long an attached provider holds a run is its own business, and nothing settles that run but the provider or the caller's cancel. The queue lane already accepted exactly this ("a run whose agent is served stays queued indefinitely"). One consequence is recorded rather than patched: self-delegation deadlock used to break itself on this clock at 120s, and now does not.

See Runs and cancels are requests.

Enforcing cancellation produced a family of bugs

funduq used to enforce cancellation by cancelling its own pump task and synthesising a stream ending. That needed a started-Event handshake and straggler absorption on the provider side, and still deadlocked: cancelling a task before its first scheduling turn means its finally never runs, so the run never terminated. All of it disappeared once funduq stopped deciding on the provider's behalf.

See Runs and cancels are requestsfull record

A question funduq did not ask is not funduq's to time out

A paused run had a deadline: paused_timeout_seconds, swept every health_sweep_interval_seconds, failing the run as paused_no_resume. It shipped off — the setting defaulted to None and the sweep skipped itself — so the deployed behaviour was already "wait forever", arrived at by leaving a setting unset rather than by deciding anything.

Deciding it: an input-required run is waiting on an answer from a caller, about a question a provider asked. funduq is neither party. Both of them already have the lever — AG-UI lets the asking provider declare Interrupt.expires_at, and the caller that owes the answer decides when to stop owing it — and funduq's clock could only overrule whichever one of them was still working. So the deadline is gone, along with the setting, repo.fail_stale_paused_runs, and the sweep loop.

That loop was the only thing start() ever spawned, so removing it took the loop, health_sweep_interval_seconds, funduq/health.py (its one surviving function, close_with_terminal_event, moved to handlers.py beside the other things that write a run's ending) and Health.background_running, which reported whether the sweep was alive and could now only ever say False. Health.ready never consulted it.

This is the same removal as silence being read as death, one layer out: that one stopped funduq judging a provider by a clock, this one stops it judging a caller by one. What is left of funduq's clocks is the broker's own sweep, and both of its windows watch a provider — accepted-but-undelivered, agent gone unserved — and settle nothing themselves: they say what they observed into the run's own lane and let the run decide.

Known and accepted: a pause now waits indefinitely, across restarts (fail_orphaned_runs deliberately excludes input-required). It costs a row, not a slot — count_queued_runs_for_thread counts only runs no provider has accepted, so a permanent pause never fills a thread's buffer. The verb that should end one is the answering party giving up, and funduq does not have it yet.

See Runs and cancels are requests.

A cancel with nobody to ask answered "too late"

cancel_run returned False for any run the broker was no longer tracking, documented as "it has already ended, and there is nobody left to ask". A paused run is not tracked either — the broker forgets a run when its stream ends, and a pause is the end of the provider's stream — so a caller asking to stop a run that was still waiting got the word for "too late", with no way to tell the two apart.

Through A2A it was worse. input-required is not terminal, so it fell past the already-ended branch and out through that False, and cancel_task returned the task unchanged, at the same state, with no pending-cancel marker. A cancel that reads exactly like never having asked is the failure the terminal branch exists to prevent, reached from the other side.

Probed, before the fix: cancel_run -> False, status still input-required, cancel_task -> TASK_STATE_INPUT_REQUIRED, metadata {}.

The fix states the fact instead: not terminal is not the same as cancellable. Cancelling here means relaying the request to whoever is working on the run, and a paused run has nobody working on it. Core raises RunNotCancellable; the A2A door quotes TaskNotCancelableError, which is what a2a-python raises when a task will not reach CANCELED.

What was deliberately not built: an abandon verb. Deciding not to answer a pause is the answering party giving up, a different act by a different party than asking a worker to stop, and folding it into cancel would make a cancel settle a run funduq has observed nothing about. The same reasoning keeps cancellinginput-required legal: a provider answering a cancel with "are you sure?" has not stopped, and recording that it did would be the outcome-invention this repo already threw a design away over.

See Runs and cancels are requests.

KYOK replaced two designs, both failing for one reason

Session rendezvous: the caller minted a session id and the token carried it to the agent provider, which decoded its own token, connected as the bridge, and was handed another provider's completion to answer — probed live, with injected tool input for whatever acted on it. Hashing the id closed the disclosure funduq itself was creating, but the id remained the entire proof.

Single connection: run and bridge on one duplex connection, with correlation by construction. It died on its own correctness — the caller cannot learn funduq-minted ids early enough to present them, and any reconnect path reintroduces "who owns this connection", the exact question the design existed to erase.

Both failed for the same reason: an actor with no identity. KYOK is now a real LLM provider on the same identity machinery.

See Keep your own keyfull record

A run id was the whole proof again, so KYOK inherits nothing

A bound run's KYOK binding used to be copied onto any run whose A2A message cited it in referenceTaskIds. The stated reason was to keep the delegating agent from holding the caller's context: funduq would copy it so the agent never had to.

Probed: an unrelated caller, with no chain and no relationship to anyone, sent a message to a different agent citing a live bound run's id. The new run got the offering and the victim's context verbatim — a spending grant, and the caller's reconciliation handle, handed out for knowing an identifier. A responsibility chain on the original thread did not narrow it: citing a task opens a new thread whose parent is the cited one, and membership governs writing on a thread, not referencing it.

So the mitigation was worse than what it mitigated. It was invented to stop one known agent from seeing the context, and it gave the context to anyone holding an id. And this repo had already named the failure, in the record of the first KYOK design it threw away: "hashing the id closed the disclosure funduq itself was creating, but the id remained the entire proof." Inheritance put that back, with money on it.

The rule that replaced it needs no mechanism: a run spends against the opt-in its own caller submitted. An agent that delegates is the caller of the new run and says what it funds — the user's offering if the user arranged that with it, its own if it is paying, or nothing. Whether a delegation continues the user's account or the delegating provider's is between those two; it was never funduq's to decide, and deciding it is what created the hole. referenceTaskIds goes back to meaning only what A2A says it means: this came from that.

See Keep your own key.

An inter-chunk timeout kills slow models and blames the wrong side

There is deliberately no timeout on a hung LLM provider. The old 30s inter-chunk timeout killed slow models while attributing the failure to the wrong party. A hung stream belongs to whoever is waiting — the agent provider's own HTTP timeout, or the serving layer cancelling the relay on disconnect.

See Keep your own keyfull record

"Trustless" binding was rejected as false safety

Baking run and thread ids into the KYOK context looks like it would make the binding trustless. It would not. Completion-to-run attribution and run parenthood are funduq-only records that nothing else signs, so an id in the context still requires trusting funduq for every link. funduq-as-relay trust is irreducible in this architecture, and the ids are unlearnable at context-minting time anyway.

See Keep your own keyfull record

Background work is not a TaskGroup

Weak-referenced tasks were silently garbage-collected, killing run pipelines. A TaskGroup was rejected for the opposite reason: one failing run must not cancel its siblings. Funduq.spawn holds strong references and isolates failures.

See Supportfull record

Self-delegation deadlocks a capacity-capped provider

A provider with max_concurrent_runs=1 that delegates to its own agent deadlocks: the outer run holds the slot while it waits, the inner run needs a slot from the same provider, and funduq — tracking that provider's budget itself — withholds the offer rather than asking. The outer run sits running indefinitely: the clock that used to give up on it was removed when silence stopped being read as death, so what ends the deadlock now is the caller cancelling, or the provider's undelivered allowance running out and withdrawing it. A provider that recurses should stay on the default unlimited capacity. Delegating to a different provider is unaffected, since it has its own budget.

funduq imposes no depth limit and performs no cycle detection.

full record

Dispatch was single-file, and the queue it blocked was everyone's

Runs executed concurrently from the beginning — each claimed run got its own pipeline task. Handing them over did not. One loop offered to one agent at a time and blocked on the provider's answer before moving to the next, so a provider slow to answer stalled the handover of every other agent, provider and caller. Measured, with two unrelated agents on two unrelated providers and the first sitting on its offer for 3 s:

slow provider holds the offer, unanswered (3s)
the other agent's run, sent to finished: 3.10s

Milliseconds of work, three seconds of waiting, for a run that shared nothing with the one ahead of it but the loop. A networked provider is always slow to answer, so the worst case was the full delivery timeout added to every other agent's next handover, per unanswered offer.

No test saw it, and the reason is worth keeping: every test provider is in-process and acks immediately, so the window whose width is the whole defect had zero width. The second defect in the same window was worse — a cancel arriving while the offer was in flight took the nobody-has-it-yet path, so funduq recorded the run cancelled and handed it to the provider, which then worked on something nobody would collect and lost a capacity slot permanently.

The fix was not to make the loop faster but to notice that the run has an owner from dispatch onwards. The offer leaves, and with it goes the run's place on the provider and a status of its own (offering); a cancel arriving now queues behind the pending answer instead of being decided in funduq's favour; and the waiting itself moved out of the shared loop into one lane per agent. The sweep starts lanes and goes back to sleep.

The first version of that fix carried a patch worth recording, because rejecting it is what produced the shape above. It queued the claim like any other command and then had to reorder the queue to put it in front, since the cancel from the window had arrived first in wall-clock time and second in truth. Reordering a queue is a sign that the order was never the queue's to fix. The claim became true before the run's lane existed, so it is not a queued command at all: the lane's first act is to record it. Ownership passes from the dispatcher to the lane at the one moment there is nothing in flight to reorder, and one step function applies every command about a run whichever owner is holding it. The reordering, and the flag tracking whether a lane had been started, both disappeared with it.

One thing deliberately stayed serial, and getting its unit right took one more pass. The first answer was "per agent, because the queue is per agent" — which is the data structure justifying itself, and it is the same mistake one size down: two conversations with one agent still made each other wait, measured at 2.00s for a conversation that shared nothing with the slow one but its agent.

The unit is the thread, because a thread is the pipe whose delivery order funduq guarantees, and nothing wider has an order at all. The reason funduq owes that order is worth stating exactly, because it is not pacing: a conversation can only be generating one turn at a time, resolving that is the agent's own scheduling, and a provider that takes turns can only take them in the order things reach it. Deliver two utterances of one conversation at once and its sequencing locks in an order nobody chose, invisibly. funduq owes sequence; the provider owns pacing.

A test was measured against this and found not to hold its own claim. test_a_declined_head_is_not_overtaken_by_its_sibling said arrival order held the sibling back; what actually held it back was the capacity rule that treats a decline as reaching the provider's declared limit. Against a provider that declares no limit there was no such brake, and the test passed with per-run queues — that is, with no arrival order at all. It now uses an unlimited provider and asserts the whole order.

An earlier attempt fixed only the cancel race, by arbitrating the run's status with a conditional UPDATE. It was abandoned: every fix needed another field to keep memory and the record in step, which is patching the seam rather than closing it.

funduq#164.

A run had no owner until somebody took it, and every pair of the four who touched it needed reconciling

The dispatch fix above (single-file handover) was measured, tested and correct, and it still carried six patches. They were counted deliberately rather than argued about:

  • a five-line comment proving only one party would settle a cancelled run, resting on there being no await between two lines — a property any later edit would break silently;
  • a re-read of the roster after an offer was accepted, compensating for a decision made across an await;
  • two ways for a run to end, _pipeline and _one_shot, with the same two closing lines in each;
  • three paths for one cancel, and which applied depended on the instant the cancel arrived in;
  • three different parties removing entries from the same queue, one of them tidying up after the others;
  • a registry of per-thread dispatch tasks that removed itself in a finally, correct for the same by-scheduling reason as the first.

One cause under all six: a run had no owner until a provider took it. Before that it was passed between the sweep, the dispatch lane, request_cancel and the expiry clock — four parties with no order between them — and every place two of them met grew a patch to reconcile the two views.

What replaced it: a run gets its own task the moment it is queued, and that task's only wait is the run's own command queue. Being asked to try handing itself over is a command like any other, so nothing outside works out what a run's state means and then acts on it — it says what happened, and the run decides in its own order. unregister_provider stopped reading claimed_by and started saying this provider is gone; the expiry clock stopped settling runs and started saying nobody took you; cancel became one line.

Two things made it affordable rather than a new kind of complexity. Duplicate reasons to try are coalesced into one pending question — three things changing at once used to mean three offers for one dispatchable moment, and three counts against a provider for one decline. And enqueue_run now refuses a run whose agent nobody is serving, so the lane opens by offering rather than by waiting for someone to appear, and "no provider" is a recovery path rather than an entry state.

The second one was justified with a claim that was not true. It read: already true on every production path, since the doors record agent_offline instead of queueing, but not relied on. The doors could not make it true. dispatch read the roster, await session.commit() — a network round trip on Postgres — and only then called enqueue_run, which read it again and raised. A provider closing its socket inside that commit is ordinary, and it produced all three things this record is about at once: an unhandled RuntimeError where the door promises agent_offline (a 500 at either protocol door), a run left queued forever, and a broker that had never heard of it — the very "run nothing could ever finish" the refusal exists to prevent.

The cause is this record's own cause wearing different clothes: two parties reading one fact at two moments, with a suspension point between them. Narrowing the window would have kept both parties. The door stopped asking, and enqueue_run answers None instead of raising, so the reading that counts is the one taken in the same synchronous breath as the insert it guards. Being asked while the broker is stopped is still an exception: that cannot become true between two lines.

Four of the twelve counted items survived, and they belong to a different cause: in_flight is both a count of what a provider holds and a place to record "it says it is full", and the status machine is both the record of what happened and the permit a compensation path needs. Fact and policy sharing a field is its own record to write.

funduq#164, and the branch that followed it.

Open contradictions

One thing on this page still disagrees with the code. It is recorded rather than resolved, because resolving it is a design decision nobody has made yet.

thread_id is a credential today and a pure name under rule zero. Rule zero says nothing whose only quality is being known may authorize anything. But the current A2A surface rejects an unknown contextId precisely because thread ids act as capability tokens in today's trust model, and the de facto resume credential is knowledge of thread_id — any AG-UI call naming the thread and carrying non-empty resume entries gets through. Rule zero exists to replace that. Until it is built, both statements are true of different points in time.

Answering a paused A2A task rides taskId, so this contradiction now has money and authority behind it rather than just read access: knowing a task id is enough to answer someone else's paused question. That is the interim marker until A2A v1.1's elicitationId lands, and it is the sharpest reason rule zero needs a caller identity to scope names and rights to.

The door asymmetry that used to be recorded here as unexplained — AG-UI minting an unknown thread while A2A refuses one — is no longer a contradiction: it is each protocol's own grammar, with one deliberate rule underneath both.