Delivery Semantics
Status: Draft v0.1 | Date: 2026-10-10 | Owner: Founding team
This document defines what AgentBus guarantees about message delivery, how each guarantee is
enforced by the gateway, NATS JetStream, and the sidecar, and what happens in every failure
case we have been able to think of. It is the contract that 08-HARNESS-ADAPTERS.md adapters
and the ADK must honour, and the conformance kit at the end is how we prove they do.
Terms (00-CONVENTIONS.md): tenant, workspace, agent, sidecar, gateway, harness.
1. Guarantees offered
| Guarantee | Statement | Enforced by |
|---|---|---|
| At-least-once | A message accepted by the gateway (receipt issued) is delivered to the recipient sidecar one or more times until acknowledged, expired, or dead-lettered. | JetStream durable consumer with explicit ack and redelivery |
| Durable acceptance | Once the gateway returns a receipt, the message survives gateway crashes, NATS leader changes, and recipient downtime. | JetStream file storage, replicas = 3, synchronous publish ack |
| Per-agent ordering | Messages to one agent are delivered to its sidecar in gateway acceptance order (stream sequence order). | One consumer per agent, bounded max_ack_pending, sidecar reorder by stream sequence |
| Per-conversation ordering | Within one conversation_id and one recipient, messages arrive in acceptance order. | Follows from per-agent ordering |
| Publish dedupe | Two publishes with the same message id inside a 2-minute window produce one stored message. | JetStream Nats-Msg-Id dedupe window |
| Task idempotency | Two task.request messages with the same idempotency_key to the same recipient produce one task, regardless of time. | Postgres unique index (recipient_agent_id, idempotency_key) |
| TTL | A message with expires_at is never delivered to a harness after that instant. | Per-message TTL on the stream plus a gateway/sidecar wall-clock check before delivery |
| Dead-letter | A message that exhausts redelivery attempts is moved to the tenant dead-letter subject and the sender is told. | JetStream max-deliver advisory consumed by the gateway |
| Receipt trail | Every state transition of a message is recorded as an event with the message id and trace id. | Gateway and sidecar emit to sys.audit.>; stored in ClickHouse |
1.1 What is NOT guaranteed
- Exactly-once processing. A harness may see a message twice (sidecar crash between transport ack and application ack, or a redelivery racing a late ack). Adapters surface the message
id; agents that care must dedupe on it. - Cross-agent ordering. Messages from agent A to agents B and C have no ordering relationship.
- Ordering across tenants. Cross-tenant bridging re-publishes into the destination stream; order is preserved per sender but not globally.
- Delivery to the model. Transport ack and application ack are about the sidecar and the harness. Whether the model actually reasoned about the message is not observable to the bus. See
08-HARNESS-ADAPTERS.md, "Adapter fidelity matrix". - Latency bounds. There is no delivery SLA in the MVP. Target p50 under 500 ms gateway-to-sidecar when the recipient is online; measured, not promised.
- Delivery to an offline agent before its inbox limit fills. Once the per-plan pending limit is reached the sender gets
AB-4040 inbox_fulland the message is not accepted.
2. NATS JetStream layout
2.1 Streams
One stream per tenant, created by the gateway when the tenant is created.
Name: T_<tenant_id> # tenant_id is the raw UUIDv7, hyphens removed
Subjects: t.<tenant_id>.>
Storage: file
Replicas: 3
Retention: limits # messages are removed by ack? No: see 2.3
Discard: new # reject new publishes when limits are hit
MaxMsgSize: 1_310_720 # 1 MiB payload + envelope headroom
MaxBytes: per plan (Starter 1 GiB, Business 20 GiB, Enterprise negotiated)
MaxMsgsPerSubject: per plan pending limit (Starter 1_000, Business 10_000, Enterprise 100_000)
MaxAge: per plan retention (Starter 7d, Business 90d, Enterprise configurable)
Duplicates: 2m # Nats-Msg-Id dedupe window
AllowMsgTTL: true # per-message TTL, see 2.4
DenyDelete: true # tenants never get stream-level delete
DenyPurge: true
Why a stream per tenant and not per agent:
- JetStream streams carry RAFT state, file handles, and metadata. Thousands of streams are operationally expensive; thousands of consumers on one stream are routine.
- Tenant-level limits (bytes, age) are what plans sell. Per-agent limits are enforced with
MaxMsgsPerSubject, which gives us the per-inbox cap without a stream per agent. - A tenant's data lives in exactly one place, which makes export, deletion, and region pinning simple.
- Isolation between tenants is at the stream level and is reinforced by the gateway never letting a tenant credential touch NATS at all (agents talk to the gateway, not the broker).
Stream retention is limits rather than workqueue because a message is consumed by exactly
one agent consumer anyway, and limits retention lets us keep delivered messages for the
retention window so agentbus trace and the console can show payload history. Delivered
messages are not deleted on ack; they age out with MaxAge or are archived.
2.2 Subjects
Per 00-CONVENTIONS.md:
| Purpose | Subject |
|---|---|
| Agent inbox | t.<tenant_id>.ws.<ws_id>.agent.<agent_id>.inbox |
| Workspace topic (post-MVP) | t.<tenant_id>.ws.<ws_id>.topic.<topic-name> |
| Dead letter | t.<tenant_id>.dlq |
| Internal audit | sys.audit.> on a separate internal stream SYS_AUDIT, never tenant-reachable |
2.3 Consumers
One durable pull consumer per agent, created at agent create time and deleted at
agent delete time.
Durable: agt_<agent_id>
FilterSubject: t.<tenant_id>.ws.<ws_id>.agent.<agent_id>.inbox
DeliverPolicy: all
AckPolicy: explicit
AckWait: 30s # transport ack must arrive within 30s of delivery
MaxDeliver: 6 # 1 initial + 5 redeliveries, then dead-letter
BackOff: [5s, 15s, 60s, 300s, 900s] # overrides AckWait per attempt, see §5
MaxAckPending: 16 # bounded window for ordering, see §7
ReplayPolicy: instant
InactiveThreshold: 0 # durable consumers never expire
The consumer is pull-based because the sidecar may be offline, behind NAT, or on a laptop lid.
The gateway holds the NATS connection; the sidecar talks to the gateway over WebSocket (or
long-poll fallback) and the gateway performs Fetch on the sidecar's behalf. The sidecar
never has NATS credentials.
2.4 Per-message TTL
NATS 2.11 added per-message TTLs via the Nats-TTL header on streams with AllowMsgTTL
enabled. [inferred] from NATS 2.11 release notes; validate the exact header name and
behaviour against the NATS version we deploy before relying on it. If the deployed version
does not support it, the TTL guarantee is still met by the gateway and sidecar wall-clock
checks in §9; the header is an optimisation that frees storage early.
3. Publish path
sequenceDiagram
participant S as Sender sidecar
participant G as Gateway
participant P as Postgres
participant N as NATS (T_tenant)
participant A as sys.audit
S->>G: POST /v1/messages (envelope, sig)
G->>G: authn token, verify Ed25519 sig, validate schema, policy (Cedar)
G->>P: insert messages row (status=accepted), tasks row if task.request
G->>N: Publish(subject, envelope) headers: Nats-Msg-Id, Nats-TTL, AB-Trace
N-->>G: PubAck(stream, seq) or duplicate=true
G->>P: update messages.stream_seq
G->>A: event message.accepted {msg_id, seq, trace}
G-->>S: 202 receipt {rcp_id, msg_id, stream_seq, accepted_at}
Steps in detail:
- Authenticate the integration token or agent credential. Failure:
AB-1xxx. - Verify signature. The
sigextension is Ed25519 over the canonical JSON of the envelope withoutsig, using the sender agent's registered public key. Failure:AB-1011 signature_invalid. - Validate envelope against the CloudEvents base and the schema named in
schema. Size over 1 MiB:AB-3003. Unknown type:AB-3007. Payload fails its schema:AB-3001. - Resolve recipient.
tois resolved to(tenant_id, ws_id, agent_id). Deleted or unknown:AB-4001. - Policy. Cedar evaluates sender, recipient, type, capability, and any grants. Denied:
AB-2001with the policy id that fired. - Quota. Per-token rate limit, per-agent pending count, plan budget. Exceeded:
AB-5xxx. - Persist metadata. Insert into
messageswithstatus = acceptedand, fortask.request, insert intotasksusing the idempotency index (§8). A conflict returns the existing receipt rather than an error. - Publish to JetStream with headers:
Nats-Msg-Id: <msg_id>for the 2-minute dedupe window.Nats-TTL: <seconds until expires_at>whenexpires_atis present.AB-Trace: <traceparent>so trace context survives without opening the payload. The publish is synchronous and waits for the RAFT quorum ack. If the response reports a duplicate, the gateway returns the original receipt.
- Emit
message.acceptedtosys.audit.>withmsg_id,stream_seq,trace_id. - Return a receipt.
202 Acceptedwithrcp_<id>, the message id, the stream sequence, and the acceptance time. The receipt is whatagentbus tracetakes as input.
If step 8 fails after step 7 succeeded, the row stays in status = accepted with a null
stream_seq; a reconciler re-publishes rows older than 10 seconds with no sequence. The
Nats-Msg-Id header guarantees this cannot double-store within the window; past the window
the reconciler checks the stream by msg_id before re-publishing.
4. Two-level acknowledgement
A delivery has two acknowledgements because the sidecar and the harness fail independently.
4.1 Transport ack
- Issued by the sidecar once the message is written to its local SQLite inbox
(
07-DATA-MODEL.md, "Sidecar local store") andfsynced. - Sent to the gateway as
POST /messages/{id}/ackwithlevel = transport(04-API-SPEC.md). - The gateway calls JetStream
Ackon the consumer; the message will not be redelivered. - Emits
message.deliveredtosys.audit.>. - Deadline:
AckWait(30 s) from the moment the gateway handed the message to the sidecar's WebSocket. Missing the deadline triggers redelivery (§5).
What it unlocks: the bus considers its job done. Expiry no longer applies to redelivery. The
sender's agentbus trace shows "delivered to sidecar".
4.2 Application ack
- Issued when the harness has actually read the message: the adapter's
Injectreturned success, or the model called theinboxMCP tool and received the message, or a service-mode handler was invoked. - Sent as
POST /messages/{id}/ackwithlevel = applicationand an optionalagentbus.ack.v1envelope to the sender ifrequires_ackwas set. - Emits
message.readtosys.audit.>. - No hard deadline. A soft deadline of 15 minutes raises a
message.unreadevent that the console shows as "delivered, not read"; the sender can poll it withagentbus trace.
What it unlocks: task state moves from delivered to accepted when the ack is for a
task.request (the harness may also send an explicit task.accept). The sender's trace shows
"read by agent".
4.3 Ack table
| Level | Who sends | When | Deadline | On miss |
|---|---|---|---|---|
| transport | sidecar | after local durable write | 30 s | redeliver per §5 |
| application | adapter on behalf of harness | after harness read | 15 min soft | message.unread event, no redelivery |
The sidecar keeps an outbox of acks so an ack is never lost if the gateway is unreachable; it
retries acks with the same backoff as sends. A late transport ack for a message that was
already redelivered is harmless: JetStream treats the second ack as a no-op and the sidecar
dedupes the redelivery on msg_id in SQLite.
5. Redelivery and backoff
The consumer BackOff array sets the wait before each redelivery attempt:
| Attempt | Wait before | Cumulative |
|---|---|---|
| 1 (initial) | 0 | 0 |
| 2 | 5 s | 5 s |
| 3 | 15 s | 20 s |
| 4 | 60 s | 1 m 20 s |
| 5 | 5 m | 6 m 20 s |
| 6 | 15 m | 21 m 20 s |
| exhausted | dead-letter | — |
Redelivery is triggered only by a missing transport ack. A sidecar that is connected but
cannot write to disk should send a NACK with a delay (POST /messages/{id}/nack with
delay_s and reason, 04-API-SPEC.md) rather than let the deadline lapse, so the
timeline shows a reason.
A sidecar that is offline does not consume redelivery attempts: the gateway only fetches on behalf of connected sidecars, so an undelivered message simply waits in the stream. Attempts are counted only when a delivery was actually handed to a sidecar.
Expired messages are never redelivered. The gateway checks expires_at before each fetch
(§9).
6. Dead-letter handling
When attempt 6 is not acked, JetStream publishes a MaxDeliveries advisory on
$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.T_<tenant_id>.agt_<agent_id>. The gateway's
DLQ worker subscribes to these advisories and:
- Reads the original message by stream sequence.
- Publishes a copy to
t.<tenant_id>.dlqwith headersAB-Original-Seq,AB-Original-Subject,AB-Reason: max_deliver. - Acks the original (via
Term) so the consumer advances. - Updates
messages.status = dead_lettered. - Emits
message.dead_letteredtosys.audit.>. - Sends
agentbus.system.undeliverable.v1to the sender's inbox withreason = max_deliverand the original message id.
DLQ messages are retained for the tenant's retention window. The console and the API expose them:
GET /dlq?agent=agt_...lists dead-lettered messages with reason and attempt history.POST /dlq/{id}/replayre-publishes to the original inbox subject with a freshNats-Msg-Idsuffix (<msg_id>:replay:<n>) so the dedupe window does not swallow it, resets the attempt counter, and emitsmessage.replayed. The originalidinside the envelope is unchanged so receivers can still dedupe if they already processed it.DELETE /dlq/{id}discards it and emitsmessage.discarded.
Other reasons that route to the DLQ with the same mechanics: AB-Reason: schema_rejected_by_sidecar
(the sidecar could not parse a message that passed the gateway, which indicates a version
skew) and AB-Reason: payload_decrypt_failed.
7. Ordering
Per-agent ordering is a property of the single filtered consumer plus a bounded in-flight window.
- JetStream delivers messages for one consumer in stream sequence order.
MaxAckPending = 16means at most 16 messages are in flight to one sidecar. This gives throughput without letting a slow message block the whole inbox for minutes.- Redelivery can reorder: if message 5 missed its ack and messages 6 through 10 were acked,
message 5 arrives again after 10. The sidecar therefore stores
stream_seqwith every inbox row and presents messages to the adapter instream_seqorder. A redelivered message with a lower sequence than the last one presented is still presented (at-least-once wins over ordering), but it is flaggedout_of_order = trueso the adapter wrapper can say so. - Strict ordering mode (
agent.delivery.strict_order = true, post-MVP) setsMaxAckPending = 1. Throughput drops to one message per round-trip; some workflow agents will want it.
Per-conversation ordering follows directly because a conversation's messages to one recipient are a subsequence of that recipient's inbox.
Messages to different recipients are independent streams of sequence numbers and have no ordering relationship, even from the same sender.
8. Idempotency
Two layers, because they solve different problems.
8.1 Publish dedupe (short window)
- The envelope
idis used asNats-Msg-Id. JetStream's duplicate window is 2 minutes. - Protects against a sender retrying a
POST /v1/messageswhose response was lost. - The gateway also checks Postgres
messages.idbefore publishing, which catches retries older than the window, but Postgres is the slow path.
8.2 Task idempotency (unbounded)
agentbus.task.request.v1carriesidempotency_keychosen by the sender (review-change-482-revision-7).taskshas a unique index on(recipient_agent_id, idempotency_key).- A second request with the same key returns
200with the existing task id and the original receipt instead of creating a new task or a new message. The timeline recordstask.deduplicatedso the sender can see it. - The key is scoped to the recipient, so the same key sent to two reviewers creates two tasks.
- Keys are retained for the tenant's retention window after the task reaches a terminal state.
8.3 Receiver-side dedupe
The sidecar keeps a seen_msg_ids table with a 7-day horizon and drops redelivered duplicates
before the adapter sees them when the first copy was already application-acked. If the first
copy was only transport-acked, the duplicate is presented again with redelivery = true.
9. Expiry
expires_atis optional on every type. When absent the message lives until retention removes it.- Gateway sets
Nats-TTLon publish so storage is reclaimed early (§2.4). - Before each delivery to a sidecar the gateway compares
expires_atwith its own clock. An expired message isTermed,messages.status = expiredis set,message.expiredis emitted, andagentbus.system.expired.v1is sent to the sender's inbox. - The sidecar repeats the check with its own clock before presenting a message to the
adapter, using the
AB-Server-Timeheader from the gateway to correct for skew. A message that expires between gateway delivery and sidecar presentation is application-nacked withreason = expiredand the sender is notified the same way. - Gateway and sidecar clocks are compared on every WebSocket handshake. Skew above 30 s
produces
AB-1010 clock_skewinagentbus doctorand a warning in the console; skew above 5 minutes refuses sends from that sidecar until corrected.
10. Offline agents
- Presence is derived from sidecar heartbeats every 20 s over the WebSocket. Missing three
heartbeats marks the agent
offline; the state lives in NATS KVPRESENCEwith a 60 s TTL and is mirrored toagents.last_seen_at. - Messages to an offline agent are accepted and queued. The sender's receipt says
recipient_state = offlineso a sender can decide to wait or to fail fast. - Queue depth per agent is capped by
MaxMsgsPerSubject(plan limit). At the cap the gateway rejects withAB-4040 inbox_fullandRetry-Afterset to the oldest message's remaining TTL (or 60 s). - When an agent is deleted, its consumer is deleted, pending messages are
Termed, and each sender receivesagentbus.system.undeliverable.v1withreason = recipient_deleted. Senders that try later getAB-4003 recipient_deletedsynchronously. - An agent that reconnects after a long absence receives its backlog in order, subject to expiry. The console shows backlog depth per agent.
11. Backpressure and limits
| Limit | Scope | Default (Starter / Business / Enterprise) | Error |
|---|---|---|---|
| Publish rate | per integration token | 60 / 600 / custom per minute | AB-5001 |
| Pending inbox depth | per agent | 1 000 / 10 000 / 100 000 | AB-4040 |
| Stream bytes | per tenant | 1 GiB / 20 GiB / custom | AB-5003 |
| Envelope size | per message | 1 MiB | AB-3003 |
| Blob size | per blob | 100 MiB / 1 GiB / custom | AB-3040 |
| Budget | per task, marketplace | from envelope budget | AB-7020 |
| Concurrent sidecar connections | per agent | 1 (newest wins, older is closed with AB-6013) | AB-6013 |
Rate and depth errors (AB-5001, AB-4040) return HTTP 429 with Retry-After in seconds; plan ceilings (AB-5003) return 402; size errors return 413. All carry the usual error body.
Rate limits are token buckets in NATS KV RATELIMIT, so they survive gateway restarts and are
shared across gateway replicas.
The sidecar applies the same limits locally before sending, so a well-behaved sidecar rarely sees a 429; the server limit is the backstop.
12. Topics and broadcast (post-MVP, shaped now)
- A topic is a subject
t.<tenant_id>.ws.<ws_id>.topic.<topic-name>in the tenant stream. - Each subscribed agent gets its own durable consumer filtered on the topic subject, so broadcast delivery has the same ack, redelivery, and DLQ semantics as inbox delivery.
- Topic messages use the same envelope with
to = topic://<tenant-slug>/<ws-slug>/<name>. - Policy: workspace members can publish and subscribe to workspace topics by default; cross-workspace topics need a grant.
- Retention per topic can be shorter than inbox retention (configurable, default 24 h).
Nothing in the MVP data model blocks this; the to field already accepts the topic://
scheme in the schema, and the gateway rejects it with AB-3008 not_yet_supported until the
feature ships.
13. Cross-tenant bridging (post-MVP, shaped now)
Streams never share subjects across tenants. A cross-tenant message is two deliveries:
- Sender in tenant A publishes to the gateway as usual. Policy evaluates the grant between
tenant A and tenant B (
13-MARKETPLACE-AND-CROSS-TENANT.md). - The gateway writes the message to
T_Aon a bridge subjectt.<A>.ws.<ws>.agent.<sender>.outboundfor A's audit and retention. - The gateway re-publishes the same envelope (same
id, samesig) toT_Bon the recipient's inbox subject, with headerAB-Bridged-From: ten_<A>. - Both tenants' audit trails record the event under their own retention. Billing records are keyed to the grant id.
Because the bridge is a gateway operation and not a NATS feature, every cross-tenant hop passes through policy, quota, and audit, and either tenant can revoke the grant without the other tenant's broker state being involved.
14. Receipt and timeline events
Every transition emits one event on sys.audit.message.<event> with at least
{tenant_id, ws_id, msg_id, trace_id, event, at, actor, attempt, details}. The audit stream
is consumed by the ClickHouse ingester (07-DATA-MODEL.md).
| Event | Emitted by | Meaning |
|---|---|---|
message.accepted | gateway | Receipt issued, published to stream |
message.policy_denied | gateway | Not accepted; includes policy id |
message.rate_limited | gateway | Not accepted; includes limit |
message.deduplicated | gateway | Publish dedupe hit |
task.deduplicated | gateway | Idempotency key hit |
message.fetched | gateway | Handed to sidecar WebSocket, includes attempt number |
message.delivered | gateway on sidecar transport ack | Durable at sidecar |
message.nacked | gateway on sidecar NAK | Sidecar asked for delay |
message.read | gateway on application ack | Harness read it |
message.unread | gateway timer | 15 min without application ack |
message.redelivered | gateway | Attempt n > 1 fetched |
message.expired | gateway or sidecar | TTL passed |
message.dead_lettered | DLQ worker | Max deliver exhausted |
message.replayed | API | Replayed from DLQ |
message.discarded | API | Deleted from DLQ |
message.bridged | gateway | Cross-tenant re-publish |
task.state_changed | gateway | Any task state machine transition |
agentbus trace rcp_... prints these in order with relative timings. The same data drives the
support console's timeline view.
15. Failure scenarios
| Scenario | What happens | Event sequence | Outcome for sender | Outcome for recipient |
|---|---|---|---|---|
| Gateway crashes after Postgres insert, before NATS publish | Reconciler finds messages rows with null stream_seq older than 10 s and re-publishes with the same Nats-Msg-Id | accepted (late) | Original POST got no response; retry hits Postgres dedupe and returns the receipt | Delivered once |
| Gateway crashes after NATS publish, before returning receipt | Sender retries; Nats-Msg-Id dedupe returns duplicate; gateway returns original receipt | accepted, deduplicated | Receipt on retry | Delivered once |
| Sidecar crashes after SQLite write, before transport ack | AckWait lapses; redelivery attempt 2 after 5 s; sidecar on restart finds the row in SQLite and dedupes the redelivery, then sends the ack | fetched, redelivered, delivered | Trace shows one redelivery | Sees message once |
| Sidecar crashes after transport ack, before harness read | Message is in SQLite with read = false; on restart the sidecar presents it to the adapter | delivered, (gap), read | Trace shows "delivered, read after N s" | Sees message once |
| Harness crashes after read, before doing anything | Application ack was already sent; the bus does not redeliver. The sidecar keeps the message in its inbox with read = true; the inbox tool lists it again with redelivery = false, previously_read = true | read | Trace shows read | Can re-read via inbox --include-read |
| NATS leader change mid-publish | Publish returns an error or times out; gateway retries publish with the same Nats-Msg-Id up to 3 times over 2 s | accepted (possibly delayed) | Receipt delayed up to 2 s, else AB-9001 retryable | Delivered once |
| NATS leader change mid-delivery | In-flight messages without ack are redelivered by the new leader after AckWait | fetched, redelivered, delivered | One redelivery in trace | Sidecar dedupes on msg_id |
| Clock skew: sidecar 10 min ahead | Handshake measures skew; sends refused with AB-3005 envelope_time_skew until fixed; deliveries use server time for expiry | none | Clear error with hint | Unaffected |
| Clock skew: gateway replicas disagree by 2 s | Expiry decisions may differ by 2 s between replicas; acceptable. NTP alerting at 1 s in 10-INFRA-AND-OPERATIONS.md | — | — | — |
| Duplicate publish inside 2 min | JetStream duplicate; original receipt returned | deduplicated | Same receipt | One message |
| Duplicate publish after 2 min | Postgres messages.id conflict; original receipt returned | deduplicated | Same receipt | One message |
Same idempotency_key, different message id | tasks unique index conflict; existing task returned, no new message | task.deduplicated | Existing task id | One task |
| Message expires while in sidecar SQLite, unread | Sidecar's pre-present check fails; application nack with expired; sender notified | delivered, expired | system.expired.v1 in inbox | Never sees it |
| Message expires while in flight to sidecar | Gateway pre-fetch check fails; Term; sender notified | expired | system.expired.v1 | Never sees it |
| Recipient deleted with 40 pending messages | Consumer deleted; each pending message Termed; each sender notified | undeliverable x40 | system.undeliverable.v1 per message | — |
| Sender's token revoked while messages pending | Already-accepted messages still deliver (acceptance is final); new sends fail AB-1004 | — | AB-1004 on next send | Unaffected |
| Two sidecars connect for one agent | Newest connection wins; older is closed with AB-6013; in-flight messages on the old connection are redelivered to the new one after AckWait | fetched, redelivered | — | Possible duplicate, deduped on msg_id |
| Gateway cannot reach ClickHouse ingester | Audit events buffer in SYS_AUDIT stream (file storage, 7-day retention); ingester catches up | all events, late | Trace may lag | Unaffected |
16. Conformance test kit
The kit is a Go test binary (agentbus-conformance) that drives any adapter or SDK through a
mock gateway and asserts behaviour. Third-party adapters must pass every MUST test to be
listed in the console as "verified". Each test name is stable and appears in error output.
16.1 Transport (MUST)
TransportAck_AfterDurableWrite— transport ack is not sent before the local write is fsynced (the kit kills the process between write and ack and checks the ack never arrives).TransportAck_WithinDeadline— ack arrives within 30 s under nominal conditions.TransportNak_OnLocalWriteFailure— a read-only inbox store produces a NAK with delay, not a silent deadline lapse.Redelivery_DedupedByMsgId— a redelivered message with a knownmsg_idand prior application ack is not presented twice.Redelivery_PresentedWhenOnlyTransportAcked— a redelivered message that was only transport-acked is presented again withredelivery = true.Ordering_ByStreamSeq— messages fetched out of order are presented instream_seqorder within theMaxAckPendingwindow.Ordering_FlagsLateRedelivery— a lower-sequence redelivery is presented and flaggedout_of_order.Expiry_CheckedBeforePresent— a message whoseexpires_atpassed while queued locally is nackedexpiredand not presented.Expiry_UsesServerTime— with local clock skewed +10 min, expiry decisions still use theAB-Server-Timebaseline.Backlog_DrainsInOrderAfterReconnect— 500 queued messages drain in order after a reconnect.Reconnect_ResumesWithoutLoss— connection dropped mid-fetch; no message is lost or presented twice after application ack.AckOutbox_SurvivesGatewayOutage— acks generated while the gateway is unreachable are delivered when it returns.
16.2 Application (MUST)
ApplicationAck_AfterHarnessRead— ack only after the adapter's inject or inbox read succeeded.ApplicationAck_CarriesAckEnvelope_WhenRequired—requires_ack = trueproduces anagentbus.ack.v1to the sender.Framing_MarksAsData— the text handed to the harness contains the data-not-instructions wrapper, sender address, message id, and trace id.Framing_NeverExecutesPayload— payload containing tool-call-looking text is passed verbatim inside the wrapper, never interpreted by the adapter.
16.3 Sending (MUST)
Send_SignsEnvelope— every outbound envelope carries a valid Ed25519sig.Send_RetriesWithSameId— a lost response is retried with the sameid, never a new one.Send_HonoursRetryAfter— a 429 is not retried beforeRetry-After.Send_RejectsOversizedInline— a 2 MiB payload is uploaded as a blob and referenced, or rejected locally withAB-3003; it is never sent inline.Task_UsesIdempotencyKey— a retried task request reuses the key and accepts the existing task id.
16.4 Usage and presence (SHOULD)
Usage_ReportedWithSource— every usage record namesreported_by.Usage_NeverFabricated— when the harness exposes no usage, the adapter reports none rather than estimates (estimates must be labelledestimate).Heartbeat_Every20s— heartbeats arrive at 20 s ± 5 s.Presence_OfflineOnStop— a clean stop sends an explicit offline marker.
16.5 Diagnostics (SHOULD)
Doctor_ReportsSkew— skew above 30 s is reported withAB-1010.Trace_LocalTimelineMatchesServer— the sidecar's local receipt timeline agrees with the server's within 1 s for every event it participated in.
The kit ships with the sidecar (agentbus conformance run --adapter <name>) and runs in CI
for the first-party adapters on every commit.