MESH ONLINE│CODENAME: Breaking the Law
v0.45

Agent-to-Agent Task Handoff

Discovery and invocation cover "call a capability and get an answer." A2A covers something else: one agent hands a long job to a different agent, keeps working, and can cancel it mid-run — with the other side demonstrably stopping.

The distinction that decides whether you want this:

You wantUse
A short call whose answer you need before continuingDiscover and invoke — a capability
Sequential work inside one agent's own contextDirect capabilities, not A2A
Parallelism — a long job runs elsewhere while you continueA2A

A2A is for parallelism, and the reason it exists as a separate surface is that the executing agent does not share your memory. Asking another agent to continue your work is briefing a colleague who wasn't in the room — so the protocol makes that explicit rather than pretending otherwise.

The brief carries references, not context

A TaskBrief carries the job plus the context the executor needs as artifact references, not inlined content. That's deliberate: the other agent has its own memory, and inlining would imply a shared context that doesn't exist. Put the context in Dataforts and hand over the refs.

And it is enforced, not merely advised: a brief is refused locally if its encoded form exceeds A2A_MAX_BRIEF_BYTES. That ceiling is far below the nRPC body cap because the A2A wire carries a request inside a JSON array-of-bytes envelope, which costs up to four bytes per payload byte, and one mesh packet is 8 KiB. It also reserves room for the TaskRecord a status reply carries the brief back inside, so a brief that crossed the wire can always be read back.

The encoded brief is one joint budget: the task id, the service and revision names, every context ref, every tag, the JSON structure and JSON escaping all come out of it. A service's announced max_prompt_bytes is a per-field cap the provider enforces, and serve time guarantees it is reachable — but spending the budget on refs and tags can still overflow it, and the refusal names both numbers. Query a2a_announceable_prompt_bytes() rather than hardcoding a figure; the constants are derived from the transport and move with it.

An over-large request is not refused by the far side — it overflows the packet and is never delivered at all — so the check happens before anything is sent, and a configured service may not announce a max_prompt_bytes above the reachable ceiling (ServeError::A2aUndeliverableBounds).

Replies are bounded too. A catalog, a status record or a result that would overflow a packet answers ERR_A2A_REPLY_TOO_LARGE naming what overflowed, rather than returning a frame the transport drops and leaving the caller to time out on an answer the provider already computed. Discovery is paginated, so a large catalog is walked rather than truncated; provider-authored diagnostic prose is truncated with the cut marked, because a verdict is worth delivering even abbreviated, while an offer or a result never is.

A long prompt is therefore a design signal: put the bulk in an artifact ref, which is what briefs carry refs for.

Control calls are bounded in time

Every A2A verb — submit, status, cancel, describe, prepare — is a short control call, and each carries a hard A2A_CALL_TIMEOUT (30s). The task stays unbounded; only the round trip is bounded.

This matters because a request delivered to a live service whose handler never answers would otherwise park the caller permanently: CallOptions::deadline defaults to "wait forever". An expired deadline is A2aFlowError::Timeout, which says unknown, not failed — so retry it. Every verb is idempotent per (owner, task id), and a submit that did land answers with the existing task rather than starting a second run.

The same bound applies to the payment channel: an unanswered pay becomes a retryable channel error, which is what moves a durable purchase attempt to unknown (resumable by re-sending the identical stored payload) instead of claiming a payment did not happen after its authorization already left the process.

Lifecycle

▸ text
requested → accepted → running → completed{ref} | failed | cancelled
                                 | interrupted{detail}

completed carries a reference to the result, for the same reason the brief carries references to the input.

interrupted says the executor's outcome is not knowable from this process — a restart interrupted the run — and only a configured catalog (paid services) ever produces one.

Executor side

▸ rust
let handles = mesh.serve_a2a(registry, executor)?;
// Hold the handles for as long as this agent should accept tasks —
// dropping them unregisters the services.

serve_a2a registers three services at once: accept a brief (spawning the executor), answer status, and cancel. Rollback is automatic — if registering the third fails, the first two unregister as the error returns, so you never end up half-serving.

A malformed brief does not fail out of band. It answers a TaskAck { accepted: false } that the requester reads, so a bad submission is a value on the happy path rather than an exception on a background task.

The node must be start()ed before serving.

Requester side

▸ rust
let ack = mesh.submit_task(target, brief).await?;      // TaskAck
let rec = mesh.task_status(target, &task_id).await?;   // Option<TaskRecord>
let stopped = mesh.cancel_task(target, &task_id).await?;  // bool

task_status returns Option — None means the executor has no record of that id, which is different from "the task failed." cancel_task returns whether the cancel took effect, and the executor observes it through a CancelToken rather than being killed, so it can stop cleanly.

Free and paid are provider configuration, never something a caller selects. serve_a2a above is the free path and is unchanged. The strict sibling takes a catalog in which every service is explicitly one or the other:

▸ rust
let serving = mesh
    .serve_a2a_configured(registry, executor, config)
    .await?;
// serving.handles — hold them, as above.
// serving.store   — the durable admission records (below).

Each catalog entry is a Free(offer) or a Paid(offer), and the offer carries the service id, its revision, the bounds a brief must fit, the net.pricing.terms@1 document for a paid service, and three retention windows. Callers read the catalog with describe_a2a(node).

A paid service never degrades to free. Each of these is a refusal to start, not a warning:

MisconfigurationServe-time refusal
Paid entry with no pricing termsMissingPricingTerms
Free entry carrying pricing termsUnenforceablePricing
any Paid entry with no payment gate, or no admission journalA2aPaidMisconfigured

A catalog of only Free entries needs neither a gate nor a journal — but free means no payment, not no policy. Free services still run the application preflight and still enforce capacity, so an oversized or unauthorized brief is refused there too.

Prepare, purchase, submit

The ordering is the whole design: everything that can refuse the work happens before a quote exists, and the launch is claimed durably before the executor is spawned.

StepWhat it doesMoney
preparevalidates the brief, runs the provider's preflight, reserves capacity, mints the admission id, quotes, and persists the attemptnone — no funds move
purchaseconsumes the quote prepare stored against that reservation, runs spend policy, pays once, stores the evidencethe one charge
submitsends the brief with its evidence; the provider redeems, claims the launch, then runs itnone

prepare answers busy when the service is at max_in_flight. Nothing was reserved and nothing was quoted, so it is safe to retry — and it is also how a first call that is not yet routable to the provider reports itself.

The quote commits to the admission id and to a hash of the exact brief, so the same proof presented under another reservation — or by another peer — does not compute the provider's expected hash, and the redemption is refused before anything runs. A retry of an identical submission returns the original task rather than buying a second run.

The caller-side flow keeps a durable attempt per task, so a lost reply is resumed, never re-quoted: the stored payload is re-sent and the engine answers with the original verdict.

Refusals

Payment and admission refusals are not exceptions on a background task, and they are not the in-body rejection either. They arrive as the ERR_PAYMENT application error carrying a human message and a machine-readable failure schematic:

ReasonMeansRetry?
missing_quotepaid submit with no quote header; the gate was never consultedafter paying
binding_requireda quote id alone; a task always needs the payer's signatureafter signing
binding_rejectedthe binding does not match the recorded purchaseno
no_reservationthe provider holds no admission record for this taskno — reconcile
input_binding_mismatchthe quote commits to different work than the briefno
admission_revokedthe provider can no longer admit work that may already be paidno — operator
journal_unavailablethe admission store refused a write, so nothing startedyes, same proof

Everything non-financial stays where it always was, in-body as TaskAck { accepted: false }: an unknown service, a stale revision, a brief outside the bounds, Busy at capacity, and Retired once a result has aged out.

What a restart surfaces

A paid provider keeps a durable admission record per task, and the launch ledger that record writes is never pruned automatically. A successor process reads what it finds conservatively, so an interrupted run is reported as ambiguous rather than silently repeated:

Record foundtask_status answersAn identical retry does
reserved, unexpirednull — nothing ranredeems (idempotently) and launches once
reserved, expirednullre-acquires capacity, or Busy — the gate is not called
paid, never launchedinterrupted{paid_not_started}resumes on the recorded payment, no second charge, launches once
launched, no outcomeinterrupted{outcome_unknown}acknowledges the task and never relaunches
revoked after paymentinterrupted{admission_revoked}refuses again; an operator resolves it
terminalthe recorded state, until retentionacknowledges the task
ledger only, result retirednullRetired

interrupted is the one state this path adds to the status wire, and only a configured catalog produces it. Python and Node hand the status back as JSON and pass it through; a Rust requester built before it existed cannot decode a record carrying it.

Records in the last three rows are the operator's queue. They are never pruned automatically — money moved, or may have — and an operator resolving them is the only exit.

Bindings

BindingSurface
Rustserve_a2a, submit_task, task_status, cancel_task
Pythonserve_a2a(callback), submit_task(...), task_status(node, id), cancel_task(node, id)
Node / TypeScriptserveA2a(executor, options?), submitTask(...), taskStatus(...), cancelTask(...)
GoNot available — no A2A surface in the Go binding today

The paid path is narrower, because a paid provider needs a payment engine and a durable journal beside it:

BindingPaid surface
Rustserve_a2a_configured, describe_a2a, prepare_a2a, submit_task_paid
Pythonserve with PaymentProvider.serve_a2a_configured; buy with CapabilityGateway.prepare_task / purchase_task / submit_task; from net_sdk, build both with create_payment_provider / create_capability_gateway
Node / TypeScriptserve with PaymentProvider.serveA2aConfigured; buy with CapabilityGateway.prepareTask / purchaseTask / submitTask; raw NetMesh.describeA2a / submitTaskPaid; from @net-mesh/sdk, build both with createPaymentProvider / createCapabilityGateway
GoNot available

From TypeScript, through the SDK:

▸ typescript
import {
  MeshNode, createPaymentProvider, createCapabilityGateway, a2aDocument, classifyError,
} from '@net-mesh/sdk';
 
const provider = createPaymentProvider(node, {
  statePath: 'state/engine.json',
  facilitatorUrl: 'https://facilitator.example.com',
});
const terms = await provider.pricingTerms(
  `${node.nodeId()}/net.a2a.task/summarize`,
  requirementsJson,
);
const handle = await provider.serveA2aConfigured(
  // brief.service and brief.revision are set
  async (brief) => runTheJob(brief),
  { summarize: { revision: 'r1', pricingTerms: terms,
                 bounds: { maxPromptBytes: 1024n, maxContextRefs: 8n, maxTags: 8n,
                           maxTagBytes: 64n, maxInFlight: 4n },
                 reservationTtlSecs: 600n, reservationRetentionSecs: 604800n,
                 retentionSecs: 3600n } },
  'state/a2a-journal.json',
);
 
const gateway = createCapabilityGateway(caller, {
  paymentPolicyPath: 'state/spend-policy.json',
  paymentProfile: 'production',
  a2aPurchasePath: 'state/a2a-purchases.json',
});
const env = await gateway.prepareTask(providerNodeId, 'summarize', 'the filings');
const prepared = a2aDocument(env, '/prepared');   // never JSON.parse + JSON.stringify
const bought = JSON.parse(await gateway.purchaseTask(prepared));
if (bought.status === 'paid') await gateway.submitTask(prepared);

Three Node-specific rules:

  • Hand documents back with a2aDocument, read u64 fields with a2aU64. Paid-A2A documents carry u64 integers — prepared.provider_node, a quote's expires_at_ns, recovery generations — and JSON.parse rounds anything above 2^53, so a prepared document round-tripped through it can name a different provider. Status strings and messages are safe to JSON.parse.
  • Errors are prefixed native Errors until classifyError types them: PaymentRefusedError (with the provider's schematic, kept byte-exact), JournalOwnedElsewhereError, A2aInvalidArgumentError.
  • A JS executor cannot be aborted from outside. A cancel — or the handlerTimeoutMs deadline (default one hour) — records the task's terminal state and discards the handler's eventual result, but the handler's own side effects continue unless it cooperates. On a paid service a timeout is a terminal execution failure, never a refund. And stop() / close() retire the registration without releasing the admission journal while a launched task can still record its outcome.

See also