Bridge Specification (formerly SPEC §5, §6, §12)¶
5. Bridge State Management (as implemented)¶
5.1 Durable intake¶
- Storage and upgrades: the
bridgeCNPG database; one shareddbutilpool (pgx driver) backs the mautrix SQL StateStore and the bridge's own tables. Bridge-owned schema changes run through the versionedbridge_versionupgrade table. An emptyDATABASE_URLkeeps the matching in-memory implementation for development only; it is not durable. - Pre-acknowledgement boundary: the appservice authenticates and parses each bounded transaction body, computes SHA-256 over its exact bytes, derives every eligible
(Matrix event, target ghost)job, and atomically commits the transaction plus all new jobs before Synapse receives HTTP 200. A storage or classification failure returns an error instead of acknowledging work that was not recorded. - Atomic capacity boundary: serialized admission counts all existing non-terminal jobs, including delayed and leased work, against
ROOM_QUEUE_CAPACITY(32 by default) andGLOBAL_QUEUE_CAPACITY(256 by default). For each new target it checks the room first, then global capacity, and immediately counts an accepted target for later decisions in the same transaction. A full target becomes a terminaldeniedrow in that same commit withqueue_room_capacity_rejectedorqueue_global_capacity_rejected; terminal rows do not consume future capacity, and their content fields are cleared before insertion. - Replay binding: replaying a transaction ID with the same exact-body hash is idempotent and passes the original bytes back through the mautrix consumer. Reusing that ID with changed bytes returns HTTP 409 without consuming the changed body or creating work. It also emits one content-free
log_stream=audit,audit_schema=fgentic.appservice_transaction.v1record withmsg="appservice transaction conflict", the transaction ID,outcome=rejected,terminal_reason=transaction_hash_conflict, and the stored/received SHA-256 values as lowercase hexadecimal; neither request body nor an error string is logged. The unique(matrix_event_id, ghost_mxid)key and an immutable intake fingerprint also reject conflicting evidence across different transaction IDs.
5.2 Ledger, recovery, and downstream delivery¶
- Tables and states:
mx_*remains owned/upgraded by mautrix.bridge_appservice_transactionsholds the transaction ID, exact-body hash, and commit time.bridge_delegationsholds one versioned job per event/ghost with checked statespending,a2a_prepared,awaiting_task,awaiting_input,reply_pending,delivered,denied,ambiguous, anddead.bridge_delegation_controlsis the bounded cancel/continuation/question/progress/pin outbox tied to the parent job and its lease generation.bridge_contexts(room_id, ghost, context_id, updated_at, PK(room_id, ghost))remains the conversation index.bridge_room_welcomesis the permanent content-free at-most-once onboarding marker, andbridge_room_encryption_refusalsis its counterpart for the at-most-once encrypted-room refusal (§6.1); both hold a room ID and a timestamp only, and neither is ever pruned.bridge_processed_eventsis the retained legacy marker table. - Fenced claims and order: one coordinator claims the globally oldest eligible work up to
CONCURRENCY. The oldest non-terminal job in a room blocks every later job in that room, including while it is delayed or leased, so unrelated rooms can run concurrently without losing cross-restart per-room FIFO. Each claim increments a lease generation; every heartbeat, transition, retry, and Matrix-event record checks owner, generation, and expiry, so a stale process cannot commit after another process recovers the job. - A2A boundary: the bridge persists a deterministic A2A message ID plus a content-free
a2a_attemptedboundary and transitions toa2a_preparedbefore invoking the A2A client. That ID is correlation evidence, not a claim that an arbitrary target implements idempotency. A failure proven to occur before the transport starts can retry with the same ID. Once the HTTP transport may have started, a missing acknowledgement becomes terminalambiguous: the bridge posts a generic notice and does not resend. An acknowledged non-terminal task persists its task/context IDs and recovery usesGetTask, never a secondSendMessage. - Matrix outbox: reply, working-placeholder, placeholder-edit, and per-index artifact event sends use distinct deterministic Matrix transaction IDs derived from the job. The primary returned event IDs are immutable ledger evidence. A crash can therefore replay a pending send through Matrix's transaction-id idempotency without creating a second room event. Media-repository uploads themselves have no caller transaction ID and can repeat before the stable artifact event is sent, leaving an unreferenced duplicate upload for homeserver cleanup.
- Retries: storage, Matrix, preflight, and task-poll failures use capped exponential backoff.
DELEGATION_MAX_ATTEMPTScounts consecutive recovery failures (default five); a successful state transition or healthy scheduled task poll resets the count. Exhaustion produces a generic notice where possible and terminaldeadevidence rather than an infinite retry loop. Dead-man cancellation after an accepted terminal Matrix event is the narrow exception: it keeps the jobreply_pendingand retries the idempotent cancellation at the capped delay until Synapse confirms cleanup, without re-projecting the reply or invoking A2A again. Declaring delivery while that timer remained armed would permit a false stale-task notice after success. - Atomic conversation/result evidence: an A2A context update and the corresponding job transition commit in one database transaction. A terminal agent result or notice first becomes durable
reply_pending, then Matrix projection advances the job to its terminal state. A recovered worker therefore resumes from durable protocol evidence rather than reconstructing progress from logs.
5.3 Retention and compatibility¶
- Content lifecycle: the recoverable Matrix event, prompt, pending result/notice, and active question/answer/progress control payloads are retained only while their checked workflow needs them.
awaiting_inputcommits the question in the job result field before planning its deterministic control, then clears that recovery copy once the outbox record exists; this closes the process-loss gap between the state and question writes. Every terminal job or control transition atomically clears its content-bearing fields. A control's immutable intake fingerprint remains as content-free replay evidence. Non-contentdeliveredanddeniedtombstones remain for at least 24 hours and are then eligible for periodic cleanup;ambiguousanddeadevidence remains indefinitely for operator review. Unreferenced appservice transaction tombstones follow the same minimum 24-hour window. - Legacy upgrade marker: a fresh durable intake honors a
bridge_processed_eventsmarker from the previous implementation for its minimum 24-hour window, preventing an upgrade-time replay from invoking an already processed event. Expired legacy markers are pruned opportunistically; they are compatibility tombstones, not durable completion records. - Process posture:
replicas: 1still means one ready appservice intake consumer and the Deployment keepsmaxSurge: 0,maxUnavailable: 1. Graceful shutdown stops new claims and lets active leased work drain. After SIGKILL, node loss, or process failure, expired leases make recorded work claimable by the replacement while the database enforces room order and fencing. This is work durability, not uninterrupted intake availability or a multi-replica bridge claim.
6. Async Delegation (as implemented)¶
- The routing and invocation allowlist is a versioned
agents.yamldocument governed by agents.schema.json. Current files declareschemaVersion: 1; an unknown major fails closed at startup and reload, while an omitted version temporarily defaults to v1 with a structured deprecation warning. Invalid reloads never replace the last-known valid map. Each local mapping declares the highestdataClassificationit may receive; omission defaults fail-closed toregulated. The bridge sends that reviewed class only on its authenticated local agentgateway request; it is never forwarded to a remote A2A target. A repository-owned local mapping's durable fingerprint binds both that classification andagentContractSHA256; a contract-only rollout or rollback therefore invalidates prepared work before another A2A call, preserving the persisted version evidence. Such mappings deliberately reject older fingerprints that cannot prove the contract. Remote and legacy unpinned mappings retain the existing exact pre-classification compatibility fingerprint while that producer rollout drains. - A local mapping enters the permission-aware retrieval path only with explicit
retrievalCapable: true; names and prompts never imply the control. Such a mapping requires anagentContractSHA256, forbidsmaxSessionAge, and uses the top-level exact-roomroomClassificationsregistry (publicby default;approved_non_publiconly by explicit room ID). Immediately before the initialSendMessage, the joined target ghost hot-reads current create/member/join-rule/history-visibility/server-ACL state, requires the caller and ghost to remain joined in an invite-only/joined-history room, classifies every effective reader, and sends the canonical ADR 0017 snapshot asX-Fgentic-Room-Context. It never loads or records a(room, ghost)context ID, rejects input/auth continuations, and caps task polling at the earlier ofTASK_TIMEOUTand 540 seconds. Expiry stops polling, attemptsCancelTask, and retains no grounded result. Before every terminal reply/edit attempt and each artifact post, the bridge re-reads the same state and classification; any missing state or digest drift terminatesdenied, scrubs the durable payload, and emits no grounded Matrix content. Matrix cannot make the final read and send atomic, so ADR 0017's residual time-of-check/time-of-use window remains explicit. - Durable transaction intake drops own bot/ghost senders and every message type except
m.textand — when it mentions an agent — a media message (m.file/m.image/…) carrying an inbound file (D8; #115), classifies the validated full sender MXID against configured external-appservice namespaces, resolves targets under D6's rules, and records one immutable row per target before acknowledgement. A valid/ask <agent> <prompt>or!ask <agent> <prompt>is normalized into that same target-resolution and durable-job contract before acknowledgement; it cannot bypass sender, stage, trust, cost, rate, media, or queue admission. Each target is exactly one local kagentnamespace/namepair, one remoteurl/timeout/tokenBudget/cardIdentitytrust pin, or oneacct:handle carrying that same A2A pin plus an independent FEP-8b32 actor/proof-age pin. The authenticated ActivityPub gateway broker performs SSRF-safe WebFinger and actor discovery; A2A remains verified by the bridge, while AP fallback requires the exact fresh actor proof and acknowledges asynchronous signed delivery only. Neither path receives the localA2A_API_KEY, and a handle alone never confers trust. A bridged sender is local by MXID but remains denied unless that target'sallowedSendersexplicitly matches it. The durable ledger replaces the initial in-memory dispatcher queue:CONCURRENCYbounds active claims,ROOM_QUEUE_CAPACITY/GLOBAL_QUEUE_CAPACITYbound non-terminal rows, andAPPSERVICE_TRANSACTION_MAX_BYTEScaps the exact request body (16 MiB by default). Capacity-denied rows are terminal, content-free, and do not enter the claim queue. - Worker (database-enforced per-room FIFO, global concurrency cap): atomically re-read the current origin, mapping, and sender policy before spending a limiter token or calling A2A. A removed grant or changed target fails closed; removing an origin rule cannot downgrade an already classified bridged event to native Matrix. A
stage: devagent invoked outside the bridge's configuredSTAGING_ROOMSis refused fail-closed before any trust, cost, limiter, or A2A step withoutcome=denied,terminal_stage=admission,terminal_reason=stage_policy_rejected, and one bounded policy notice; promoting it tostage: prodis a one-lineagents.yamlreload with no pod restart. For a remote target, the bridge first requires a currently trusted A2A v1.0 AgentCard: its ES256 signature over the JCS-canonical card must verify under the pinned P-256 public JWK, and its protected key ID, name, provider organization, exact JSONRPC or HTTP+JSON endpoint, and token-budget extension must match the configured pins. Startup, config reload, and independent five-minute refreshes revalidate that card. An unsigned, invalid, mismatched, or post-signature-tampered card normally quarantines the target before ghost registration and limiter admission, and emitsoutcome=denied,terminal_stage=agent_card,terminal_reason=agent_card_untrusted,rate_limit_verdict=not_checked, anda2a_attempted=false. A verified card that declares arequired: trueA2A extension the mapping does not activate is a negotiation gap, not a signature or identity failure: it quarantines the same way but audits the distinctterminal_reason=agent_card_extension_unsupportedsibling. Transport authentication is layered on top when configured (#244): a remote mapping may pin an A2A v1.0 mTLS client certificate (mtls:— SOPS-managedclientCertFile/clientKeyFile, optionalserverCAFile), which the bridge presents on both the card fetch and every delegation over a per-targettls.Config; the local gateway credential is still never sent. A verified card that declares an mTLSsecuritySchemewhile its mapping configured no client certificate fails closed (quarantined,ErrRemoteMutualTLSRequired) rather than silently downgrading the declared scheme. A client-cert rotation changes the target's opaque ID, forcing card re-verification and re-keying queued work. A per-generation transport guard closes the readiness race: if trust changes after the persisteda2a_prepared/attempt boundary, a copied stale client is refused before HTTP withrate_limit_verdict=allowed;a2a_attempted=truethen records conservative client-invocation intent, not a network side effect. If trust changes while polling an already-started task, polling stops with the same verdict plus the persisted task evidence. The separate Matrix membership-invite handler may register a configured ghost without delegating to it. - All bridge-generated onboarding, command, directory, and failure-catalog notices share a response plane with separate per-sender/per-room buckets; exhaustion is silent, so a rejected response cannot amplify a flood. With
WELCOME_ENABLED=true, the bot's first accepted room invitation owns one standalone automated onboarding notice. It renders the bounded directory for the inviter's full MXID, teaches@mentionplus the client-safe!ask/!agents/!budgetforms and raw-client slash equivalents, stores a permanent content-free room marker before notice admission, and uses a room-derived Matrix transaction ID; replay, rejoin, and process replacement cannot create a second welcome. The old chart-rendered static welcome is absent because it could not enforce per-sender visibility. The plaintext command parser recognizes leading/ask <agent> <prompt>,/agents [name], and/budgettokens plus the client-safe!ask,!agents, and!budgetforms and adds no SDK dependency. Element clients consume leading slash commands before sending, so their documented path uses the!forms; raw Matrix clients and integrations may use either. Unknown/malformed slash commands and unavailable agents fail closed with one bounded actionable notice, while unrelated!commands remain ordinary text./agentsand!agentsuse the same bounded gallery of the sender's allowed mappings with cached AgentCard name, description, declared skills, target type, and trust status; either form's<name>detail reads the same cache without another fetch. A quarantined remote remains visible as unavailable while its stale card claims stay hidden./budgetand!budgettake a non-mutating snapshot of the invoking room and sender-plus-agent request buckets and show configured remotemaxTokens/maxCostceilings; they create no limiter key, spend no invocation capacity, and label reservations as admission limits rather than observed token consumption. The future native Matrix command picker remains owned by #223. Replacing an existing working placeholder with its terminal state does not create a new timeline event and therefore bypasses new-notice admission; it must not leave a visible task permanently "working" after the ledger becomes terminal. Each invocation/notice sender/room map is independently capped at 4096 keys by default. Unknown keys fail closed at capacity without evicting an active bucket or resetting its burst; idle buckets become reusable through a bounded, at-most-once-per-minute sweep. A denied bridged sender posts at most one bounded policym.notice, emits an origin-attributed audit/metric, and never consumes invocation capacity or calls A2A. An allowed, trusted target then registers/joins its ghost, checks the invocation budgets (D7), and sendsSendMessagewith the(room, ghost)contextId and Matrix sender inX-User-Id. Command selection does not change that attribution-only boundary: authorization still uses the validated event sender and full-MXID policy, never the asserted A2A header. Local calls use the global 60-second transport deadline and bridge workload credential. Remote calls add their configured whole-delegation ceiling to the globalREQUEST_TIMEOUTandTASK_TIMEOUTand never inheritA2A_API_KEY. The whole-task clock begins at the first persisted A2A attempt boundary, not ledger admission, so room-backlog time does not consume the target deadline. Extension negotiation is first-class: every remote delegation activates the always-onhttps://fgentic.fmind.ai/a2a/extensions/token-budget/v1(carrying{maxTokens: tokenBudget}metadata — a partner-enforced request contract, not bridge-observed or hard local model-token accounting) plus each URI in the mapping's optionalextensions:list, sent on theA2A-Extensionsheader for exactly the subset the verified card also declares. New extensions are added only through reviewed config, never dynamically; the mapping'sextensions:doubles as the allowlist ofrequiredcard extensions it will trust. Which of the requested extensions the server echoes as activated is recorded in the delegation audit'sa2a_activated_extensionsfield — bounded to the requested set (an unsigned response header cannot inflate or inject into it), audit-only, never a control decision. A per-remotemaxCostadds a cost gate: when set, the bridge reads the fgentic-specificskill-quote/v1data-only extension from the verified card and refuses — before limiter admission or A2A dispatch — any delegation whose highest advertised credit-unit price exceedsmaxCost, or whose card carries no usable quote, withoutcome=denied,terminal_stage=admission,terminal_reason=quote_over_budget, anda2a_attempted=false(credit-units are bilateral accounting units, not currency; docs/federation.md §8.3). Bridged limiter keys include bounded network, full MXID, and agent; metrics keep only bounded ghost/outcome labels. - Terminal
Message/Task: post the extracted text (artifacts → status message → last agent turn) as the ghost — anm.noticereply to the original event. The agent's non-text products ride the same reply under the media policy (#115): each artifact Raw part that passes the exact-matchMEDIA_MIME_ALLOWLISTand byte caps is uploaded to the Matrix content repository and posted as a separate ghostm.file/m.image(alsom.automated-tagged); Data parts fold into fenced JSON code blocks; URL parts become labeled untrusted links the bridge never fetches server-side. Anything withheld appends one bounded, content-free⚠️ N attached file(s) withheld …note. A file-only reply gets a short caption instead of the empty-content fallback. - Reply-secret scan (#343): the reply→room boundary is a control point only the bridge owns, so before the terminal reply is persisted to
reply_pendingor projected, the bridge scans the exact user-visible content — the reply text plus every folded structured-data block and link label/URL — with a curated, high-precision set of structured credential patterns (AWS/GitHub/GitLab/Slack/Google/Stripe/npm/OpenAI tokens, JWTs, PEM private-key blocks, URL-embedded connection-string passwords). The scan runs at the pre-persist chokepoint so a detected credential never reachesreply_pending, the ledgerResultText, a log, an audit record, or the timeline.REPLY_SCAN_MODEis the same-org base posture andREPLY_SCAN_FEDERATED_MODEthe stricter posture applied when the delegation shows federation exposure (a remote-homeserver sender, a bridged origin, or a remote agent target); validation requires the federated posture to be at least as strict, and a room whose federation the bridge cannot observe still never leaks a raw secret because every non-off mode masks the value. Both takeoff|annotate|redact|block:annotate(default base) masks each matched span with a value-free‹redacted:<class>›placeholder and appends one bounded, content-free⚠️ N possible credential(s) detected and masked (<classes>)notice;redactmasks silently and delivers the remainder;block(default federated) withholds the whole reply and posts only⚠️ reply withheld …, emittingterminal_reason=secret_in_reply,outcome=denied,a2a_attempted=true. A clean reply is delivered byte-unchanged; setting both modes tooffrestores the prior post-unchanged behavior. The scan never records the matched value: it emits only a content-freefgentic_reply_secret_scans_total{ghost,action}metric and the audit's boundedsecret_scan_action/secret_match_count/secret_rule_classesfields. The bridge cannot readm.room.create/m.federate, so federation exposure keys off these bounded, contract-sanctioned signals rather than treating any partner-supplied MXID or homeserver as authenticated identity (docs/audit.md, docs/federation.md §8.3); raw file-artifact bytes stay governed by the media policy below, and block mode withholds them with the rest of the reply. - Media policy (#115): files are a sharper injection/malware vector than chat text, so the same exact-match MIME allowlist and byte caps (
MEDIA_MAX_BYTESper file,MEDIA_MAX_TOTAL_BYTESsummed per delegation per direction, plus a per-delegation file-count cap) gate both directions and fail closed.MEDIA_MAX_BYTES=0disables the media path entirely. Inbound: a file the mention references — the media event itself, or the single event it replies to (one extra fetch, never a thread walk) — is checked against its declared type/size before download, then downloaded through authenticated media via anio.LimitReadercapped atMEDIA_MAX_BYTES+1 (the untrusted declaredinfo.sizenever sizes the read, so an oversized blob is never fully buffered), and re-checked on real bytes before being forwarded as an A2A Raw part. Because the allowlist matches only the self-declared MIME (untrusted room/agent content), the bridge also content-sniffs each file and refuses anything that sniffs as HTML in either direction — a targeted guard against smuggling stored-XSS past a benign label; non-HTML mislabeling is a documented residual risk, so downloaded files must be treated as untrusted. A referenced file that fails policy (disallowed type, oversized, empty, encrypted — the current crypto-free bridge refuses encrypted events under ADR 0026 — or a remote target withoutallowMedia) fails the whole delegation closed with one bounded notice and no A2A call:outcome=denied,terminal_stage=media_admission,terminal_reason=media_input_rejected. Bytes cross an organization boundary only when the remote mapping setsallowMedia: true; without it the boundary stays closed to files in both directions. The bridge's Matrix media access token lives on a separate HTTP client and never reaches A2A; delegation audits carry content-freemedia_in/media_out/media_rejectedcounts. Filenames and link labels are sanitized (control characters and path separators stripped, length bounded) since they are untrusted agent- or room-supplied strings. - Non-terminal
Task: persist the acknowledged task/context IDs, send a deterministic "⏳ working on it…" placeholder, scheduleGetTaskwithout holding a worker, and edit (m.replace) that same placeholder through the deterministic edit transaction when the task becomes terminal. Healthy working responses reset the consecutive recovery-failure count;TASK_TIMEOUTremains the whole-task ceiling measured from the first persisted A2A attempt. Recovery never reinvokes a task whose ID is known. - Optional delayed-event dead-man switch (#119):
DEAD_MAN_SWITCH_DELAY=0preserves the ordinary task path exactly. With a positive delay of at least two minutes, startup probes/_matrix/client/versionsfororg.matrix.msc4140; a failed probe or absent feature disables the enhancement without failing readiness or task delivery. For each visible long-task placeholder, the ghost schedules one delayed automatedm.noticereply stating that the bridge stopped responding and the placeholder is stale, restarts its homeserver timer coarsely on healthy poll ticks, and cancels it only after Matrix accepts and the ledger records the terminal replacement. Durable jobs persist the returned delay ID under the fenced lease so a replacement process refreshes or cancels the same timer rather than scheduling an unmanaged duplicate; if that persistence fails, the bridge immediately attempts cancellation and reports both failures before recovery retries the stable scheduling transaction. A failed terminal cancellation keeps the outbox retryable with the accepted Matrix event ID already durable, so recovery retries only the idempotent cancellation beyond the ordinary delivery-attempt limit and never re-projects the result. A hard process loss, or a terminal path that cannot project an honest replacement, leaves the server-owned fallback armed, so Synapse materializes the notice after the configured delay. The notice is informational output only: it ism.notice, carries the automation marker, and can never become delegating input under D8. Synapse 1.155.0 advertises the capability whenmax_event_delay_durationis non-null; the current unstable send query and action-suffixed management path are isolated in the bridge client for the eventual stable endpoint rename. The local and GCP profiles use a five-minute bridge delay under a ten-minute homeserver ceiling; demo and federation settings keep the feature disabled pending their explicit runtime proof. - Interactive controls (#396) share the durable job fence and a per-job
CONTROL_CAPACITY_PER_JOBbound (16 by default). One slot is unavailable to ordinary controls and reserved for terminal unpin convergence, so cleanup cannot be starved by a full lifetime ledger. Repeated inbound events collapse to one authorized and one denied record per kind/input generation, so unauthorized reaction or answer churn cannot consume every control slot ahead of the original sender. Inbound ❌ cancellation commits the full authorized canceler MXID before acknowledgement; the original sender is always eligible and another member needsCANCEL_MODERATOR_POWER_LEVELin that exact room. The worker stops future polling, prepares once, calls A2ACancelTask, and terminally edits the placeholder with the canceler. Recovery of a prepared cancel iscontrol_ack_ambiguousand never re-sends. Other reactions and reactions to unknown/terminal placeholders remain ignored. TASK_STATE_INPUT_REQUIREDpersistsawaiting_input, the same task/context IDs, a deterministic question edit, and a separateINPUT_WAIT_TIMEOUT(10 minutes by default). That wait pauses the whole-task deadline; only the original full sender MXID may answer in the placeholder thread. One authorized answer generation is prepared with its deterministic A2A message ID and continues the same task/context; one different-sender attempt is retained as a content-free denied control, while further duplicates are bounded out before they can consume more slots. Expiry terminally edits the placeholder.TASK_STATE_AUTH_REQUIREDstill terminates withauth_required_not_forwardedbecause the bridge forwards no caller credentials.- Working text is persisted into at most
MAX_TASK_PROGRESS_POSTSdeterministic threaded notice slots (three by default); excess updates are dropped or collapsed without extending the bound across restart.PIN_IN_FLIGHT_TASKS=falseremains the default. When enabled, deterministic state transaction IDs converge the placeholder into and out ofm.room.pinned_events; a missing state-event power grant records onlypin_power_rejectedand never fails the delegation. Typing indicators remain process-local and are not recovery evidence. - Every ghost-authored event — replies, the working placeholder,
m.replaceedits, directory responses, and failure notices — also carries the MSC3955m.automatedmixin so other automation can reliably skip agent output at the content layer rather than by MXID heuristics (federation-safe: no localpart matching). The additive raw key rides alongside the untouchedm.notice, so mixin-unaware clients render replies unchanged; edits mirror it intom.new_contentso edit-aware clients keep the marker after applying the replacement. The key isorg.matrix.msc1767.automated: true— MSC3955's "Unstable prefix" section deliberately reuses the parent MSC1767 namespace (which is also what interoperating implementations emit); flip to the stablem.automatedonly once the MSC lands. - Known failures project the bounded, actionable catalog copy in §6.1. The catalog accepts only a stable reason, agent name, and optional timeout; provider-controlled A2A bodies, error text, and internal endpoints cannot enter the room. Unknown reasons retain the generic fallback and diagnostics log only stable codes, Go error types, and content-free identifiers. A terminal response with no text, data, links, or files is a failed
empty_reply, not a successful empty notice. - A durable capacity refusal makes no A2A call and consumes no invocation-rate token. For each capacity denial returned by a newly accepted transaction, the bridge increments
fgentic_delegations_total{outcome="queue_full"}, emits one content-free terminal audit withoutcome=queue_full,terminal_stage=queue, its stable room/global reason,rate_limit_verdict=not_checked, anda2a_attempted=false, then hands a content-free notice descriptor to a fixed-size worker after mautrix consumes the transaction. The worker applies the shared notice buckets and may emit one catalog notice; a full handoff or exhausted notice bucket suppresses it. The notice is best-effort process state rather than a durable outbox, and exact transaction replay does not duplicate the audit, metric, or notice. - Shutdown marks the appservice unready and asks the bridge-owned HTTP server for five seconds of graceful quiescence. On timeout it force-closes active transaction connections; a request that received no successful acknowledgement remains eligible for Synapse retry, while an exact replay of a committed transaction is safe. The processor barrier then drains ordinary Matrix handlers. The durable queue stops claiming new jobs and waits for active jobs during
SHUTDOWN_TIMEOUT; if that grace expires, canceling their contexts leaves non-terminal jobs recoverable after their leases expire. The chart's 45-second termination grace covers HTTP intake, event draining, durable-work grace, and telemetry cleanup. A hard crash follows the same persisted-state recovery rules without a graceful drain: unstarted work is reclaimed, known tasks resume withGetTask, pending Matrix output reuses its deterministic transaction ID, and acknowledgement-ambiguous A2A sends are not repeated.
6.1 Failure-message catalog¶
Every entry is an m.notice through the shared notice response plane. <agent> is the configured ghost localpart and <duration> is the bounded task deadline. Budget wording describes a configured admission limit; it never presents a quote reservation as observed token consumption.
| Terminal reason | User message |
|---|---|
room_encrypted_unsupported |
⚠️ This room is encrypted, so agent "<agent>" cannot read it and nothing was sent to it. Ask an operator for an unencrypted room, or remove the agent from this one. |
queue_room_capacity_rejected |
⚠️ This room already has as many agent requests as it can safely queue. Please try again in a moment. |
queue_global_capacity_rejected |
⚠️ The agent service is handling its maximum safe queue. Please try again in a moment. |
rate_limit_rejected |
⚠️ This agent has reached a request limit. Please wait a moment before trying again. |
quote_over_budget |
⚠️ This agent is over its configured budget limit, so the request was not sent. Ask an operator to review the limit. |
agent_card_untrusted |
⚠️ Agent "<agent>" could not be verified, so the request was not sent. Ask an operator to review its trust configuration. |
sender_policy_rejected |
⚠️ You are not allowed to invoke agent "<agent>". Ask an operator to review its sender policy. |
stage_policy_rejected |
⚠️ Agent "<agent>" is a staging build and can only run in its designated staging room. |
media_input_rejected |
⚠️ The attached file was refused by the media policy. Remove it or use an allowed file type, then try again. |
request_timeout |
⚠️ Agent "<agent>" did not answer before the request deadline. Try again once; if it continues, ask an operator to check availability. |
task_timeout |
⚠️ Agent "<agent>" did not finish within <duration>. Start a new request if the work is still needed. |
auth_required_not_forwarded |
⚠️ Agent "<agent>" needs authorization that the platform does not forward. The task was stopped; ask an operator for an approved access path. |
empty_reply |
⚠️ Agent "<agent>" completed without a reply. Try rephrasing the request; if it repeats, ask an operator to check the agent. |
a2a_ack_ambiguous |
⚠️ Agent "<agent>" may have received this request, but its acknowledgement was lost. The bridge did not resend it; check the agent before retrying. |
input_wait_timeout |
⌛ Agent "<agent>" received no authorized threaded answer before INPUT_WAIT_TIMEOUT; the task was dropped. |
control_ack_ambiguous |
⚠️ The requested cancel or continuation may have reached the agent, but its acknowledgement was lost; the bridge did not resend it. |
task_result_invalid |
⚠️ Agent "<agent>" returned invalid task information. Start a new request; if it repeats, ask an operator to check the agent. |
agent_failed / task_failed |
⚠️ Agent "<agent>" could not complete the task. Try once more or ask an operator to check the agent. |
task_poll_failed |
⚠️ The bridge lost track of agent "<agent>"'s task. Start a new request if the work is still needed. |
a2a_preflight_retry |
⚠️ Agent "<agent>"'s request could not be recovered after repeated failures. Start a new request if the work is still needed. |
| Unknown reason | ⚠️ could not reach agent "<agent>" — see the bridge logs. |
room_encrypted_unsupported is the only entry that is not the terminal state of a resolved delegation, because under encryption no delegation can be resolved at all: the mention sits inside ciphertext the crypto-free bridge holds no key for, so the durable intake classifier derives no job and no A2A call is ever made. The bridge detects the condition at three points — an m.room.encrypted event, an m.room.encryption state change, and a direct room-state read taken once immediately after a mapped ghost joins, which is the only way a room that was already encrypted before the ghost arrived can be seen. It answers as the first mapped ghost joined to the room, in stable ghost order, and stays silent in a room holding no mapped ghost, where nothing was lost and nobody is owed an explanation. Encryption is one-way, so a permanent room marker in bridge state is claimed before notice capacity is spent and bounds the whole room to exactly one notice and one terminal audit record (terminal_stage=room_encryption, a2a_attempted=false) no matter how many undecryptable events follow. Refusing loudly is the point: the alternative is a room where every mention silently produces nothing. A ghost still accepts an authorized invite into such a room before refusing, because posting the notice requires membership. Per-room encryption selection and staged bridge participation are governed by ADR 0026; the encrypted-room intake design note records why reading these rooms is a larger change than adding a crypto library, and what it would cost.
12. Testing & Validation Ladder¶
- Unit (§12.1) — done: the race-enabled suite covers target resolution, exact-body transaction replay/conflict, atomic per-target admission, checked state transitions, lease fencing/expiry/heartbeat, cross-worker per-room FIFO, consecutive retry accounting, terminal retention, deterministic A2A/Matrix IDs, lost-A2A-ack ambiguity, known-task recovery, and reply projection. The repository's
mise run checkgate verifies lint, vulnerabilities, formatting, secrets, rendered manifests, Terraform, workflows, and IaC configuration. - Contract (§12.2) — done: the in-process
a2asrvfixtures exercise empty and terminalMessagereplies, terminal and workingTaskresults, contextId reuse, request deadlines, transientGetTaskrecovery with capped exponential backoff, the literal kagent-compatible A2A wire version, JCS/ES256 Signed AgentCard acceptance, unsigned/tampered/identity-mismatched refusal, per-remote timeouts, token-budget extension metadata, and local-credential isolation. Because the fixture uses the SDK's server half but asserts the expected version independently, an SDK default change cannot silently move both client and fixture together. - Integration (§12.3) — done: the isolated kind fixture starts Postgres, Synapse, the real bridge, and an SDK-backed A2A server; registers the appservice; creates and invites a room; drives both local and signed remote
@mention → m.noticepaths through the Matrix client API; and directly replays the local transaction to require no second A2A call or Matrix reply. The remote executor also requires the extension URI in bothMessage.ExtensionsandA2A-Extensions, plus wire-decoded{maxTokens: 4096}metadata. Its fixed, test-only P-256 identity signs the card, then the driver mutates the card after signing and waits across two one-second refresh fetches before sending a negative mention. The fixture requires zero additional remote A2A requests and exactly one content-freeagent_card_untrustedaudit. CI runs it for bridge and fixture changes; it never connects to a model provider. - E2E demo script (§12.4 — M3 demo, M5 trace): the runbook-driven "enterprise showcase" path: bootstrap → login → room → mention → reply → one Grafana trace covering the full hop. The demo is the acceptance test.
- Load sanity (§12.5) — done: the isolated kind regression drives 100 mentions across ten rooms through real Synapse appservice transactions and a 2 s SDK-backed A2A stub. It asserts prompt transaction acknowledgement, at most 16 active delegations, bounded heap, durable backlog drain, per-room FIFO starts and completions, and exactly one reply per mention after transaction replay. See the dated reference measurements and re-run command.
- Availability (§12.6) — graceful path done: the isolated kind fixture holds one started A2A call, deletes the bridge pod normally, requires the old process to observe SIGTERM, releases the call, and requires one A2A completion and one Matrix reply. After a distinct replacement process becomes Ready, the driver directly replays the same event and requires no second A2A call or reply. This scenario measures graceful drain and replacement timing only.
- Crash recovery (§12.7) — fixture implemented, candidate run required:
mise run test:crash-recoveryuses real Postgres, Synapse, A2A, and bridge processes and SIGKILLs the bridge at twelve job/control boundaries: ledger commit before acknowledgement, acknowledged work before claim, A2A send/cancel/continuation acceptance before its record, durable control intent before claim, Matrix question/progress/pin acceptance before record or convergence, persisted result before Matrix, Matrix acceptance before event-ID record, and a known task blocked inGetTask. Run it on the candidate revision before claiming those hard-crash paths. Its diagnostic scenario duration is not a production RTO, and it does not simulate node loss.