The customer-support orchestration covered in the multi-agent article had four agents inside one Foundry project, all callable through the Connected Agents pattern. That works when you own all the agents. It does not work when you need to call an agent that lives in a different team's Foundry project, in a different subscription, or in a different organisation entirely. We hit the limit when the support team's billing-question agent needed to consult finance team's audit-trail agent, which lived in finance's Foundry project with finance's RBAC and finance's data residency requirements.
Microsoft's answer is the A2A (Agent-to-Agent) protocol, an open standard for agents in different projects, organisations, or vendors to call each other through a uniform interface, with discovery via Agent Cards, identity via Entra ID, and a transport that fits HTTP. By the end of our build, the support orchestrator could call finance's audit agent (and a contractor's policy agent, in a different tenant entirely) using the same SDK pattern as our internal Connected Agents.
This post is the entire build. By the end you have an A2A-compatible Foundry agent published with a discoverable Agent Card, an A2A client that the orchestrator uses to call out-of-project agents, identity flowing through via Entra ID with cross-tenant federation where needed, full request/response tracing across the boundary, and a working scenario where one team's agent calls another team's agent and gets a structured answer back. About 350 lines of Python, the canonical Agent Card JSON, and the trust patterns that make cross-org agent collaboration safe.
Why A2A, and what makes it different from "just call an HTTP API"
Brief context because the protocol's value isn't obvious from the marketing.
Why A2A and not REST. Two agents calling each other over plain REST works for one pair. Doing it for ten pairs requires inventing your own protocol for: discovering what an agent does, presenting credentials, handling streaming responses, structuring multi-turn conversations, and threading context across calls. A2A is what you'd invent if you did this ten times — except Microsoft and the Linux Foundation already invented it, so you don't have to.
Why an open protocol matters. A2A is now part of the Linux Foundation, which means non-Microsoft agent platforms (anthropic, custom self-hosted, third-party SaaS) speak the same protocol. Building on A2A means your orchestrator can call a Foundry agent today and an Anthropic agent tomorrow, with the same client code. The interoperability is what makes the investment in A2A worth more than in a Microsoft-only solution.
Why Agent Cards are the discovery primitive. An Agent Card is a JSON document, served at a well-known URL, that describes what an agent does, what skills it offers, and how to authenticate. Discovery becomes "fetch the Agent Card and read it" instead of "read someone's wiki page about their agent." It's machine-readable, which means the orchestrator can choose between agents at runtime if needed.
Why Entra ID is the right identity primitive for in-Microsoft A2A. Cross-project, cross-subscription, cross-tenant — Entra ID handles all three with the same OIDC plus federated-credential pattern. The agent calling out doesn't need to know whether the callee is across the room or across the org; the federation makes the boundary invisible at the code level.
What you'll have at the end
~/foundry-a2a/
├── publish/
│ ├── agent-card.json # the discovery document
│ ├── agent-card-server.py # serves the card at well-known URL
│ └── infra/
│ └── publish-card.bicep # Static Web App or Front Door config
├── server/
│ ├── audit_agent_a2a.py # the audit agent's A2A endpoint
│ ├── auth.py # Entra ID validation
│ └── skills/
│ └── lookup_audit_record.py
├── client/
│ ├── a2a_client.py # the cross-project caller
│ ├── discovery.py # fetch + cache Agent Cards
│ └── caller_example.py # support agent calling audit agent
├── eval/
│ ├── a2a-contract-tests.py # spec compliance tests
│ └── e2e.py
└── README.md
Prerequisites
- Two Microsoft Foundry projects in different resource groups (or different subscriptions, or different tenants — the pattern is the same). One hosts the agent that's offering A2A; the other hosts the agent that calls.
- Entra ID app registrations for both sides. Caller has a client identity; callee has an API identity with custom scopes for what calls can be made.
- Familiarity with the A2A spec, which lives at the Linux Foundation A2A Working Group. The protocol is small; an afternoon with the spec.
- Python 3.12+ with
azure-ai-projects,azure-identity,httpx,python-josepackages. - A static-content host for the Agent Card — Azure Static Web Apps, Azure Storage static website, or any HTTPS endpoint you control.
python -m venv .venv && source .venv/bin/activate
pip install azure-ai-projects azure-identity httpx python-jose
Step 1: Publish the Agent Card
The Agent Card is the discovery document. Its canonical location is https://<host>/.well-known/agent-card.json, served over HTTPS.
publish/agent-card.json:
{
"agentCardVersion": "1.0",
"agent": {
"name": "finance-audit-agent",
"displayName": "Finance Audit Trail Agent",
"description": "Looks up audit trail records for billing transactions. Returns the requesting transaction's actor, timestamp, and immutable hash.",
"owner": {
"organization": "Finance Platform",
"contactEmail": "finance-platform@yourcorp.com"
},
"version": "2.1.0"
},
"skills": [
{
"name": "lookup_audit_record",
"description": "Get the audit record for a single transaction id. Returns the actor, timestamp, and tamper-proof hash.",
"input": {
"type": "object",
"properties": {
"transaction_id": {
"type": "string",
"description": "Transaction ID, e.g. txn-2026-04-12-91234"
}
},
"required": ["transaction_id"]
},
"output": {
"type": "object",
"properties": {
"transaction_id": { "type": "string" },
"actor_oid": { "type": "string" },
"actor_email": { "type": "string" },
"occurred_at": { "type": "string", "format": "date-time" },
"hash": { "type": "string" },
"verified": { "type": "boolean" }
}
}
}
],
"endpoints": {
"messages": "https://finance-audit.yourcorp.com/a2a/messages",
"tasks": "https://finance-audit.yourcorp.com/a2a/tasks"
},
"authentication": {
"schemes": ["oauth2"],
"oauth2": {
"tokenUrl": "https://login.microsoftonline.com/<finance-tenant-id>/oauth2/v2.0/token",
"scopes": [
"api://finance-audit-agent/audit.read"
]
}
},
"rateLimits": {
"requestsPerMinute": 60,
"tokensPerMinute": 50000
}
}
What each block does:
agentis the human-readable identity: who owns this agent, how to contact them, what version it is.skillsis the API surface. Each skill has a JSON Schema for input and output, which the calling agent uses to construct typed calls. The schema is the contract.endpointsare the URLs the caller hits.messagesfor synchronous,tasksfor asynchronous (long-running) calls.authenticationdeclares which Entra ID scope the caller must obtain. The caller does the OAuth dance against this scope before making the A2A call.rateLimitsdeclares the limits this agent enforces. Callers should respect them; the agent enforces them anyway, so a misbehaving caller gets rate-limited rather than crashing the agent.
Publish it at the well-known location:
# Azure Static Web Apps + Bicep
az staticwebapp create \
-n agent-card-finance \
-g rg-finance-platform \
-l westus2
# Upload the card
az staticwebapp deploy \
-n agent-card-finance \
--source ./publish \
--location-pattern "/.well-known/agent-card.json"
The card lives at https://<deploy-fqdn>/.well-known/agent-card.json. Front it with a friendly DNS name (finance-audit.yourcorp.com) via Azure Front Door for cleaner discovery URLs.
Step 2: The A2A server skill
server/skills/lookup_audit_record.py:
"""Implementation of the lookup_audit_record skill. Wired to the A2A server
in the next step."""
from typing import TypedDict
import httpx
from azure.identity import DefaultAzureCredential
class AuditRecord(TypedDict):
transaction_id: str
actor_oid: str
actor_email: str
occurred_at: str
hash: str
verified: bool
async def lookup_audit_record(transaction_id: str, calling_user_oid: str) -> AuditRecord:
"""Look up an audit record. The caller's OID is passed for downstream RBAC.
Args:
transaction_id: The transaction to look up.
calling_user_oid: OID of the human user who originated the call,
propagated by the A2A protocol from the calling agent.
Returns:
The audit record, or raises if the transaction is not visible to the user.
"""
# Authenticate to the internal audit store via managed identity
credential = DefaultAzureCredential()
token = credential.get_token("https://audit-store.internal/.default")
# Pass the caller's user OID downstream so the audit store can apply
# row-level security: the user must have rights to see this transaction.
headers = {
"Authorization": f"Bearer {token.token}",
"X-Caller-User-Oid": calling_user_oid,
}
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"https://audit-store.internal/transactions/{transaction_id}",
headers=headers,
)
if response.status_code == 403:
raise PermissionError(f"User {calling_user_oid} cannot see transaction {transaction_id}")
response.raise_for_status()
return response.json()
Two important properties:
The user's OID is propagated through the A2A call. When the caller agent invokes this skill, it passes the originating human user's OID as part of the request context. The skill then propagates it downstream so the audit store enforces RBAC on the user, not on the caller agent. This is the same defence-in-depth as in the SQL row-level-security article.
Returning a 403 is informational, not just a failure. The caller gets back a structured "this user cannot see this record" response, which the orchestrator can turn into an appropriate user-facing message ("you don't have access to this transaction's audit trail"). Don't conflate "transaction not found" with "permission denied"; the distinction matters.
Step 3: The A2A server endpoint
server/audit_agent_a2a.py:
"""HTTP server implementing the A2A protocol's /messages endpoint."""
from fastapi import FastAPI, Header, HTTPException
from typing import Optional
from .auth import validate_a2a_caller
from .skills.lookup_audit_record import lookup_audit_record
app = FastAPI()
@app.post("/a2a/messages")
async def receive_message(
body: dict,
authorization: Optional[str] = Header(None),
):
"""Handle an A2A message from another agent.
The protocol body shape (abbreviated):
{
"task_id": "uuid",
"skill": "lookup_audit_record",
"input": { "transaction_id": "txn-..." },
"context": {
"caller_agent": "support-orchestrator",
"caller_user_oid": "...",
"trace_id": "..."
}
}
"""
# 1. Authenticate the calling agent via Entra ID
if not authorization:
raise HTTPException(401, "missing authorization")
caller_claims = await validate_a2a_caller(authorization, required_scope="audit.read")
# 2. Dispatch to the right skill
skill_name = body.get("skill")
skill_input = body.get("input", {})
context = body.get("context", {})
if skill_name == "lookup_audit_record":
try:
result = await lookup_audit_record(
transaction_id=skill_input["transaction_id"],
calling_user_oid=context.get("caller_user_oid", ""),
)
return {
"task_id": body["task_id"],
"status": "completed",
"output": result,
}
except PermissionError as e:
return {
"task_id": body["task_id"],
"status": "failed",
"error": {"code": "permission_denied", "message": str(e)},
}
else:
raise HTTPException(404, f"unknown skill: {skill_name}")
server/auth.py:
"""Validate an incoming Entra ID token from a calling A2A agent."""
from jose import jwt, JWTError
import httpx
import os
TENANT_ID = os.environ["TENANT_ID"]
EXPECTED_AUDIENCE = "api://finance-audit-agent"
# Cache the JWKS keys; refresh hourly
_jwks_cache = None
async def validate_a2a_caller(authz_header: str, required_scope: str) -> dict:
"""Validate a Bearer token. Return claims if valid, raise 401 otherwise."""
token = authz_header.replace("Bearer ", "")
# Fetch JWKS
global _jwks_cache
if _jwks_cache is None:
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys"
)
_jwks_cache = response.json()
try:
# Validate signature, issuer, audience
claims = jwt.decode(
token,
_jwks_cache,
algorithms=["RS256"],
audience=EXPECTED_AUDIENCE,
issuer=f"https://login.microsoftonline.com/{TENANT_ID}/v2.0",
)
except JWTError as e:
raise HTTPException(401, f"invalid token: {e}")
# Verify the required scope is present
scopes = claims.get("scp", "").split() + claims.get("roles", [])
if required_scope not in scopes:
raise HTTPException(403, f"missing scope: {required_scope}")
return claims
The validation is the same shape as any Entra-protected API. What's specific to A2A:
- The audience (
api://finance-audit-agent) is the agent's app id URI, which the calling agent had to obtain a token for via theacquire_token_for_clientMSAL call. - The required scope (
audit.read) is what the Agent Card declared. The token must include this scope; otherwise, 403. - Cross-tenant calls (caller in tenant A, agent in tenant B) work the same as long as the caller's app is granted access to the agent's tenant via "guest user" or external collaboration setup. Microsoft documents this at External collaboration with Microsoft Entra B2B.
Step 4: The A2A client
client/a2a_client.py:
"""Client that calls A2A-compatible agents."""
import uuid
import httpx
from azure.identity.aio import DefaultAzureCredential
from .discovery import get_agent_card
class A2AClient:
def __init__(self, calling_agent_id: str):
self.calling_agent_id = calling_agent_id
self.credential = DefaultAzureCredential()
async def call_skill(
self,
agent_card_url: str,
skill_name: str,
skill_input: dict,
calling_user_oid: str,
trace_id: str = None,
) -> dict:
"""Invoke a skill on a remote A2A agent.
Args:
agent_card_url: e.g. https://finance-audit.yourcorp.com/.well-known/agent-card.json
skill_name: e.g. "lookup_audit_record"
skill_input: matching the skill's input schema
calling_user_oid: the originating human user's OID
trace_id: optional trace id for distributed tracing
"""
# 1. Discover the agent's endpoint and required scope
card = await get_agent_card(agent_card_url)
endpoint = card["endpoints"]["messages"]
required_scope = card["authentication"]["oauth2"]["scopes"][0]
# 2. Acquire a token for the agent's API
token = await self.credential.get_token(required_scope)
# 3. Build the A2A message
body = {
"task_id": str(uuid.uuid4()),
"skill": skill_name,
"input": skill_input,
"context": {
"caller_agent": self.calling_agent_id,
"caller_user_oid": calling_user_oid,
"trace_id": trace_id or str(uuid.uuid4()),
},
}
# 4. Send the request
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
endpoint,
json=body,
headers={
"Authorization": f"Bearer {token.token}",
"Content-Type": "application/json",
"X-Trace-Id": body["context"]["trace_id"],
},
)
response.raise_for_status()
return response.json()
Three patterns the client handles:
Discovery via Agent Card before the call. The client doesn't hardcode endpoint URLs; it reads them from the card. This means an agent moving (changing FQDNs, scaling out) doesn't break callers — they just refresh the card.
Token acquisition per call. The credential's token cache is in MSAL; subsequent calls within the token's TTL reuse the cached token. The first call to a new agent costs ~150ms for the OAuth round-trip; subsequent calls are sub-100ms.
Trace ID propagation in the request context. The trace ID lets you correlate calls across agents in App Insights. Without this, cross-agent calls show up as separate, unconnected traces; with this, you get one end-to-end view.
Step 5: Discovery with caching
client/discovery.py:
"""Fetch and cache Agent Cards."""
import time
import httpx
CACHE_TTL_SECONDS = 3600
_cache: dict[str, tuple[dict, float]] = {}
async def get_agent_card(card_url: str) -> dict:
"""Fetch an Agent Card from its well-known URL, with caching."""
now = time.time()
cached = _cache.get(card_url)
if cached and (now - cached[1]) < CACHE_TTL_SECONDS:
return cached[0]
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(card_url)
response.raise_for_status()
card = response.json()
# Validate the card has required fields
required_fields = ["agent", "skills", "endpoints", "authentication"]
for field in required_fields:
if field not in card:
raise ValueError(f"Agent Card missing required field: {field}")
_cache[card_url] = (card, now)
return card
The 1-hour TTL is the trade-off between freshness and call rate. Agent Cards rarely change; the FQDN of an agent is stable across deployments. An hour is generous; some teams set it to a day.
Step 6: The orchestrator integration
The support orchestrator from the multi-agent article gets one new tool: a generic A2A caller, plus a hardcoded reference to the finance audit agent's card.
# In the support agent host
from client.a2a_client import A2AClient
A2A = A2AClient(calling_agent_id="support-orchestrator")
FINANCE_AUDIT_CARD = "https://finance-audit.yourcorp.com/.well-known/agent-card.json"
async def lookup_audit_record_tool(transaction_id: str, user_oid: str, trace_id: str):
"""Tool the support agent calls when the user asks 'who created that charge'.
Routes through A2A to finance team's audit agent."""
return await A2A.call_skill(
agent_card_url=FINANCE_AUDIT_CARD,
skill_name="lookup_audit_record",
skill_input={"transaction_id": transaction_id},
calling_user_oid=user_oid,
trace_id=trace_id,
)
# Register as a Foundry tool with the orchestrator agent
from azure.ai.projects import AIProjectClient
project = AIProjectClient(endpoint=PROJECT_ENDPOINT, credential=DefaultAzureCredential())
agent = project.agents.get_agent("support-orchestrator")
project.agents.update_agent(
agent_id=agent.id,
tools=[
{
"type": "function",
"function": {
"name": "lookup_audit_record",
"description": (
"When the user asks who created a transaction or wants the audit "
"trail for a charge, call this tool with the transaction id. "
"Returns who initiated the transaction, when, and a tamper-proof hash."
),
"parameters": {
"type": "object",
"properties": {
"transaction_id": {"type": "string"}
},
"required": ["transaction_id"]
}
}
}
]
)
From the orchestrator agent's perspective, the A2A call looks like any other tool. The fact that it crosses Foundry projects, organisations, and possibly tenants is invisible at the prompt level. The orchestrator's instructions only need to say "call lookup_audit_record when the user asks about a charge's audit trail"; the rest is plumbing.
Step 7: Cross-tenant federation (the harder case)
When the calling agent and the called agent are in different Entra ID tenants (e.g., your support team in Tenant A, a contractor's policy agent in Tenant B), Entra ID's external collaboration features handle it. The setup:
In the called agent's tenant (Tenant B): create an app registration for the agent. Set audience to
AzureADMultipleOrgsso external tenants can request tokens against it.In the calling agent's tenant (Tenant A): the agent's identity (typically a managed identity) needs to be granted access to the multi-tenant app from Tenant B. Microsoft documents this at Multi-tenant applications in Microsoft Entra.
In the called agent (Tenant B's API): validate tokens against
iss = https://login.microsoftonline.com/<Tenant A's id>/v2.0. The audience stays the same; the issuer changes per calling tenant.
The change to the validation code:
# In server/auth.py
ALLOWED_ISSUERS = [
f"https://login.microsoftonline.com/{TENANT_A_ID}/v2.0", # support team
f"https://login.microsoftonline.com/{TENANT_C_ID}/v2.0", # another partner
# Add tenant by tenant; do not accept arbitrary issuers
]
# In validate_a2a_caller, instead of single issuer:
claims = jwt.decode(
token, jwks, algorithms=["RS256"],
audience=EXPECTED_AUDIENCE,
issuer=ALLOWED_ISSUERS, # accept any of these
)
The allowlist of tenants is the security boundary. Don't accept tokens from any random tenant; accept them from the tenants you've explicitly allowed.
Step 8: The contract test
eval/a2a-contract-tests.py:
"""Regression tests for the A2A server's contract compliance."""
import asyncio
import httpx
import pytest
AGENT_CARD_URL = "https://finance-audit.yourcorp.com/.well-known/agent-card.json"
@pytest.mark.asyncio
async def test_agent_card_is_valid():
"""The Agent Card must be served with the right shape."""
async with httpx.AsyncClient() as client:
response = await client.get(AGENT_CARD_URL)
assert response.status_code == 200
card = response.json()
assert card["agentCardVersion"] == "1.0"
assert "agent" in card and "name" in card["agent"]
assert "skills" in card and len(card["skills"]) > 0
assert "endpoints" in card and "messages" in card["endpoints"]
assert "authentication" in card
@pytest.mark.asyncio
async def test_unauthenticated_call_returns_401(card):
"""No token = 401, not 200, not 500."""
endpoint = card["endpoints"]["messages"]
async with httpx.AsyncClient() as client:
response = await client.post(endpoint, json={"task_id": "test", "skill": "lookup_audit_record"})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_wrong_scope_returns_403(card, valid_token_no_scope):
"""Token without the required scope = 403."""
endpoint = card["endpoints"]["messages"]
async with httpx.AsyncClient() as client:
response = await client.post(
endpoint,
json={"task_id": "test", "skill": "lookup_audit_record"},
headers={"Authorization": f"Bearer {valid_token_no_scope}"},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_unknown_skill_returns_404(card, valid_token):
"""Asking for a skill the agent doesn't have = 404, not 500."""
endpoint = card["endpoints"]["messages"]
async with httpx.AsyncClient() as client:
response = await client.post(
endpoint,
json={"task_id": "test", "skill": "non_existent_skill"},
headers={"Authorization": f"Bearer {valid_token}"},
)
assert response.status_code == 404
These tests run on every deploy of the A2A server. They prove the contract still holds: the card is valid, auth boundaries fire correctly, unknown skills produce predictable errors. Without these, A2A consumers across the org silently break when the server's behaviour shifts.
Production checklist
Pin the Agent Card on a stable URL. Don't move it once published; consumers cache. If you need to break the contract, version the URL (
/.well-known/agent-card-v2.json) and run both for a deprecation window.Document scopes per skill. If your agent has multiple skills with different sensitivity (read vs write, billing vs audit), declare separate scopes per skill. The current Agent Card supports per-skill auth requirements.
Rate-limit by calling agent, not by tenant. A misbehaving caller from the support team shouldn't take down the audit agent for the finance team's other consumers. Per-caller rate limits.
Trace every A2A call end-to-end. The trace_id propagation is the foundation; tooling like Application Insights' Map view shows the cross-service flow. Without distributed tracing, debugging a multi-org A2A call is an investigation nightmare.
Monitor calls per partner. A spike in calls from "policy contractor" agent should be visible to the finance team's on-call. Build a per-caller dashboard.
Test cross-tenant scenarios in CI. Even if you only call same-tenant today, a contract test that exercises an external-tenant caller path catches the silent-breakage class.
Troubleshooting
Token validation passes but the call returns 403. Token has the wrong scope or role claim. Check claims.scp and claims.roles; the required scope must be in one of them.
Cross-tenant call gets AADSTS50020. The caller's tenant has not been added to the called app's allowed-tenants list. Microsoft Entra → External Identities → Cross-tenant access → Add the calling tenant.
Agent Card returns 404 from the well-known URL. The static-content host's path-mapping doesn't preserve the /.well-known/ prefix. Configure path mappings explicitly.
A2A call works locally but fails in production. Network egress: the caller agent's host must allow outbound to the called agent's FQDN. If you're in a private VNet (per the Foundry-with-Private-Link article), add the called agent's FQDN to the egress firewall allowlist.
Trace IDs don't correlate across agents. Application Insights uses W3C traceparent, not custom X-Trace-Id headers. Use traceparent: 00-<32 hex>-<16 hex>-01 format in addition to the A2A context's trace_id, and configure your OpenTelemetry exporter to propagate it.
Real-world references
- The A2A Protocol specification, the canonical Linux Foundation reference. Read this first.
- Microsoft Learn, A2A protocol overview in Foundry, Microsoft's documentation for using A2A with Foundry agents.
- Microsoft DevBlogs, Cross-team agent collaboration with A2A, launch posts and patterns.
- GitHub, a2aproject/a2a-samples, the official protocol's sample implementations.
- Microsoft Learn, Multi-tenant applications in Microsoft Entra, the cross-tenant federation reference.
The A2A protocol spec is the foundational document; everything in this article is application of it to Microsoft Foundry specifically.
What this gives you, beyond cross-team agent calls
The obvious win is the unblock: the support team can call finance's audit agent without inventing a custom integration. The 23-day project to build "support-finance audit integration" became an afternoon to wire up an A2A call. Multiplied across team pairs, the unlock is substantial.
The less obvious win is what changes about how teams design agents. Without A2A, every cross-team integration was a custom one-off, which made teams reluctant to depend on each other. With A2A, "use this team's agent" is as easy as "use this team's API," which it already is for non-AI services. The agent ecosystem starts behaving like a microservice ecosystem, with the same composability.
The far-out win is what becomes possible at the org level. Once enough teams publish A2A-compatible agents, an orchestrator can route work across the entire organisation, calling the right agent for each step. You stop needing one big agent that does everything; you build a network of specialists, each owned by the team with domain expertise. That's the shape Microsoft is betting on, and it's the shape that scales.
A year into running A2A on the org I shipped this for, there are 11 published Agent Cards across 6 teams, with 3 cross-team integrations live and 2 cross-tenant integrations in pilot. Total custom integration code written: roughly 800 lines, almost all of it the A2A client and server scaffolding from this article. The same number of integrations done point-to-point would have been at least 8,000 lines and a multi-quarter project. That's the bill the protocol pays back.

Conversation
Reactions & commentsLiked this? Tap a reaction. Want to push back, share a war story, or ask a follow-up? Drop a comment below — replies are threaded and markdown works.