Skip to content

Federation Spec (formerly SPEC §8) — the flagship differentiator, milestone M8

Design position: Matrix federation for the collaboration plane (humans + agents in shared rooms), A2A for the delegation plane (direct org-to-org machine calls) — they compose rather than compete, and Fgentic ships both:

The production operator workflow is the bilateral partner federation onboarding runbook. Its public preflight checks reachability and discovery only; allowlists, room policy, A2A authorization, and contractual controls require separate operator evidence. The complementary partner federation offboarding runbook revokes a partner across every plane in safe order, with the content-free evidence each step leaves.

8.1 What Matrix federation gives — stated honestly

  1. Identity is org-level, not agent-level: events are signed by the homeserver; org B can forge any @user:org-b.com, including its own agents. The honest claim is "cryptographically attributable to the partner organization". Per-agent identity is layered on with A2A v1.0 Signed AgentCards on the delegation plane.
  2. Room state and history replicate fully and irrevocably to every participating homeserver; redaction cross-server is best-effort ("gentlemen's agreement" — Matrix Foundation's own words). Data residency is a contractual control (DPA + mutual retention policy), not a technical one. The spec says so out loud; enterprises respect honesty about this more than silence.

8.2 Required hardening (all git-declared)

  1. Room version 12 minimum for any federated room (Hydra fix for malicious state resets — CVE-2025-49090 class).
  2. Closed federation: Synapse federation_domain_whitelist (mutual, includes own domain + a reachable key notary), federation listener firewalled to partner IPs where feasible.
  3. Server ACLs (m.room.server_acl) allowlisting partner servers per room; m.federate: false on rooms that must never federate (it is immutable at creation — set it deliberately, always).
  4. Synapse module callbacks (federated_user_may_invite, should_drop_federated_event) as the programmatic policy border — this is Fgentic's open equivalent of the "Secure Border Gateway" that TI-Messenger mandates between parties (and which Element gates behind ESS Pro; ours is policy-as-code instead of a paid appliance).
  5. Bridge sender policy (D6): per-agent allowedServers/allowedSenders; federated senders deny-by-default — already implemented and tested, ahead of M8.

The executable lab encodes the first four controls as one defense-in-depth contract. Every Synapse release sets default_room_version: "12". The admitted organizations A, B, and D each allow exactly the three admitted servers {A, B, D} in federation_domain_whitelist; org B serves as the reachable trusted_key_servers notary for A and D, so the proof has no public notary dependency. The denied control server is deliberately absent from every allowlist.

Room creation is equally explicit. The bootstrap helper is the lab's supported federated-room constructor: it requests room version 12, sets m.federate: true, and installs an initial m.room.server_acl state event whose allow list contains only A and B and whose allow_ip_literals is false. Applying the ACL as initial state prevents an ungoverned-event race. A separate local-only room deliberately sets m.federate: false; this creation-time flag is the operational default for rooms that must never federate, because it cannot be changed later. Flux owns the homeserver configuration, while the bootstrap owns and verifies the per-room state.

apps/synapse-federation-policy is the programmatic border loaded on A and B, but deliberately not C. Its strict, versioned JSON policy names the exact admitted sender servers, exact federated event types, and invite rule. Unknown fields, malformed identifiers, duplicate values, an empty allowlist, an unreadable file, or an invalid replacement deny every new event. federated_user_may_invite rejects an invite whose sender server is not admitted; should_drop_federated_event rejects an unadmitted sender server or event type. Synapse does not pass the authenticated transaction origin to these callbacks, so federation_domain_whitelist remains the transport-origin control.

Each rejection emits one structured, content-free policy record with the reason, sender server, event type, room ID, event ID, policy digest, and policy counts; event content is never logged. Normal /send events reach the callback after signature and hash validation. Synapse invokes the invite endpoint's early drop callback before its signature check, so an invite-path record is diagnostic policy evidence, not authenticated attribution by itself; only the later accepted federation flow can supply that evidence.

The module source is projected from the immutable, versioned fgentic-synapse-federation-policy-v1 ConfigMap. The operator-edited policy uses the stable fgentic-federation-policy name so Kubernetes can update its full-directory projection without a subPath mount or workload rollout. Both volumes reach the ESS config-check hook and Synapse runtime. The module detects an atomically projected policy replacement on the next callback, validates the whole file before use, and logs its digest. Flux remains the only field manager: changing policy.json in git and reconciling the new source revision changes the border without rebuilding Synapse.

Synapse 1.157.2 calls the drop callback once before staging an inbound PDU and may call it again while draining that room's staging queue. A policy tightening between those calls must not reverse the first admission: upstream's second-call drop path leaves the row in place and would otherwise select it forever. On a denial, the module therefore asks Synapse's database whether that exact (room_id, event_id) is already in federation_inbound_events_staging. An existing row is a local prior-admission fact and is allowed to drain; an absent row is a new event and remains denied. This query deliberately couples the workaround to the pinned Synapse schema without adding a table or retaining event content. Every Synapse upgrade must re-run the module and live reload gates; remove the workaround only after upstream deletes a staged row on its drop path.

8.2.1 Partner trust registry — one validated source of truth

The four hardening controls above plus the delegation plane (§8.3) are enforced across five separate planes — Synapse federation_domain_whitelist, per-room m.room.server_acl, the callback policy.json, the pinned AgentCard identity, and the admitted A2A consumers' azp/issuer/quota descriptors. Hand-editing five planes risks admitting a partner on one and forgetting another; for a regulated adopter that drift is an audit finding, because "who is trusted" must have one answer. infra/federation/registry/partners.yaml is that answer: a schema-validated registry (partners.schema.json) keying each partner by exact Matrix server_name (FQDN, never a localpart — D6) with its allowlist membership, admitted A2A azp/issuer/JWKS path/audience, pinned AgentCard key IDs, per-partner budget reservation, and data classification. The multi-party lab (issue #354) carries two admitted A2A consumers with distinct per-azp reservation budgets; the renderer maps the two admitted partners to fixed platform-settings slots, and the gate binds every admitted issuer to the shared federation IdP (§8.5). It holds verify-only public material — never a private key or secret.

Trust is also time-bounded (issue #463): each admitted partner carries a required review_by and an optional valid_until (both in the registry and the signed agreement, so a window cannot change without re-signing). A passed valid_until fails check:fed-registry closed — federation trust config will not reconcile past a hard expiry — and the opt-in federation-trust-review PrometheusRule (rendered from the registry by fed:trust-alert-render) raises a content-free review-due/expiry alert. Expiry blocks reconciliation and warns; it is not a live-traffic kill-switch — immediate cutoff is break-glass containment (onboarding §9).

The registry is the source; the enforcement planes are derived. mise run fed:registry-render regenerates the two authored artifacts that carry partner identity as literal values — the callback policy.json allowed_servers and the federation identity/budget keys of clusters/federation/platform-settings.yaml — and Flux post-build substitution propagates those keys into the templated planes (Synapse whitelist, A2A routes, agentgateway policies, rate-limit, the signed AgentCard, Keycloak). Rendering a clean tree is a byte-identical no-op; Flux remains the only field manager for the rendered outputs. mise run check:fed-registry then fails closed on any divergence: an unknown field, a localpart-based identifier, a duplicate or empty allowlist, a plane whose value disagrees with the registry, or the denied control server leaking into any plane. The remaining pinned constants that are not Flux-templated — the seed ACL server literals, the admitted azp client ids, and the signing key IDs — are held to the registry by the same gate, so no plane can merge in a state the registry does not sanction. The denied control server (org C) is absent from every rendered plane, proving deny-by-default survives rendering.

8.3 Cross-org delegation plane

When org B should invoke an agent in org A (not just converse with it), expose selected A2A endpoints through agentgateway on a dedicated Gateway listener with JWT (OIDC federation between orgs) or mTLS per A2A v1.0 security schemes, a Signed AgentCard published at the well-known path, and per-consumer quotas. Signed cards prove the declared agent identity; they do not replace transport authorization or authenticate the natural person behind a Matrix MXID.

The outbound bridge half is implemented. An agents.yaml entry chooses exactly one local kagent namespace/name target or one remote target with an exact url, positive request timeout, scalar tokenBudget, and pinned cardIdentity (name, provider organization, protected key ID, and ES256 P-256 public JWK). The remote timeout is an additional whole-delegation ceiling combined with the bridge's global request/task timeouts. The token budget is partner-enforced extension metadata, not bridge-observed model-token accounting. The bridge fetches <url>/.well-known/agent-card.json, verifies the A2A v1.0 JWS over the JCS-canonical card under that public JWK, requires the configured identity, exact JSONRPC or HTTP+JSON interface, and token-budget extension, and revalidates it independently every five minutes. It never forwards the local agentgateway A2A_API_KEY to a remote URL. Unsigned, mismatched, or tampered cards quarantine the target before limiter admission or A2A dispatch with bounded agent_card_untrusted evidence. The provider-free kind fixture proves one valid remote mention round trip and the post-refresh tamper refusal.

Zero-downtime signing-key rotation (#352). cardIdentity accepts a set of currently-valid keys, not one: the primary keyID/publicKey plus optional additionalKeys (each its own keyID+publicKey), and optional revokedKeyIDs. A card verifies if any signature is a valid ES256 signature under a pinned, non-revoked key ID, so during a rotation overlap window both the partner's old and new keys are pinned and a card presented under either keeps verifying — the retiring peer never fails while it re-pins. Once the peer has cut over, the operator promotes the new key to primary, drops it from additionalKeys, and lists the retired key ID in revokedKeyIDs; a card offered under a revoked key ID is then refused deterministically even if still cryptographically valid, quarantining the target. The security invariant is that a key ID can never be both pinned and revoked (that fails closed at config load), so acceptance only ever occurs through a currently-pinned, non-revoked key — the revoked set is consulted only to attribute the rejection reason. Rotation is a GitOps agents.yaml change (a new pin set re-keys the opaque target and forces re-verification), and the five-minute revalidation and post-refresh tamper refusal are unchanged. The exporter's complementary half — publishing a multi-signature card during the overlap and retiring its own signing key — is the signer-side lifecycle work.

The federation profile implements the complementary inbound half for exactly one org-A agent. The only public invocation URL is POST https://a2a.org-a.fgentic.localhost/api/a2a/kagent/docs-qa; its card is the anonymous GET at the same URL plus /.well-known/agent-card.json. Traefik's https-a2a listener admits routes only from agentgateway-system, and those routes attach to agentgateway's separate federation-a2a listener on :8081. Both paths are exact matches: no prefix route publishes another Agent, and kagent itself remains a ClusterIP service behind namespace NetworkPolicy.

The lifecycle substitutes the two organization hostnames into a tracked unsigned card, constructs the signing payload without signatures, normalizes A2A protobuf defaults, canonicalizes it with RFC 8785 JCS, and adds an ES256/P-256 JWS with a protected kid. Signing and remote verification use the same Go package. The corresponding verify-only public JWK is a non-secret ConfigMap artifact. The private PEM persists only in the lifecycle-owned flux-system/fgentic-demo-bootstrap Secret; that Secret also retains the public key ID so repeated lab starts keep the same trust anchor. Private material never enters the ephemeral Git snapshot, the served card, the public JWK, or a workload namespace. The lab verifier reads the JWK artifact directly; a production partner must receive and pin that trust anchor through a separately authenticated onboarding channel.

Two edge-authentication patterns remain explicit:

  1. OIDC JWT — implemented and proved by the lab. Org B's Keycloak realm issues five-minute access tokens through client_credentials. agentgateway validates the exact issuer, fgentic-a2a audience, signature through the issuer's JWKS, and authorized-party claim azp=org-b-a2a. A missing, malformed, or wrong-audience token is unauthenticated; a valid token for a different client is unauthorized. This identifies the partner service client, not a Matrix user or natural person.
  2. mTLS — documented alternative, not implemented by the lab. A deployment may replace the JWT policy and card security scheme with a dedicated client-certificate listener that trusts an approved partner CA, maps an exact SAN to the consumer identity used by the quota descriptor, and owns certificate issuance, rotation, and revocation. It must retain the same exact routes, fail-closed authorization, and per-consumer quota contract; TLS termination without client-certificate validation is not this option.

D18 composes permission-aware retrieval from this already-validated machine-client identity. Only one exact registry entry keyed by (issuer, audience, azp) may project namespaced partner groups; a bare azp, JWT group claim, partner-supplied Matrix identity, or homeserver domain grants nothing and can never inherit a local principal or group. The mapping and retrieval projection remain future behavior until #333 implements ADR 0017.

The public POST route admits exactly A2A v1 SendMessage and GetTask. Both operations require A2A-Version: 1.0 and an A2A-Extensions header naming the token-budget and usage-receipt extensions. SendMessage additionally carries both extension URIs in the message body, plus an integer maxTokens from 1 through the configured per-request ceiling. The version header selects kagent's v1 JSON-RPC binding rather than its compatibility-default v0.3 binding. After JWT validation, agentgateway passes maxTokens as the SendMessage request cost to a fail-closed global rate-limit service keyed by the verified azp; GetTask makes no new reservation. This is admission accounting: it reserves the caller-declared maximum before kagent runs. The downstream LLM hop separately reports actual provider/model token metrics, but those series are aggregate and cannot be attributed to an azp, A2A task, or Matrix sender. Never report the reservation as consumed tokens or the aggregate model metric as per-consumer usage.

After authentication, authorization, and reservation succeed, agentgateway buffers the bounded A2A request and response through a private Envoy external processor. A terminal Task receives one fgentic.usage-receipt.v1 object under https://fgentic.fmind.ai/a2a/extensions/usage-receipt/v1 in its protocol-native metadata: wrapped result.task.metadata for SendMessage, or direct result.metadata for GetTask. A direct A2A Message is rejected because it carries no task state from which the seller could honestly assert a terminal outcome. The receipt binds the verified azp, task/context IDs, RFC 8785 hash of the original SendMessage, reserved ceiling, UTC completion time, terminal outcome, and protected key ID. tokensConsumed is intentionally null: the seller asserts admission evidence, not unavailable per-consumer actuals. Nonterminal Tasks persist only their content-free request hash and reservation until the matching authenticated GetTask becomes terminal.

The receipt is JCS-canonicalized and signed with ES256 under a dedicated P-256 key whose public JWK and key ID are pinned inside the already-signed AgentCard. The AgentCard and receipt keys remain separate so the online receipt processor cannot forge discovery identity. Signed receipts are appended as JSONL to single-writer persistent storage before they are returned; prompt and response content are absent. NetworkPolicy admits only agentgateway's proxy to the processor, and policy ordering keeps denied or unpublished prompts from invoking it. The lab verifier checks the signature and schema, rejects a modified receipt, and proves that only the authorized terminal request increments the archive.

The v1 receipt is seller-signed only. A later settlement profile may add an org-B countersignature over the unchanged seller receipt, acknowledging receipt and bilateral ledger posting without changing what org A originally asserted; countersigning must not turn a reservation into consumption or payment evidence.

Beyond that per-request token ceiling, a remote card may advertise per-skill price quotes through the fgentic-specific data-only extension https://fgentic.fmind.ai/a2a/extensions/skill-quote/v1 — a set of {skillId, unit, price, maxTokens} entries carried inside the already-signed AgentCard, so the price is tamper-evident with zero new protocol machinery and a quote change re-signs the whole card atomically. Credit-units are bilateral accounting units agreed between two organizations, never currency, and the quote is not a standard A2A field. When an operator sets a per-remote maxCost, the bridge reads the quote from the verified card and refuses — before spending a limiter token or dispatching A2A — any delegation whose highest advertised price exceeds maxCost, or whose card carries no usable quote, auditing quote_over_budget. Because the bridge cannot pin which skill a free-form room prompt will exercise, it compares the worst-case (highest) quoted price. This is discovery-time price transparency; it composes with the signed usage receipt and the future bilateral credit ledger (#143), and its {unit, price} vocabulary is deliberately schema-adjacent to AP2 mandates so a later migration is a rename, not a redesign.

No production remote or public Agent is configured by default. The listener, disposable credentials, signed artifacts, quota store, docs-qa Agent, and deterministic model are opt-in federation-lab resources; they make no provider or external-network connection.

8.3.1 Signed bilateral agreement as the enforcement source

The bilateral quota, allowed data classification, and residency an operator negotiates in the onboarding runbook §6–§7 are prose approved by humans; nothing stops the enforced per-azp reservation, server ACLs, and classification from silently diverging from what both parties signed. A signed agreement artifact closes that gap (issue #353). infra/federation/agreements/<partner>.yaml is the machine-readable, signed source for a partner's admission reservation (D7 maxTokens), per-minute quota, and allowed classification, keyed by exact server_name/azp. It is signed with a detached ES256/P-256 signature over the file's exact bytes (<partner>.yaml.sig), verifiable with the committed public key signing.pub; the signing private key is held out-of-band by the agreement owner and never committed, so a term change is a deliberate re-sign. Stated plainly: because the public key travels in-repo, the signature's job is to make an edit-without-re-sign fail closed (the realistic accident or drift), not to defend against an actor with repo-write who could re-sign with a fresh key and swap signing.pub — git history and peer review remain the integrity control for that, exactly as for any committed source.

mise run fed:agreement-render syncs the signed reservation/quota/classification into the trust registry §8.2.1, which renders the enforcement planes — so the values agentgateway's rate-limit and the callback border enforce are exactly the signed ones. mise run check:fed-agreement fails closed on a tampered or unsigned agreement, a classification beyond the ADR 0015 bound (only public or partner-approved internal; never restricted/regulated/secret), or a registry that diverges from the signed terms — so editing enforcement without re-signing does not reconcile. The reservation stays admission accounting, never measured consumption (D7/D8): it reserves the caller-declared maximum before kagent runs, and the aggregate model metrics are never attributed per-azp. Residency is a recorded, surfaced contractual control — Matrix replicates room state to every participating homeserver, so residency is honored by agreement and operator jurisdiction, never claimed as a transport guarantee.

8.4 Federated-room encryption stance

ADR 0015 continues to govern current scoped plaintext federated agent rooms while ADR 0026 stages per-room encryption. Every production or real-partner room is new, private, invite-only, purpose-specific, and starts with m.room.history_visibility: joined; it carries a visible plaintext/replication warning, names one allowed data class, and completes the bilateral onboarding agreement before activation. Public and explicitly partner-approved non-public content are the only supported classes; restricted, regulated, secret, and authentication material stay outside the room. The current bridge refuses encrypted events until its staged Matrix crypto participation lands.

This is a disclosed risk position, not an encryption claim: every admitted homeserver operator can read and retain room content. The ADR defines the compensating controls and hard revisit triggers for a future mautrix appservice-E2EE or accepted Matrix MLS/DMLS path.

The provider-free acceptance rig is the sole narrow exception: it may use test-specific membership/history state without a bilateral contract only for synthetic identities and public/generated fixtures. It is never a real-partner or non-public-data environment.

8.5 Permanent federation acceptance rig

mise run fed:up is the executable M8 baseline and permanent provider-free acceptance rig: three independently named participating Matrix homeservers (a multi-party consortium) plus one denied control homeserver in the separately owned fgentic-fed k3d cluster. The maintained manifests and proofs are permanent; disposable describes only the cluster lifecycle, because fed:down removes the owned runtime wholesale. The baseline remains bridge-free and deliberately uses one Kubernetes control plane. It exercises Matrix discovery, TLS, server signing, event replication, federation authorization, and rejection of an untrusted server while keeping cross-cluster routing out of the first acceptance boundary. It does not claim infrastructure or failure-domain isolation — all four homeservers share one control plane; use separate clusters when testing independent networks, control planes, or disaster recovery. The normal local/demo profiles bind 127.0.0.1:80/443, while this lab binds 127.0.0.2:80/443; the host-side proof pins every federation hostname to that second loopback address, so the lab can coexist with the shared local cluster when the workstation has enough capacity.

Set FGENTIC_FED_AGENTS=yes on mise run fed:up to compose the opt-in agents Component. It adds only organization A's Matrix↔A2A bridge, its appservice registration, and the deterministic @agent-docs-qa:org-a.fgentic.localhost mapping. Agents mode extends the same room-v12, closed-allowlist, server-ACL, and full-MXID sender-policy proof with a partner mention whose ghost reply federates back from the provider-free docs-qa runtime. Omitting the variable renders and executes the canonical baseline unchanged.

Organization Matrix server name Namespace Synapse database Lab role
A org-a.fgentic.localhost matrix synapse Federated participant · A2A route exporter
B org-b.fgentic.localhost matrix-b synapse_b Federated participant · A2A consumer (IdP)
D org-d.fgentic.localhost matrix-d synapse_d Federated participant · second A2A consumer
C org-c.fgentic.localhost matrix-c synapse_c Denied negative control

All four homeservers are Synapse-only ESS Community releases. They share one CloudNativePG cluster but have separate roles, databases, credentials, and namespace-local credential copies. Each server owns its apex /.well-known/matrix/server delegation and local-CA certificate; the public lab CA is mounted as outbound federation trust. The same cluster additionally runs the shared machine-client Keycloak realm (hosted at org B's identity domain), agentgateway, the single docs-qa kagent Agent, and the deterministic demo-model endpoint for the delegation proof. It runs no MAS, Matrix-to-A2A appservice, external model, provider account, or paid service.

Federation is closed among the three admitted participants: A, B, and D each admit exactly {A, B, D} through federation_domain_whitelist and the shared room's m.room.server_acl, rendered from the trust registry (§8.2.1) so the three-way allowlist stays a single-source change. A and D each trust org B as their signing-key notary. C is routable only so the acceptance test can make a real federation attempt; it is not admitted by any participant's domain allowlist or the shared room's server ACL, and its join and signed sends fail closed against all three admitted servers. Each Gateway listener also accepts HTTPRoute attachments only from its owning Matrix namespace (and the HTTP redirect only from gateway). The duplicated controls are intentional: ingress ownership and homeserver policy limit federation globally, while room state remains an independently replicated authorization boundary.

Multi-party surfaces the questions bilateral federation hides. N-way attribution: each of A, B, and D sends one message into the single shared room that arrives at both other participants, and every receiver resolves the sender to its exact full MXID (@alice:org-a…, @bob:org-b…, @dave:org-d…) — never by localpart (D6). Two independent A2A consumers, each correctly attributed: org B and org D both invoke the same docs-qa agent, authenticating through the same shared federation IdP and distinguished only by their verified azp (org-b-a2a, org-d-a2a). Org A exposes a dedicated per-consumer invocation host for each — a2a.org-a… for org B, a2a-d.org-a… for org D — and each route carries its own azp-bound seller-receipt signer and archive. A completed org-D delegation therefore mints exactly one usage receipt stamped org-d-a2astructurally never org-b-a2a, regardless of the reserved maxTokens, because the signer identity is fixed by the route, not derived from request content. Each consumer also holds its own per-azp reservation counter with a distinct per-minute budget (org B: 5,000; org D: 2,000): a 3,000-unit reservation org B accepts is refused for org-d-a2a, and org D succeeds even after org B is exhausted, proving the reservations are independent — one member can never consume another's budget (D7/D8), and a reservation is never reported as consumption.

The shared federation IdP stays D6-safe. Relaxing the earlier per-partner issuer == id.<self> binding to a shared broker keeps every guarantee by construction: check:fed-registry requires all admitted consumers to carry a byte-identical issuer, and requires that issuer to be an exact member of the set of registered admitted partners' id.<server_name> domains (a whole-line fixed-string match — no wildcard, substring, suffix, or normalized-host value passes). The issuer host is therefore always an exact FQDN of a trust-registry admitted partner, never a localpart; azp, not the issuer, distinguishes the consumer.

Run the proof from a clean workstation with Docker, Git, and mise:

mise trust
mise install
mise run fed:up

The command creates or reuses only the owned fgentic-fed cluster and reconciles the federation profile through Flux. Before the Matrix proof, it verifies the exact served AgentCard bytes and ES256 signature, obtains an org-B client-credentials token, rejects missing/malformed credentials, wrong audience, wrong azp, invalid token budgets, unsupported methods, and unpublished Agent paths, then invokes docs-qa through the public route. The deterministic reply and an increase in aggregate model-token metrics prove the downstream A2A → kagent → model path; a second 3,000-token reservation in org B's 5,000-token window must return 429. It then obtains an org-D client-credentials token and proves the full second-consumer flow on org A's dedicated a2a-d.org-a… host: a successful org-D delegation returns a completed Task and mints exactly one usage receipt correctly stamped org-d-a2a (verified azp == org-d-a2a, never org-b-a2a) on org D's own signer and archive, and a second reservation exhausts org D's distinct 2,000-unit budget with a 429 keyed to org-d-a2a only. The acceptance dies if either consumer's completion ever writes a receipt attributed to the other. This proves correct per-consumer attribution and independent per-azp admission without conflating reservation and consumption, and that one member cannot spend another's budget.

8.5.1 Scoped A2A TCK gate

After mise run fed:up, mise run fed:tck runs the official A2A TCK 1.0.0 pinned at commit 5996b79f9cefa6fc390980e383e358a66fb9e49e against the exact public docs-qa route. The command obtains org B's short-lived client-credentials token, uses the local CA, and exercises the MUST tier over JSONRPC with the same version, extension, and token-budget contract as a partner invocation. It scans staged reports for both runtime credentials before publishing any artifact.

scripts/a2a-tck-scope.json classifies every MUST node collected from that pin. Unknown or multiply classified nodes fail collection, every deliberate skip carries its reviewed reason, and outcome or skip-reason drift fails the command. The scoped claim covers the signed public AgentCard, authenticated SendMessage and GetTask, Task and Message response models, JSON-RPC envelope and media type, task/context variants that use only those exported methods, and the GetTask not-found code and ErrorInfo shape.

This is not a blanket full-server conformance claim. Unadvertised gRPC, HTTP+JSON, streaming, and push-notification bindings; non-exported list, cancel, subscribe, and extended-card operations; raw probes that omit required partner-policy inputs; and scenario-specific artifact fixtures remain outside the exported route. Two applicable nodes are conditional skips because the deterministic agent completes immediately while their fixture requires TASK_STATE_INPUT_REQUIRED. The pinned camel-case assertion is also skipped explicitly because it interprets arbitrary keys inside the protocol's open Metadata maps as A2A model fields; the separate v1 response-schema node validates the actual protocol field names.

Three consecutive runs on 2026-07-14 produced 33 passed, 202 annotated skips, zero failures, zero missing outcomes, and the same node/outcome set. The authoritative scoped result is .agents/tmp/federation-tck/fgentic-scope.json; compatibility.html, compatibility.json, tck_report.html, and junitreport.xml in the same ignored directory are broader upstream diagnostics and do not override the reviewed route scope. For the required tripwire proof, an ignored local stub returned a valid JSON-RPC body with Content-Type: text/plain; TestJsonRpcFormat::test_content_type_is_application_json failed with Expected Content-Type application/json, got: 'text/plain' and passed after restoring application/json. The stub, report, and synthetic credential were removed after the one-off proof.

The Matrix half provisions Alice on A, Bob on B, Dave on D, and Charlie on C; verifies the room-v12, {A, B, D} ACL, and explicit federation state on all three admitted homeservers; and requires a message from each of A, B, and D to arrive at both other participants, each event resolving to the exact sender MXID. For the negative paths, Charlie's join must fail, a locally accepted C-signed transaction first proves the probe signature is valid, and that same signed federation send must be forbidden by A, B, and D. Bob then sends a disallowed custom event as the final event in a dedicated throwaway room: B must retain it, A must return M_NOT_FOUND, and A must log the exact content-free denial record (with allowed_server_count of three) without the event's unique content sentinel. Keeping this probe final avoids extending a room DAG after one participant deliberately dropped an event. The command leaves the cluster running so the homeservers, delegation boundary, and reconciliation state can be inspected. No provider connection or paid service is used.

Run mise run fed:policy-reload for the stronger reconciliation proof. It starts from the canonical deny policy, records both Synapse pod UIDs, publishes an ephemeral local-git revision that admits only the probe event type, proves the event reaches A, and then reconciles the canonical deny policy again. The same pods must serve all three revisions, and the final denial must pass again. A failed drill deletes only the owned disposable lab rather than leaving the relaxed probe policy active.

Remove the lab when inspection is complete:

mise run fed:down

Teardown is ownership-guarded and verifies removal of the lab-owned containers, network, k3d image volume, attached anonymous volumes, and locally built images. Before its first delete it atomically writes fgentic/cluster-teardown/fgentic-fed.json below XDG_STATE_HOME (or below ~/.local/state when XDG state is unset) with only the lifecycle generation and exact Docker identities; FGENTIC_DEMO_STATE_DIR overrides that state root. If the process stops, rerun fed:down; it validates the receipt and every surviving name, label, creation identity, and recorded attachment before resuming. An absent or malformed receipt, name reuse, or conflicting live resource fails closed with inspect-only diagnostics. fed:status reports recovery-pending without mutation, and fed:up refuses to start until recovery finishes. The receipt contains no credentials, Kubernetes data, logs, Matrix content, or A2A payloads; it protects against accidental adoption within the local-user/Docker-daemon trust boundary, not a hostile user who already controls either. The normal local, demo, and production profiles are separate and remain untouched.

8.5.2 Constrained-host profile, evidence, and lifecycle

The canonical mise run fed:up render and acceptance contract remain unchanged. mise run fed:up:constrained is an explicit, federation-only opt-in for a memory-constrained workstation: it composes the canonical profile, retains all four Synapse homeservers, shared CloudNativePG cluster, shared Keycloak issuer, Traefik, Flux, agentgateway, docs-qa/kagent runtime, deterministic model, policy controls, and Matrix/A2A proofs, then adds lab-scoped workload/controller tuning and a serialized first-install DAG. It is a capacity profile, not a reduced federation proof or a production sizing recommendation.

The constrained Component owns every Git-rendered adjustment. Bootstrap-only controls are outside that DAG and therefore belong to the lifecycle script. Flux and metrics-server settings are reversible; the k3s Go runtime budget is a node creation-time setting. The owned server records standard or constrained capacity, and fed:up refuses an in-place mode switch. Run fed:down before moving between those modes so canonical fed:up always creates an unmodified k3s process.

Runtime surface Constrained profile Canonical restoration
Flux bootstrap controllers 64Mi request, 256Mi limit, GOMAXPROCS=1, GOGC=25, and GOMEMLIMIT from requests.memory 64Mi request, 1Gi limit, no GOMAXPROCS/GOGC override, and GOMEMLIMIT from limits.memory
Git-managed platform controllers Traefik uses GOGC=25 and a 50% Go memory target; cert-manager and CNPG use GOMAXPROCS=1, GOGC=25, and GOMEMLIMIT=64MiB The constrained Component is absent, so their canonical Helm values and rendered workloads are intact
k3s metrics-server Scaled to zero because neither acceptance proof nor the content-free CRI sampler needs the resource API Restored to one replica; a zero-to-one transition waits for rollout before reconciliation and proof
k3s server process Created with GOGC=50 and a GOMEMLIMIT=1GiB soft target A standard node has no Go runtime override; cross-mode reuse is refused

Consequently, kubectl top is unavailable while the constrained lab is running. The proof still retains the same runtime-object inventory; metrics-server is a k3s bootstrap add-on rather than a federation Flux object. The remaining measured envelopes and allocator settings for Synapse/HAProxy, Postgres, Keycloak, agentgateway, kagent, and docs-qa stay executable in the Component and its offline contract rather than being duplicated here.

Command Federation-lab contract
mise run fed:up Create/reuse only standard capacity, restore canonical runtime defaults, reconcile and prove the unchanged lab, then leave it running for inspection.
mise run fed:up:constrained Create/reuse only constrained capacity, then reconcile and prove the same lab through the explicit constrained-host overlay and runtime profile.
mise run fed:status Refuse foreign ownership; report pending receipt recovery without mutation, absent with zero retained bytes, or capacity mode, running/stopped state, running/total containers, image-volume bytes, retained-cluster bytes, and local-image bytes.
mise run fed:stop Refuse foreign ownership, stop every cluster container, and verify that the exact owned cluster containers and k3d image-volume identity survive for the next same-mode fed:up.
mise run fed:down Refuse foreign ownership; atomically record exact identities; remove every lab-owned container, network, k3d image volume, attached anonymous volume, and locally built image; resume the same receipt after interruption.

The constrained reconciler observes unique Flux milestones for the expected source revision: a Kustomization reaches its observed generation, the expected source revision, or Ready state; a HelmRelease reaches its observed generation or Ready state. A new milestone resets the no-progress clock. With the defaults, the run fails after 20 minutes without a new milestone (FGENTIC_FED_NO_PROGRESS_TIMEOUT) or after a 60-minute absolute cap (FGENTIC_FED_MAX_TIMEOUT), whichever occurs first; the no-progress interval must remain shorter than the cap. This separates a genuinely stalled install from a slow but advancing one.

Set FGENTIC_FED_TRACE=yes on either fed:up command to write one timestamped run below .agents/tmp/federation-resources/ (or the explicit FGENTIC_FED_TRACE_DIR). The trace schema allowlists only its schema/cluster/mode and sampling timestamps, intervals, phases, and failure kinds; aggregate k3d-server working-set bytes; namespace/pod/container identities with CRI working-set bytes; the owned image-volume identity and bytes, retained-cluster bytes, and owned local-image virtual bytes; and Flux layer/revision readiness times. It never reads pod logs, Kubernetes event messages, Matrix/A2A content, Secret values, or model data. The summary records the pre-idle boot peak, idle-settle duration, server/idle sample counts, idle median, image-volume and retained-cluster peaks, the conservative owned-disk upper bound (retained cluster plus local-image virtual bytes), final local-image bytes, and layer-ready timestamps. A required server or disk sample failure rejects the trace instead of publishing a lower apparent peak.

For a successful comparison, tracing starts after ownership preflight but before cluster create/resume, samples boot and reconciliation at two-second intervals, then stops after the proof, waits a fixed 300 seconds for controller and workload caches to settle, and records twelve idle samples at ten-second intervals over two minutes. Run the default and constrained traces on the same idle reference host without changing unrelated workloads between them. The constrained two-minute median must be at most 3.3 GiB and at most 85% of the default median; its boot peak must be no higher than the default boot peak; and its owned-disk upper-bound peak must remain at most 10 GiB.

fed:stop preserves only the existing owned cluster and its exact k3d image volume; the next same-mode fed:up starts it and verifies the retained container IDs, volume identity, and capacity label before reuse, so the pinned upstream images do not need a cold repull. Switching between canonical and constrained capacity requires fed:down. Successful reconciliation also prunes obsolete random-tagged source images from the host and node runtime. The constrained mode does not create a second repo-managed persistent cache. Setting FGENTIC_DEMO_CACHE_DIR is a separate explicit operator choice, and fed:down does not delete that caller-owned directory or the reusable local CA. Every federation command leaves the user's default kubeconfig context untouched; fed:up creates a process-scoped temporary kubeconfig and removes it on exit.

8.5.3 Opt-in two-control-plane drill

mise run fed:split:up is the pre-partner infrastructure drill. It does not replace, compose, or weaken canonical fed:up: it creates two separately owned k3d clusters, reconciles one organization-shaped Flux entrypoint into each, and runs only the federation acceptance subset that crosses their boundary.

Control plane Owned identity and workloads Host ingress
A org-a.fgentic.test, denied org-c.fgentic.test, public docs-qa route, agentgateway, kagent, and model stub 127.0.0.2
B org-b.fgentic.test and its id.org-b.fgentic.test Keycloak issuer 127.0.0.3

The .test identities are split-drill-only. They avoid special .localhost resolver behavior while remaining reserved for testing. The lifecycle never edits the workstation resolver: host probes pin each exact name to its owning loopback address, while each cluster receives exact CoreDNS records for the local ingress and the remote boundary. Two pinned raw-TLS relay containers carry only the named cross-cluster routes. Each relay joins the two otherwise distinct Docker networks; no k3d node or load balancer does. The lifecycle records the exact cluster, network, load-balancer, relay, and image-volume identities before destructive work, refuses foreign or conflicting resources, and removes relays before either child network.

Each control plane generates its own CA and keeps that CA's private key in its own lifecycle directory. Only public roots cross the boundary. The required peer roots are projected into the Matrix trust stores, and org A's agentgateway validates org B's live JWKS over TLS with org B's root and exact server name. Org B's client secret remains in B: the host-side driver obtains a short-lived token from B, then presents it to A's public route, matching the bilateral partner boundary rather than copying an issuer credential into A.

The acceptance proof fails unless the Kubernetes API endpoints, kube-system namespace UIDs, Docker network IDs, server/load-balancer identities, and CA fingerprints differ. It also verifies that B's Matrix and Keycloak workloads exist only on B while A's exported agent workloads exist only on A. It then requires:

  1. Alice on A and Bob on B to exchange Matrix messages in both directions under room v12 and the participant-only server ACL.
  2. Charlie on A's denied server C to fail both a local C-to-A federation send and a host-delivered, C-signed request at B's distinct ingress. The signature is generated inside C on A; this negative request itself does not traverse the relay.
  3. Org B's JWT to authorize only A's signed docs-qa AgentCard route, reach the deterministic model, return the seller-signed task receipt, and hit the same reservation ceiling as the canonical lab.

The canonical fgentic-fed lab and control plane A both reserve 127.0.0.2:80/443, so fed:split:up requires canonical fed:down to have completed first; a merely stopped canonical cluster is refused rather than adopted or deleted. Run and inspect the drill independently:

mise run fed:split:up
mise run fed:split:status
mise run fed:split:stop
mise run fed:split:down

Recorded result (2026-07-30): Nightly smoke run 30561753519 passed at source head 87af3021a170e852c5e78edcd2f462d384b0a931. Its content-free federation-split-30561753519-1 artifact records distinct control-plane UIDs and CA roots; successful cold and warm-reuse passes for bidirectional Matrix, denied C, signed-card/JWT delegation, quota, and token metrics; preserved relay and cluster identities while stopped; restored active identities after reuse; and final retained_bytes=0 for both clusters after exact teardown.

status is inspect-only. stop releases active CPU and RAM while preserving both exact owned clusters and their image volumes. down is the retryable, receipt-backed teardown and must remove both clusters, both relays, their networks and image volumes, and the drill's locally built images without touching canonical fgentic-fed.

This is stronger evidence than §8.5's single-control-plane rig, but it is still a synthetic one-workstation drill. The bidirectional Matrix exchange proves relay routing in both directions; the host-driven A2A and signed-denial probes prove the distinct ingress and trust decisions but do not claim workload-origin traffic from B. The two clusters share one Docker daemon, host kernel, physical network, filesystem, and clock. The relays prove routed TLS and explicit trust distribution, not WAN latency, packet loss, public reachability, real DNS/ACME, clock skew, independent operators, independent failure domains, backup/restore, or disaster recovery. A successful run is therefore a pre-partner gate, not evidence of a production two-organization deployment.

8.6 Joint-ops room blueprint + structured agent events (#167)

A shared incident/joint-ops room is the flagship cross-org story the M8 rails exist for: org A's agent and org B's agent each serve their own side, in one room, under all existing controls. No tenant-anchored platform (Slack/Teams/Google Chat) can put two organizations' own agents in one governed room; closed-federation Matrix plus the A2A delegation plane can. This section is the repeatable recipe. It is honest about §8.1's replication reality: a joint-ops room's state and history replicate fully and irrevocably to every participating homeserver, so the room is a contractual data-sharing boundary (DPA + mutual retention), hardened technically but never a residency guarantee.

8.6.1 Room recipe

  1. Create with room version 12 (§8.2 Hydra fix) and m.federate: true set at creation — the federation flag is immutable afterward, so decide deliberately.
  2. Invite-only (join_rules: invite) and private (history_visibility: joined, not world-readable): a partner joins only by invitation, and no pre-join history leaks. This satisfies ADR 0015's v1 constraints for a real-partner room (private, invite-only, joined-history, purpose-scoped, visibly plaintext, classification-bounded, contract-bound). Encryption is selected per room under ADR 0026, but the current bridge refuses encrypted events; ADR 0015 continues to govern plaintext partner rooms until staged crypto participation is proven.
  3. Server ACL (m.room.server_acl) as initial state with allow listing exactly the admitted participant servers and allow_ip_literals: false. Applying it at creation closes the ungoverned-event race (§8.2). The denied control server (C) is absent from the ACL, from every federation_domain_whitelist, and from the partner registry (§8.2.1) — its exclusion is enforced on all five planes, not just the room.
  4. Programmatic border (apps/synapse-federation-policy) loaded on each participant names the exact admitted sender servers, federated event types, and invite rule; unknown/malformed input denies fail-closed (§8.2).
  5. Per-agent bridge policy (D6): each org's bridge maps its own ghost(s) with allowedServers/allowedSenders that admit only its own configured principals. Org A's bridge never resolves @agent-x:org-b as a local target (federation rule — never match by localpart), and org B's senders cannot invoke org A's agent unless A's sender policy explicitly admits B's server. Cross-org invocation of A's exported agent goes through the A2A delegation plane (§8.3) under the org-B JWT, azp reservation, and quota — not by B mentioning A's ghost in the shared room.

8.6.2 What each org sees and controls

  1. Sees: every message and agent reply in the room, replicated to its homeserver (the honest §8.1 reality). Agent replies are m.notice and carry the structured ai.fgentic.a2a block (§8.6.3) so the counterpart's tooling parses outcomes without scraping text.
  2. Controls: its own agents' invocation (sender policy), its own LLM egress and budgets (agentgateway token metering, per-room/per-sender rate limits, cross-org per-azp maxTokens reservation), and its own homeserver's federation allowlist and callback border. It does not control the other org's agents, budgets, or retention — those are the partner's plane plus the contract.
  3. Attribution (D6): identity is org-level; events are homeserver-signed and "cryptographically attributable to the partner organization", not per-user unforgeable (§8.1). Per-agent identity on the delegation plane is layered with A2A Signed AgentCards. In the room, each agent answers as its own full ghost MXID @agent-<name>:<server> — never a bare localpart — so an observer can always tell which homeserver owns the answering agent.

8.6.3 Structured result metadata schema (ai.fgentic.a2a)

Following the gematik TI-Messenger pattern of structured, machine-readable metadata on messages, the bridge attaches a small versioned block as a custom top-level field on the event content of an agent ghost's terminal m.notice (the final answer and terminal failure notices). It lets a counterpart organization's tooling parse a delegation's outcome structurally instead of scraping the human-readable notice body. It is defined in apps/matrix-a2a-bridge/internal/bridge/result_metadata.go.

Field Meaning
v Schema version (currently 1). Bumped only on a breaking field change so a parser can pin what it understands.
outcome Terminal delegation outcome, reusing the existing metric/audit constants (ok, failed, error, denied, timeout, lost, budget_exhausted, rate_limited, queue_full, canceled, ...). Mirrors the fgentic.delegation.v1 audit outcome.
agent The answering ghost's full MXID @agent-<name>:<server> (D6 federation-safe identity), never a bare localpart.
task_id The A2A task id when one existed (long/polled tasks); omitted for a delegation refused before A2A (sender/stage denial, rate/budget limit) or a bare terminal Message with no task.
budget_evidence A pointer to the content-free audit stream where this delegation's cost and outcome governance is recorded (fgentic.delegation.v1, correlatable by agent + room + task_id) — deliberately a reference, not the token counts.

Content-free by construction. Every field is a stable identifier or a fixed enum. The block never carries room message content, agent reply text, prompt text, or raw per-room token counts: raw spend stays in-room (the budget command) and in the audit log, and is never projected into a federated notice (D7/D8). A signed, per-task usage receipt is the separate, provider-gated federation usage-receipt signer's job, not this block.

Which replies carry it. Only terminal replies with a known outcome: the successful answer, terminal failure notices (agent-reported failure, empty reply, transport error, task timeout, poll-lost, auth-required, room-cancel, resume-drop), pre-dispatch refusals (queue-full, over-budget, untrusted), and admission denials (sender/stage policy). The homeserver-fired dead-man stale-task fallback carries it too, with outcome: lost (the bridge stopped responding before it could cancel the timer). Non-terminal or non-outcome notices carry no block: the working placeholder, in-thread progress updates, the input-required question pause, the "only the original sender may answer" guard, command help, and welcome messages. Both delivery paths attach the block: the synchronous in-process path and — since the deployed bridge enables durable intake — the durable Matrix outbox, which is the production terminal projection.

It does NOT change D8. The block is machine-readable metadata, not an instruction. Agent replies remain m.notice, and the bridge — like any well-behaved automation — must never act on a notice. Adding this field does not make a notice actionable: bridge intake still ignores every ghost-authored notice (own-user short-circuit) and every m.notice (non-text type), so a notice carrying an ai.fgentic.a2a block is never treated as a delegation. This is asserted directly by TestResultMetadataDoesNotMakeNoticeActionable and the content-freeness tests in result_metadata_test.go.

8.6.4 Remaining runtime acceptance (needs #185)

The offline core of this blueprint — the structured metadata block, its schema, and the D8/content-free invariants — is implemented and unit-tested deterministically. Two runtime halves remain and depend on the federation agents component (#185, per ADR 0013) plus a fed:up run; they are not yet proven here and must not be claimed as demonstrated:

  1. Lab scenario (extends scripts/seed-federation.sh): a shared room v12 + ACLs where org A maps a local agent, org B invokes A's exported docs-qa via the delegation plane, and both agents answer in the shared room with correct per-org attribution (D6) — with the emitted ai.fgentic.a2a block validated against its schema and denied server C fully excluded.
  2. Adversarial half: a prompt-injection probe from org B's side against org A's agent, shown to be contained by the existing controls (sender allowlists, budgets) with content-free refusal evidence — extending the callback-probe pattern (§8.5).

Until both run under fed:up with the #185 component, the both-side lab proof is outstanding; only the offline schema/D8/content-free guarantees above are established.