3 Million Strong: Celebrating the DZone Community
AI Assist vs AI Complete: The Real Gap in Most AI Workflows Today
Getting Started With DevSecOps
Code Review Core Practices
Agent framework debates are mostly vibes. One engineer swears LangGraph is faster, another prefers the OpenAI Agents SDK, someone wants Google ADK because it feels future-proof. The team picks one, wires the workflow into its SDK, and the choice is welded in. Changing frameworks later means tearing out the wiring for one SDK and rebuilding the workflow on another, an expensive rewrite few teams take on. This tutorial makes that decision reversible and then settles it with data. You put the agent graph in LaunchDarkly and run four frameworks (LangGraph, Strands, OpenAI Agents SDK, and Google ADK) over the same topology, with the model pinned so the framework is the only variable. A LaunchDarkly experiment ranks them on graph latency and token use, with an LLM judge guarding quality. The results table tells you which framework runs your graph fastest without degrading it. This tutorial is the sequel to Compare AI orchestrators, which ran the same workflow across frameworks but kept the topology in each framework’s code. Here, the topology, routing, models, prompts, tools, and judge all live in LaunchDarkly, and each framework supplies only two functions. The experiment results do more than set a benchmark. The flag that splits experiment traffic also routes production. When one framework wins, you don’t rewrite the app; you change the flag to serve the winner. In a single loop, LaunchDarkly does three jobs: the graph definition, the experiment split, and the runtime control that ships the winner. The workload is a research-gap analysis over a set of arXiv papers. Two readers, approach-analyzer and contradiction-detector, read the same papers in parallel and fan in to gap-synthesizer, which writes the report. Prerequisites A LaunchDarkly account with AgentControl access, and your environment’s SDK keyPython 3.11+ and uvAn ANTHROPIC_API_KEY for the pinned model. OPENAI_API_KEY and GOOGLE_API_KEY are only needed if you run the optional native-model bake-off in Step 9The companion repo: ai-orchestrators on branch tutorial/graph-experiments The Experiment Design The comparison is controlled: same graph, same model, same papers, same judge, with the framework as the only variable. Mechanically, it runs in four stages: Bootstrap. manifest.yaml creates the node configs, graph, orchestrator flag, and judge in LaunchDarkly.Route. On each request, the app evaluates the orchestrator flag to pick a framework: langgraph, strands, openai-agents, or google-adk.Run. The dispatcher runs the shared graph as a directed acyclic graph (DAG). The two readers run concurrently and fan in to the synthesizer.Measure. Each run records how long the graph took, how many tokens it used, and whether the report passed the quality judge. The shape looks like this: ┌──▶ approach-analyzer ───────┐ intake (papers) ─────┤ ├──▶ gap-synthesizer ──▶ report └──▶ contradiction-detector ──┘ Step 1: Create the Graph, Flag, and Judge Everything starts from one file, config/graph_experiment_manifest.yaml. It declares the fetch_paper tool, four node configs (intake plus the three agents, pinned to claude-sonnet-4-5), the graph, the orchestrator flag, and the judge. First, clone the companion repo and install its dependencies with uv: Shell git clone https://github.com/launchdarkly-labs/ai-orchestrators cd ai-orchestrators git checkout tutorial/graph-experiments uv sync Next, set up a LaunchDarkly project. The bootstrap doesn’t create one, so create it with the LaunchDarkly MCP server, the projects agent skill, or the UI. Name it graph-experiments to match the value in .env.example, so the defaults work without edits. When it exists, copy its key into LD_PROJECT_KEY and its production environment SDK key into LD_SDK_KEY in .env. The runners and experiment harness use that SDK key to evaluate the flag and graph. The bootstrap also reads LD_API_KEY from .env to create the resources. Copy the example file to create your .env: Shell cp .env.example .env # then set LD_PROJECT_KEY, LD_SDK_KEY, and LD_API_KEY in .env With the keys in place, run the bootstrap: Shell uv run python scripts/launchdarkly/bootstrap.py config/graph_experiment_manifest.yaml This creates all four node configs, the research-gap-graph, the orchestrator flag (created off), and the gap-quality-judge attached to the gap-synthesizer node (its synthesizer-claude variation, set to 100% sampling). The judge scores the final report against the source papers, so it can verify grounding and citations. A judge can only check based on the information it has, so we give it the papers, not only an upstream agent’s analysis. When the graph ships, it is incomplete by design. The bootstrap creates the contradiction-detector config but wires only intake to approach-analyzer to gap-synthesizer, leaving the detector out. You’ll add it in Step 5 to complete the parallel fan-in. When it finishes, the bootstrap prints a link to your new agent graph. Open it and review the topology before moving on. The graph shows a straight line from intake to approach-analyzer to gap-synthesizer, with contradiction-detector created but not yet wired in. Step 2: The Dispatcher Runs the Graph The dispatcher is the heart of the project, and it’s the same code for every framework. It reads the graph as a DAG, runs the entry nodes concurrently, hands every node the papers as ground truth, and connects the readers at the fan-in node. The only framework-specific pieces are build_agent and invoke, which are passed in as arguments. The whole process is about 100 lines, built on the agent graph traversal methods in the SDK. The complete dispatcher.py is in the companion repo. The dispatcher carries the design in four parts: it builds the execution plan from the graph’s edges, composes each node’s input, runs every ready node concurrently each round, and records the graph’s metrics once per run. First, the dispatcher builds the execution plan from the graph’s edges, so the topology you draw in LaunchDarkly runs: Python for key, node in nodes.items(): for edge in node.get_edges(): target = edge.target_config if target in nodes: succ[key].append(target) preds[target].append(key) Next, every node receives the source papers and any upstream analyses, so each agent and the judge work directly from the source material rather than a summary handed down a chain: Python def compose_input(user_input, predecessor_outputs): parts = [f"=== SOURCE PAPERS ===\n{user_input}"] for key, out in predecessor_outputs: if out and out.strip(): parts.append(f"=== {key} ===\n{out}") return "\n\n".join(parts) Then each round runs every node whose predecessors have finished, concurrently, so the two readers fan out and fan in with no special casing: Python ready = [k for k in pending if all(p in done for p in preds[k])] results = await asyncio.gather(*(run_node(k) for k in ready)) Finally, the dispatcher records the graph’s metrics on each run, including the end-to-end latency the experiment ranks on: Python graph_tracker.track_duration(int((time.monotonic() - start) * 1000)) graph_tracker.track_total_tokens(TokenUsage(input=totals["in"], output=totals["out"], total=totals["in"] + totals["out"])) graph_tracker.track_path(path) graph_tracker.track_invocation_success() The dispatcher reads the topology at runtime, so reshaping the workflow in the UI, adding a node, or redrawing an edge takes effect on the next request with no code change. You’ll do exactly that in Step 5. Step 3: Each Framework Is a Thin Adapter Each framework implements build_agent(node_key, config, instructions) and async invoke(agent, input_text, tracker). Everything dynamic still comes from the LaunchDarkly node config: the model, the attached tools, and the instructions. LangGraph has a LaunchDarkly companion package, so its runner is only a few lines. The companion handles model creation, tool binding, and token tracking, so the adapter holds no framework plumbing of its own: Python def build_agent(node_key, config, instructions): llm = create_langchain_model(config) tools = build_tools(config, TOOL_REGISTRY) # binds only this node's attached tools return create_react_agent(llm, tools, prompt=instructions) async def invoke(agent, input_text, tracker): result = await tracker.track_metrics_of_async( lambda res: LDAIMetrics(success=True, tokens=sum_token_usage_from_messages(res.get("messages", []))), lambda: agent.ainvoke({"messages": [{"role": "user", "content": input_text}]}), ) messages = result.get("messages", []) for message in messages: for name in get_tool_calls_from_response(message): tracker.track_tool_call(name) text = _content_to_text(messages[-1].content) if messages else "" return text, sum_token_usage_from_messages(messages) Strands has no companion package, so its runner builds the model with a small provider-aware factory and binds tools with Strands’ native @tool. The contract is identical: Python def build_agent(node_key, config, instructions): return Agent( name=node_key, model=_create_strands_model(config), system_prompt=instructions or "Process the input and respond.", tools=_bind_tools(config), callback_handler=None, ) OpenAI Agents and Google ADK round out the four. For the comparison to stay fair, all four have to run the same model, but these two SDKs default to their own vendors’ models. LiteLLM, a thin adapter, lets them call any provider, so we point both at the pinned claude-sonnet-4-5 and keep the model identical across all four orchestrators. No OpenAI or Google servers are involved. Instead, LiteLLM translates the request format in-process, and the call goes straight to Anthropic with your key. Google ADK is fully companion-free, and OpenAI Agents uses the ldai_openai companion for token and tool-call telemetry even though it builds the model through LiteLLM. This experiment pins one model across all four frameworks, so every framework here runs Claude. Pointing each framework at its own vendor’s default model instead is a separate, optional exercise, the native-model bake-off in Step 9. The tool callables live in TOOL_REGISTRY, a plain {name: callable} map that each framework binds its own way. Step 4: Smoke Test the Graph Before you run any experiment, confirm the bootstrapped graph runs end to end. First, run one framework: Python uv run python orchestrators/verify_run.py langgraph It prints the path it took and the first part of the report. On the graph as it shipped, the path is intake -> approach-analyzer -> gap-synthesizer: intake runs its short pass, approach-analyzer reads the papers, and gap-synthesizer writes the report. There’s no contradiction-detector yet, and no error. The metrics land in the AgentControl UI under the graph you created. Step 5: Add the Parallel Fan-In In the UI Here’s the payoff of keeping the topology in LaunchDarkly: you finish building the workflow in the UI, with no redeploy, and the running app picks up the new shape on its next request. The contradiction-detector config already exists, with its fetch_paper tool attached. You wire it into the graph to add the second reader and form the parallel fan-in. To complete the graph: Click Agents in the LaunchDarkly sidebar.Click Agent graphs.Select research-gap-graph.Add the contradiction-detector node.Draw an edge from intake to contradiction-detector, then another from contradiction-detector to gap-synthesizer.Click Save. You add no routing logic: the edge itself is the route, because routing is structural. Re-run the smoke test: Shell uv run python orchestrators/verify_run.py langgraph The path now includes contradiction-detector, and because approach-analyzer and contradiction-detector run concurrently, their order can vary. You completed a multi-agent workflow from the UI, and the config you wired in already had its tool attached. You finished a multi-agent workflow from the UI, mid-development, and the dispatcher ran the new shape on the next request. No redeploy, no code change: the graph you draw is the graph that runs. Step 6: Smoke Test All Four Frameworks Before you collect experiment data, make sure all four frameworks can run the completed graph. One command runs all four in sequence: Shell uv run python orchestrators/verify_run.py all It runs each framework against the completed graph and ends with a pass/fail summary, one line per framework, exiting non-zero if any framework failed, so it works as a gate. Each framework prints the path it took and a preview of its report, then a final summary collects the results. A successful run looks like this: Plain Text ▶ Running 'langgraph' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer ▶ Running 'strands' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer ▶ Running 'openai-agents' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer ▶ Running 'google-adk' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer === smoke summary === ✓ langgraph ✓ strands ✓ openai-agents ✓ google-adk If a framework fails, its line shows an ✗ instead of a ✓ and the command exits non-zero. All four smoke tests against the pinned Claude model. ANTHROPIC_API_KEY is the only model key you need, because OpenAI Agents and Google ADK reach Claude through LiteLLM. The OpenAI Agents SDK turns on tracing by default and looks for OPENAI_API_KEY to export traces, so the openai-agents run may print a harmless tracing warning when that key is absent. It doesn’t affect the run. Step 7: Run It Through the Experiment Now you can use a LaunchDarkly experiment to rank the four frameworks on real traffic, on the same graph, with the model held constant. Because the model is fixed, the comparison is operational: which orchestrator delivers the model’s quality fastest, with the least token overhead. The bootstrap already created the flag, the judge, and the graph. These metrics are measured on each request, so do a one-time setup first: Make the request context kind available for experiments.Set the analysis unit of graph latency, tokens, and the judge metric to request. Then create the experiment in the UI: Create an experiment with the orchestrator flag as the treatment.Set the primary metric to Graph latency ($ld:ai:graph:duration:total, the time for a complete graph execution).Add tokens and $ld:ai:judge:gap-quality as secondary metrics.Set the audience to 100% and the randomization unit to request. Each run is a single request, there are no users in this workflow, and request is the unit LaunchDarkly measures AI and graph metrics by.Turn on the orchestrator flag, which the bootstrap created set to off, so it serves the experiment’s variations.Start an experiment iteration. We rank on latency and tokens because, with the model and the graph held constant, those are the things that genuinely differ: a framework can move quality only by degrading the plumbing, like a truncated report or a broken tool call. So $ld:ai:judge:gap-quality stays a guardrail that catches a framework “winning” by cutting corners, not part of the ranking. Swap the model, prompt, or tools later instead of the framework, and that same judge becomes your primary metric. Then drive traffic. The flag assigns each run one framework at random: Shell uv run python scripts/run_experiment.py --runs-per-category 6 That’s six runs over each of the six shipped topics, 36 in total. Assignment is random, so it usually fills all four variations, though it isn’t guaranteed. Each run analyzes the topic’s entire paper set, because gap analysis needs every paper to find real gaps. Open the experiment in LaunchDarkly: latency per variation, with tokens and $ld:ai:judge:gap-quality alongside. The winner is the framework with the best latency and lowest token use that doesn’t let quality slip. Because the model is pinned, cost is a fixed multiple of tokens, so the token column is also the cost ranking; for actual dollar figures, read them from Insights. Because the experiment holds everything but the framework constant, most of these bars land close, often within a few percent, which is by design. In our run, Strands won on speed: it ran the graph fastest, with quality holding at the guardrail. If you optimize for speed and quality holds, that makes Strands the orchestrator to ship for this workload. Six topics and one randomized split isn’t a large sample, so confirm the lead with more topics before you standardize on it. You can do that in Step 9. Step 8: Ship the Winner With Runtime Control The experiment gave you data. The reason to run it in LaunchDarkly, rather than a one-off script, is that acting on that data takes no deploy: the orchestrator flag that was the experiment treatment is also your production router. When a variation wins, stop the iteration and set the flag’s default to that framework. Every request routes to it on the next evaluation, with no redeploy. Then automate what you don’t want to babysit. An adaptive trigger watches a guardrail and changes a flag on its own when production drifts past it. The orchestrator you shipped is operational and won’t degrade by itself, so point the trigger at the model flag from Step 9: it fails over to a backup model when your primary provider has a bad day, the same guardrail driving a different flag. That closes the loop: experiment to find the winner, runtime control to ship it, and automation to keep it healthy. Step 9: Extend the Experiment Tighten the bands by adding more topics. Confidence comes from more distinct topics, not more runs over the same few. Download one with a title-phrase (ti:) query, and the harness picks it up automatically on the next run: Shell uv run python scripts/download_papers.py --query 'ti:"LLM-as-a-judge"' Make quality the headline by flipping a config, not a flag. The framework lives in the orchestrator flag because it is app-level routing, not a property of any agent. The model, the prompt, and the tool set are different: they live in the node configs, so you experiment on the config itself. Add a second variation to a node, such as gap-synthesizer with a stronger model or a tightened prompt, and run an experiment with that config as the treatment and its variations as the arms. Pin the framework by setting the orchestrator flag to one value and leave the graph alone, so the config is the only thing moving. The judge attached to the synthesizer already emits $ld:ai:judge:gap-quality, so quality is the primary metric with no new instrumentation. Now it genuinely moves, because a different model or prompt reasons differently about the same papers. Experiment on the graph shape with a graph-key flag. The dispatcher takes the graph key as an argument, so the shape is another value you can put behind a flag: Python graph_key = ld.variation("graph_shape", context, "research-gap-graph") result = await execute_graph(ai_client, graph_key, context, user_input, build_agent, invoke) Build two graphs with different keys: for example, a linear research-gap-graph-linear (intake to approach-analyzer to gap-synthesizer) against the parallel research-gap-graph, or one with an added critic node against one without. Make a multivariate graph_shape flag whose variations are those graph keys, evaluate it exactly as the app evaluates orchestrator, and set it as the experiment treatment with the framework and model held constant. You are measuring whether the extra structure earns its latency and quality, and because the dispatcher runs whatever shape the key resolves to, no runner or dispatcher code changes. You build the judge once, and it is the guardrail for the framework bake-off, and the headline metric for every model, prompt, tool, and shape you test next. Run a native-model bake-off. This experiment holds the model constant so the framework is the only variable. To compare each framework on its own default model instead, build separate node configs per framework. This is the optional bake-off the prerequisites mention. It’s a follow-up beyond this walkthrough, and the only part that needs OPENAI_API_KEY and GOOGLE_API_KEY. Whatever you flip, follow three rules: Change one variable at a time (the framework, the model, or the shape), never two. If you change more than one, you can’t attribute the win.Keep the quality guardrail on every run, because the fastest variant is often the one that quietly truncated its report or dropped a tool call.Earn confidence with distinct inputs, not repeats: a tight band around three repeated topics is still a tight band around the wrong number. To learn more about judge design, read When to add online evals and Evaluating with LLM-as-judge evaluators. To add a pre-production regression layer, read Offline evaluation of RAG-grounded answers. Recap and Next Steps Framework choice doesn’t have to be a one-way door. Put the topology in a LaunchDarkly agent graph, have each framework supply only build_agent and invoke, and let one experiment settle a question that usually gets answered by whoever argues hardest: pin the model, let the judge guard quality, and pick the orchestrator that delivers it fastest, with evidence in hand. Then keep going, because the framework is only the first swappable component. The same flag, experiment, and judge machinery compares models, prompts, tools, and whole graph shapes the same way, so “which is better” stops being a debate and becomes a measurement. And because the experiment and the runtime control are one flag, you never stop at a finding: you ship it, ramp it with a progressive rollout, and let an adaptive trigger hold the line in production while the AI iteration loop for reliable agents keeps the next change shipping behind eval gates. The complete code is in the sample repo. Get started with AgentControl, point the four frameworks at a graph your team actually runs, and settle the next framework argument with a number instead of a hunch.
AWS can send audit logs to an attacker’s account unless denials are enforced at the network layer, while Azure doesn’t log network-block requests at all. The concept of a data perimeter was popularized by AWS [1] to establish organizational boundaries around identities, resources, and networks. In simple terms, AWS provides access controls to ensure that trusted identities access trusted resources from expected networks while blocking all outside access. This article explores how different cloud providers handle resource access logs and how it relates to data protection. It sets up an experiment where an outside identity with valid credentials accesses a trusted resource and is blocked by a policy in one of the scenarios. The experiment explains two scenarios that differ in where the deny decision is enforced. We find that the same request for resource access produces different log artifacts in AWS and Azure. AWS sends access logs containing caller-controlled metadata in both the identity and resource-owner accounts unless a network layer explicitly denies access. However, in Azure, resource access logs are only logged at the resource-owner’s subscription, and when access is blocked at the network layer, nothing is logged there either. Both behaviors have consequences for security teams collecting and analyzing audit logs. This article walks through both scenarios with lab experiments and reproducible code. Background AWS and Azure treat identities differently. In AWS, identities are not centralized into one single place — instead, they live at the account level. For example, if an organization contains 10 accounts, identities can be created in each of the 10 accounts. In comparison, in Azure, identities are centralized into one Entra ID tenant. Since a tenant is linked to multiple subscriptions containing the company’s resources, identities from the same tenant are configured to access resources inside subscriptions. In summary, the resource-owning entity in AWS (the account) also holds identities, whereas in Azure the resource-owning entity (the subscription) does not hold identities – those live in the Entra ID tenant. Secondly, AWS and Azure treat access logging differently. In AWS, CloudTrail logs API calls at the account level. For cross-account access, AWS lets customers configure CloudTrail such that when data events are enabled, the caller account and the resource-owning account get access events. For example, if an identity in Account-A accesses a resource in Account-B and gets denied, then the deny audit entry is logged in both Account-A and Account-B. This mirroring is what makes caller-controlled metadata visible to a malicious actor’s account [2]. In contrast, in Azure, resource access logs (for example, StorageBlobLogs) live in the storage account in the subscription, whereas identity logs (Entra ID) live with the tenant. These are separate systems with no automatic mirroring. This difference sets up why a correlation problem exists and why a network-layer block does not produce logs at the resource layer. Threat Model The threat model is as follows: an attacker brings their credentials inside a corporate network and accesses the company’s resource (like an S3 bucket). By doing this, the attacker tries to exfiltrate company data by encoding sensitive information in the HTTP user agent header, a caller-controlled field that appears in access logs. This allows data to leave the corporate environment in small chunks across multiple requests. The second threat is more nuanced. A security team that relies on resource-layer logs to detect unauthorized access attempts will miss requests that are blocked before reaching the resource. If the network drops the request silently, the resource (service) never logs it. An attacker who knows this can probe a corporate environment repeatedly without appearing in the audit trail that the security team is monitoring. Experiments AWS Experiment To set up this experiment, we have three accounts: a credential-owning account (identity), a VPC-owning account, and a resource-owning account. The identity is a Lambda function that tries to access an S3 bucket (resource). The Lambda function runs from a private subnet in a VPC and accesses the S3 bucket through an S3 VPC endpoint (AWS PrivateLink). All audit logs are sent to a third account – this is a typical Control Tower setup [3]. We test two scenarios: The bucket policy denies all untrusted identities — assume that the bucket policy denies access to our identity. However, the VPC endpoint policy allows all cross-account access. The bucket policy allows this untrusted identity. However, the VPC endpoint policy disallows cross-organization access. Scenario 1 When the request gets denied at S3, AWS CloudTrail generates a standard API event: JSON { "eventType": "AwsApiCall", "errorCode": "AccessDenied", "userAgent": "...", "requestParameters": {...}, "tlsDetails": {...} } The full log is in https://github.com/sureshgururajan/aws-data-exfiltration-demo/blob/main/testing-results/scenario1-log.md. In this case, the full request context is preserved. This includes: userAgent requestParameters TLS metadata Additional request context The main observation is that this event includes caller-controlled metadata in the userAgent field. Since customers can configure CloudTrail to log data events on both the caller account and the resource account, a malicious actor gets the same denial event in their account. Therefore, an attacker in an untrusted account can exfiltrate company data into their accounts by triggering these denied access requests on the company resource. Scenario 2 In the second scenario, if the VPC endpoint policy denies cross-account access (example), CloudTrail generates a different event: JSON { "eventType": "AwsVpceEvent", "eventCategory": "NetworkActivity", "errorCode": "VpceAccessDenied", ... } See the full log here. Instead of logging an AwsApiCall event, CloudTrail logs NetworkActivity with the errorCode: VpceAccessDenied and does not log the HTTP user agent header. More importantly, this event is not sent to the malicious actor or the resource owner’s account. Rather, the event is sent to the VPC endpoint owner’s account. In other words, the cause of the denial was a VPC endpoint policy, and therefore CloudTrail generates a NetworkActivity event rather than the API event and routes it to the VPC-owning account. This prevents the bad actor from stealing company data via CloudTrail. Azure Experiment To set up this experiment, we created two Azure subscriptions – one for identity and the other for the resource. An Azure function in subscription-A writes to a blob storage in subscription-B. The Azure function is registered as a system-assigned managed identity in the Entra ID tenant while turning off the shared access key for the blob storage to ensure only managed identities can access it [5]. The function uses DefaultAzureCredential to request a token from Entra ID and attempts to write to a file in the storage account. Since both subscriptions trust the same Entra ID tenant, the identity moves across subscriptions natively without needing an AssumeRole step. Like before, we run through two scenarios: Azure function has the Storage Blob Data Contributor role and the network path is open The Azure function attempts to write to the storage account but is blocked by the firewall. Scenario 1 When the request is allowed at the blob storage, the following logs are written: The Entra ID tenant gets a token request log when the Azure function uses default Azure credentials. This event does NOT contain any information about the actual API action being taken. The resource account StorageBlobLogs records a PutBlob event with the file name and IP address but doesn’t show the name of the managed identity. Sample log entry from StorageBlobLogs Plain Text TimeGenerated [UTC] - 2026-05-02T19:30:32.7306109Z OperationName - PutBlob CallerIpAddress - 172.24.1.71:9156 Uri - https://sgrstorageaccountinsubb.blob.core.windows.net:443/storage-container/test.json AuthenticationType - OAuth RequesterObjectId - 00daa177-96c6-4b29-9a5c-53ca603565e9 StatusCode – 201 UserAgentHeader - azsdk-js-azure-storage-blob/12.31.0 core-rest-pipeline/1.22.3 Node/22.22.2 (Linux 6.6.130.1-3.azl3; x64) The requester object ID field indicates which identity made the request but doesn’t reveal more details as to the identity itself. That part is left to the Entra ID logs as shown below. However, we can see that the userAgentHeader is logged. The difference with AWS is that in Azure, the StorageBlob log entry is not mirrored to Entra ID, i.e., the caller’s subscription. In Azure, it stays only in the resource owner’s subscription. Entra ID contains just the token issuance log: Sample log entry from Entra ID Plain Text Date (UTC),2026-05-02T19:30:32Z Request ID,25c5f7f7-4206-448d-817b-730744991701 Correlation ID,73cf7b90-c49b-40f0-800d-74e77e40717c Service principal ID,00daa177-96c6-4b29-9a5c-53ca603565e9 Service principal name,SureshTestingMultiCloud-Function Credential key ID, Credential thumbprint, Application,SureshTestingMultiCloud-Function Application ID ,57650788-dae5-416f-9da8-792b4ebbbb29 App owner tenant ID, Resource,Azure Storage Resource ID ,e406a681-f3d4-42a8-90b6-c2b029497af1 Resource tenant ID, Resource owner tenant ID,f8cdef31-a31e-4b4a-93e4-5f571e91255a Home tenant ID, Home tenant name, IP address, Location,", , " Status,Success Sign-in error code, Failure reason,Other. Conditional Access,Not Applied Scenario 2 In this scenario, we introduced a network-level block using the Storage Account Firewall while keeping the permissions intact. Entra ID logs still show a successful token issuance because the identity is valid and the scope is broad. However, the storage resource logs don’t log the request. Since the connection was dropped at the network layer before reaching the storage service plane, there is no “Access denied” event in the resource’s audit log. Sample log entry from Entra ID Plain Text Date (UTC): 2026-05-02T19:35:10Z Service principal name: SureshTestingMultiCloud-Function Application: SureshTestingMultiCloud-Function Resource: Azure Storage Status: Success Sample log entry from StorageBlobLogs 0 results for the KQL query: SQL // Query to check for any recorded activity after the network block StorageBlobLogs | where TimeGenerated > ago(1h) | where RequesterObjectId == "00daa177-96c6-4b29-9a5c-53ca603565e9" | project TimeGenerated, OperationName, StatusCode, StatusText, CallerIpAddress, Uri | sort by TimeGenerated desc This result shows that a network-level block is not visible in the resource layer. The Azure administrator sees a successful token issuance in Entra ID but nothing in StorageBlobLogs. To detect this, security teams need to go beyond resource-layer logs and enable additional logging layers such as NSG Flow logs or Defender for Storage - these are outside the scope of this experiment. Comparison scenarioawsazure Identity model Account-scoped Tenant scoped Who gets audit logs? (when available and enabled) Caller-side and resource-owner side (Scenario 1 only) Resource-owner side only Where are the audit trails located? CloudTrail is the logging service. CloudTrail logs are distributed across Caller account, the resource account, and the VPC-owning account Token issuance logs are in the Tenant (Entra ID) while resource access logs are in the Subscription Caller-controlled metadata visible? Yes, visible in caller account and resource account Yes, but included in resource account only What a network-layer block produces When using VPC endpoint policy, AwsVpceEvent is produced and is routed to the VPC-owner account. No logs in resource-owner account. No resource-layer log entry. Identity context in resource logs Full caller identity context included Only the caller ID in the form of RequesterObjectId. An operator must correlate this ID with service principal ID in Entra ID logs. Mitigation We saw that in AWS, CloudTrail can be configured to send log events on both the caller account and the resource account. An attacker can use this information to silently exfiltrate small amounts of data at a time. To mitigate this attack vector, an organization must: Run their compute services in an Amazon VPC — preferably in a private subnet, and Use VPC endpoints with endpoint policies [4] to access their AWS resources for the compute services. The endpoint policies must allow trusted identities to access the resource while blocking everything else. AWS already documents these controls in [1], but these experiments show how important it is to enforce these controls. This is in addition to all the controls that an organization already uses, such as Service Control Policies and Resource Control Policies — those policies control the maximum permissible action that can be taken by an identity/resource but do not control the CloudTrail logging behavior. While Azure doesn’t have the above attack vector specifically, it has a different problem — an operator must manually correlate Entra ID events with the resource event. An example would be an “identity journey” like — managed identity (like the Azure function) requests a token, then writes to a storage account. Therefore, some tooling must be built to correlate such events — for example, routing both ManagedIdentitySignInLogs and StorageBlobLogs into a single Log Analytics workspace is a minimum. Additionally, logs must be captured at different layers such as NSG flow logs/Defender for Storage that can provide anomaly detection beyond standard diagnostic logs. Conclusion In this article, we demonstrated how the same access request produces different results in AWS and Azure. In AWS, access logs were sent to the resource account or the VPC account depending on where the deny decision was enforced, while in Azure, access logs were only sent to the resource account. We saw that this difference comes from how each cloud provider fundamentally treats identities and resources. The implications of the experiment are that security teams in multi-cloud environments cannot assume that audit coverage works the same way across providers. Each provider models their identities and provides different data perimeter controls. Before designing data perimeter controls, security teams must understand each provider’s logging architecture and its differences. References [1] https://aws.amazon.com/identity/data-perimeters-blog-post-series/ [2] https://systemweakness.com/a-subtle-audit-log-consideration-in-aws-063752150b20 [3] https://docs.aws.amazon.com/controltower/latest/userguide/what-shared.html [4] https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints-access.html [5] https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent?tabs=portal
This guide explains zone-aware routing from a Kubernetes-first point of view. It covers: why zones matter in cloud platformswhich topology labels Kubernetes places on nodeshow Kubernetes first tried to solve locality through Servicewhat gaps remained after those Service-based featureshow Gateway API implementations such as Envoy Gateway and kgateway built on top of that foundation Why Zones Matter In cloud platforms, a zone is a logical failure domain inside a region. Zones usually have low-latency networking within the zone, but crossing zones can increase both latency and cost. That cost is not theoretical. AWS documents that traffic within the same Availability Zone is free, while traffic that crosses Availability Zones typically incurs data transfer charges, and cross-zone transfer is generally billed in both directions, so a single round trip can be charged twice. See: AWS Architecture Blog: Overview of Data Transfer Costs for Common ArchitecturesAmazon EC2 pricing: Data Transfer This is one reason distributed systems try to keep traffic local when they can, while still preserving failover to other zones. The Topology Information Kubernetes Already Has Kubernetes did not start by inventing zone-aware traffic policies. It started by carrying topology information on nodes. The two most important well-known labels are: topology.kubernetes.io/regiontopology.kubernetes.io/zone According to the Kubernetes reference, these labels are populated on Node objects by the kubelet or the external cloud-controller-manager when the cluster is integrated with a cloud provider. In non-cloud environments, operators can set them manually if the topology model still makes sense. Reference: Kubernetes well-known labels: topology.kubernetes.io/zone In managed clusters, these labels are commonly present by default. Here is the kind of node data Kubernetes typically exposes: YAML apiVersion: v1 kind: Node metadata: name: ip-10-0-12-34.ec2.internal labels: kubernetes.io/hostname: ip-10-0-12-34.ec2.internal topology.kubernetes.io/region: us-east-1 topology.kubernetes.io/zone: us-east-1a That topology data is useful for scheduling, spreading replicas, volume placement, and eventually traffic routing. The Original Service Model The original Kubernetes Service abstraction solved a different problem first: stable discovery and virtual IPs for ephemeral Pods. At the beginning, the model was simple: a Service selected a set of Podskube-proxy programmed forwarding rulestraffic could be sent to any healthy endpoint behind the Service That was excellent for reachability and abstraction, but it had no built-in notion of zone locality. The gap was straightforward: the Service abstraction knew which endpoints existed, but not that a client in zone-a should usually prefer endpoints in zone-a. Kubernetes' First Attempts to Improve Locality Through Services Kubernetes gradually added locality-aware behavior on top of Service, mostly by improving how endpoint selection works. Internal Traffic Policy One early mechanism was internalTrafficPolicy: Local. This tells kube-proxy to use only node-local endpoints for cluster-internal traffic. Example: YAML apiVersion: v1 kind: Service metadata: name: my-service spec: selector: app: my-app ports: - port: 80 targetPort: 8080 internalTrafficPolicy: Local Reference: Kubernetes Service Internal Traffic Policy This helps with node locality, but it is not zone-aware routing. Its limitations are important: it is node-local, not zone-localif a node has no local endpoint, the Service behaves as if it has zero endpoints from that node's perspectiveit is too strict for many multi-zone workloads that want zonal preference, not node affinity So this was useful, but it did not really solve multi-zone locality. Topology Aware Routing With Services Kubernetes next introduced Topology Aware Hints, now called Topology Aware Routing. This works through two components: The EndpointSlice controller looks at endpoint and node topology.kube-proxy consumes hints from EndpointSlices and prefers endpoints closer to the client zone. Historically, the Service-side configuration was commonly exposed through the service.kubernetes.io/topology-mode: Auto annotation: YAML apiVersion: v1 kind: Service metadata: name: zone-aware-backend annotations: service.kubernetes.io/topology-mode: Auto spec: selector: app: backend ports: - port: 80 targetPort: 8080 Conceptually, the flow looks like this: This was Kubernetes' first real zone-aware answer at the Service layer. It is useful historical context, but it is no longer the clearest Service-level API to emphasize for new users. Traffic Distribution Preferences Kubernetes later added trafficDistribution as a clearer way to express routing preferences. In current Kubernetes documentation, the relevant zone-level preference is: PreferSameZone The older PreferClose name is documented as deprecated in favor of PreferSameZone, though you may still see PreferClose in some provider and implementation docs that have not yet caught up. Example: YAML apiVersion: v1 kind: Service metadata: name: zone-aware-backend spec: selector: app: backend ports: - port: 80 targetPort: 8080 trafficDistribution: PreferSameZone Reference: Kubernetes Service trafficDistribution This is a better API shape than older annotations because it is explicit in the Service spec and described as a preference rather than a strict guarantee. In practice, that means current Kubernetes guidance emphasizes trafficDistribution: PreferSameZone, while the older topology-mode: Auto path is best understood as part of the feature's evolution. What Gap Remained After Service-Based Locality Kubernetes Services improved a lot, but they still left several gaps. The Behavior Is Best Effort Topology-aware routing is not a hard guarantee. Kubernetes documents multiple safeguard cases where the system falls back to cluster-wide routing. Examples include: too few endpointsimpossible balanced allocationmissing topology labels on one or more nodesmissing hints for one or more endpointsno hinted endpoint for the local zone That is correct for safety, but it means the behavior is heuristic and conditional. It Assumes a Certain Traffic Shape Kubernetes explicitly documents that Topology Aware Routing works best when traffic is roughly evenly distributed and when there are enough endpoints per zone. If most traffic originates from one zone, local subsets can overload while the global service still looks healthy. It Is Scoped to the Service Datapath This is the most important architectural gap. Service-level topology features influence how kube-proxy chooses endpoints for Service traffic. They do not automatically solve every higher-level data plane. In particular, they do not by themselves define: how an L7 gateway proxy should understand its own zonehow an Envoy-based gateway should configure locality-aware upstream load balancinghow a gateway controller should express stricter local preference versus simple best-effort localityhow policy should attach to particular routes, gateways, or backends That left room for Gateway API implementations to expose richer locality controls. Why Gateway API Implementations Stepped In Gateway API is intentionally expressive and extensible. It standardizes core routing objects, but implementations often add policy CRDs to expose features that are specific to their data plane. That distinction matters here: Gateway API itself does not define one universal, cross-implementation zone-aware policy. Instead, it gives implementations room to expose locality behavior in a way that matches their proxy and control-plane design. Reference: Gateway API overview This is where zone-aware routing became more explicit at the gateway layer. Instead of relying only on kube-proxy's Service behavior, gateway implementations can: understand the proxy's own localityread backend endpoint localityconfigure the underlying proxy's load balancer directlyexpose locality policies as route or backend-attached configuration Example of How Envoy Gateway Addresses the Gap Envoy Gateway supports two paths: Reusing Kubernetes Service-level locality such as Topology Aware Routing or trafficDistributionConfiguring zone awareness directly through BackendTrafficPolicy Reference: Envoy Gateway zone-aware routingEnvoy zone-aware routing Example BackendTrafficPolicy: YAML apiVersion: gateway.envoyproxy.io/v1alpha1 kind: BackendTrafficPolicy metadata: name: zone-aware-routing spec: targetRefs: - group: gateway.networking.k8s.io kind: HTTPRoute name: zone-aware-routing loadBalancer: type: RoundRobin zoneAware: preferLocal: minEndpointsThreshold: 1 force: minEndpointsInZoneThreshold: 1 That is a meaningful step beyond plain Service because the gateway layer is now explicitly participating in locality-aware upstream balancing. Example of How kgateway Addresses the Gap kgateway takes a similar approach in spirit: proxy locality is made explicit, and backend load-balancing behavior is configured through policy rather than relying only on Service heuristics. At a high level, kgateway combines: Gateway proxy locality configurationBackend-attached load-balancing policyNative Envoy locality-aware upstream load balancingEndpoint locality metadata that Envoy can use directly Architectural Summary The progression looks like this: Kubernetes Service solved stable discovery and reachability.internalTrafficPolicy improved node-local routing, but not zonal routing.Topology Aware Routing and trafficDistribution added zone-aware preferences to the Service datapath.Gateway API implementations extended the model so L7 gateways and proxies could make explicit locality-aware decisions themselves. Practical Takeaways Kubernetes already provides the topology metadata needed for zone-aware decisions.Service-native locality is useful, but it is heuristic and scoped to the Service datapath.Zone-aware traffic for gateways usually needs the gateway implementation to understand locality too.Modern Gateway API implementations fill that gap by attaching locality-aware load-balancing policy closer to the L7 data plane. Where Zone-Aware Routing Matters in Practice Zone-aware routing usually becomes worth the added operational attention when one or both of these are true: The workload has a tight latency budget, especially at p95 or p99The system moves enough east-west traffic that even a small per-GB cross-zone charge becomes material Common examples include: Gaming platforms, where matchmaking, player session state, inventory, and real-time coordination are sensitive to a few extra milliseconds of network delayFinancial services, where payment, quote, fraud, or checkout paths care more about predictable tail latency than average latencyLarge SaaS and enterprise control planes, where a gateway fans out to many internal APIs and the aggregate cross-zone traffic becomes a real monthly costAI inference, media delivery, logging, and telemetry pipelines, where payload sizes are large enough that bandwidth cost matters even when latency is less critical Worked Example: Multiplayer Gaming Backend Suppose a regional game API runs gateway proxies and backend pods in three zones. Players connect to a gateway in zone-a, and that gateway calls a player-state service that is also deployed in zone-a, zone-b, and zone-c. Assume the following: 25,000 requests per second reach the player-state service from zone-athe combined request and response payload is about 40 KiB per callcross-zone traffic is billed at a representative $0.01 per GBwithout zone awareness, only about one third of those calls stay in zone-a, while the other two thirds go to zone-b or zone-c Actual billing varies by provider, region, and direction of transfer, but the point of the example is that a seemingly small per-GB rate compounds quickly on hot service paths. That means the traffic volume from zone-a to the player-state service is about: 25,000 x 40 KiB per second, or roughly 1 GB/s totalif two thirds of that traffic crosses zones, that is about 0.67 GB/s of cross-zone trafficover a 30-day month, that is about 1.7 million GBat $0.01 per GB, that is about $17,000 per month in cross-zone transfer for just that one service path That is the cost side. The latency side can matter even more for the player experience. If each cross-zone hop adds only 1-3 ms, a request path that fans out to several internal services can add multiple milliseconds of extra tail latency. For a gaming workload, that can affect: matchmaking responsivenesssession join timethe smoothness of player state or presence updateshow stable the system feels during traffic spikes and retries This is why zone-aware routing is not only a cost optimization. In some industries, it is a user-experience and SLO control. Worked Example: Large SaaS Control Plane The same logic applies outside gaming. Consider a large enterprise SaaS platform where each incoming API request hits a gateway and then fans out to an auth service, tenant metadata service, feature-flag service, and audit pipeline. Even if each individual backend call is small, the gateway can generate a large amount of aggregate east-west traffic. In that kind of system, zone-aware routing helps in two ways: it removes avoidable cross-zone traffic from the steady-state hot pathit reduces the chance that a multi-hop request burns several extra milliseconds just on internal network distance For that kind of platform, the business case is usually a combination of lower regional data-transfer cost, tighter latency distributions, and better failure-domain alignment. Conclusion Zone-aware routing is the story of a single idea moving down the stack. Kubernetes started with topology labels on nodes, then taught the Service datapath to prefer local endpoints through internalTrafficPolicy, Topology Aware Routing, and trafficDistribution. Those features are valuable, but they are best-effort and they stop at the Service boundary, which leaves L7 gateways unable to reason about their own locality. Gateway API implementations such as Envoy Gateway and kgateway pick the idea up from there, making proxy locality explicit and pushing locality-aware load balancing into Envoy where it can act on real endpoint metadata. The practical guidance is short. Start with the Service-native controls, because they are simple and often enough. Reach for gateway-level locality policy when you have a tight tail-latency budget, or enough east-west traffic that cross-zone transfer becomes a line item you can see. In both cases, the goal is the same: keep traffic local when you safely can, and fail across zones when you must. Further Reading Kubernetes ServiceKubernetes Topology Aware RoutingKubernetes Service Internal Traffic PolicyKubernetes well-known topology labelsGateway API overviewAWS Architecture Blog: Data transfer costs
The evolution from monolithic applications to microservices transformed enterprise software by decomposing business capabilities into independently deployable services. REST APIs, asynchronous messaging, and service discovery enabled systems that scaled both organizationally and technically. Although this model remains effective for deterministic business logic, the emergence of AI agents introduces a different execution paradigm. Instead of invoking predefined endpoints, an agent receives an objective, reasons about available capabilities, selects appropriate services, and dynamically composes a workflow. This shift changes service boundaries from business functionality to decision-making and capability orchestration. Why This Matters Traditional microservices assume that applications already know which services to invoke. An Order Service calls Inventory, Payment, and Shipping because the workflow is explicitly encoded during development. An AI agent, however, begins with an intent rather than an execution path. A request such as "purchase the least expensive laptop available and deliver it tomorrow" requires evaluating inventory, pricing, promotions, shipping constraints, and fraud policies before any API is called. The workflow is determined during execution instead of implementation. A conventional orchestration service typically resembles the following implementation. Java public OrderResponse checkout(OrderRequest request) { Inventory inventory = inventoryClient.reserve(request); Payment payment = paymentClient.authorize(request); Shipping shipment = shippingClient.schedule(request); return new OrderResponse(payment, shipment); } The implementation is deterministic because every dependency is known beforehand. Adding another payment gateway or shipping provider requires modifying orchestration logic, gradually increasing coupling between services. As enterprises integrate AI-driven workflows, continuously extending predefined execution paths becomes increasingly difficult. Agent Services replace hardcoded dependencies with capability discovery. Rather than directly invoking an Inventory Service, the runtime identifies which registered capability satisfies the current intent. Java public Tool resolve(Intent intent) { return toolRegistry.stream() .filter(tool -> tool.supports(intent)) .findFirst() .orElseThrow(() -> new ToolNotFoundException(intent.name())); } The registry enables services to advertise capabilities instead of exposing only procedural APIs. Existing microservices remain responsible for inventory reservation, payment authorization, or shipment scheduling, but the responsibility for deciding which capability should execute moves into an intelligent coordination layer. New business capabilities can therefore be introduced without rewriting orchestration code. This distinction fundamentally changes API design. Traditional REST endpoints expose operations such as /reserveInventory or /authorizePayment. Agent-oriented systems instead expose semantic capabilities like "find lowest cost supplier," "recommend shipping option," or "detect payment risk." These descriptions allow planning engines to reason about business objectives instead of matching endpoint names. Reasoning requires an additional architectural component capable of translating natural language into executable plans. This responsibility belongs to an Intent Router, which functions similarly to an API Gateway but routes requests based on semantic meaning rather than URLs. Java public ExecutionPlan plan(String goal) { Intent intent = classifier.classify(goal); Tool tool = registry.resolve(intent); return planner.create(tool, goal); } The classifier converts an objective into structured intent, the registry discovers an appropriate capability, and the planner generates an execution strategy. Once planning completes, downstream execution remains deterministic. Large language models participate only during reasoning, while conventional microservices continue enforcing validation rules, transactional consistency, and domain constraints. Separating planning from execution preserves enterprise reliability while introducing adaptive behavior. This separation also dispels a common misconception that AI agents replace microservices. Business logic continues to belong inside deterministic services because payment authorization, inventory consistency, pricing calculations, and compliance rules require predictable execution. Agent Services instead provide an intelligent layer responsible for selecting, coordinating, and sequencing those services according to business objectives. Rather than replacing existing architectures, they extend them with decision-making capabilities that previously existed only inside application code. Consequently, service boundaries begin shifting away from business entities toward reusable decision engines. Instead of embedding procurement, logistics, or fraud decisions inside multiple applications, organizations can expose these responsibilities as independent Agent Services that orchestrate existing microservices. The underlying APIs remain stable while reasoning evolves independently, enabling enterprise systems to become progressively more adaptive without sacrificing the deterministic foundations that made microservice architectures successful. Taking Memory Into Account Memory becomes the next architectural concern once planning is separated from execution. Stateless REST requests work well for isolated transactions, but agents frequently solve objectives through multiple reasoning cycles. Intermediate decisions, retrieved knowledge, user preferences, and execution history must persist beyond a single request. This context is operational rather than transactional. Business entities continue residing in relational databases, while the agent memory layer preserves reasoning state that enables future decisions to remain consistent. Java public AgentContext update(String sessionId, Observation observation) { AgentContext context = repository.load(sessionId); context.append(observation); repository.save(context); return context; } Rather than storing business records, the memory layer continuously enriches execution context with observations generated during planning. Future reasoning cycles consume this accumulated context instead of repeatedly querying downstream services, reducing redundant tool execution while maintaining continuity across long-running workflows. As objectives become more sophisticated, a single agent rarely owns every required capability. Instead of directly invoking multiple APIs, an agent can delegate specialized responsibilities to another agent while maintaining overall coordination. This interaction is based on expertise rather than ownership, allowing procurement, logistics, compliance, or fraud agents to evolve independently while sharing the same underlying microservices. Java AgentResponse response = logisticsAgent.execute( new AgentTask( "Optimize shipping route", context)); Delegation transfers structured objectives instead of procedural API calls. Each agent independently plans its assigned task before returning a deterministic result. Existing Inventory, Payment, and Shipping services remain unchanged, while the coordination layer becomes modular and extensible. Observability Implications Observability must also evolve because traditional distributed tracing explains service execution but not decision making. Understanding why an agent selected one capability over another is equally important as measuring latency or availability. Reasoning traces therefore become first-class telemetry alongside conventional application metrics. Java Span span = tracer.nextSpan() .name("agent.plan"); span.tag("goal", goal); span.tag("selectedTool", tool.name()); span.tag("confidence", score.toString()); span.end(); Capturing planning metadata allows engineering teams to correlate business outcomes with reasoning quality. An operation may succeed technically while producing an incorrect recommendation because the planner selected an unsuitable capability. Monitoring therefore expands beyond response times to include tool selection, planning confidence, execution cost, and reasoning latency. Autonomous planning also introduces governance challenges. Traditional services authorize callers before executing business logic, whereas Agent Services must additionally validate that planners invoke only approved capabilities. Every tool should expose explicit permissions and execution policies so that reasoning engines remain constrained by enterprise governance regardless of how plans are generated. Java public ToolResult execute(AgentTask task) { policyEngine.authorize(task.agent(), task.tool()); return toolExecutor.run(task); } Separating authorization from planning ensures deterministic policy enforcement around probabilistic reasoning. Existing identity providers, audit systems, and compliance frameworks remain applicable because execution ultimately flows through governed business capabilities rather than unrestricted model outputs. A Final Word The transition from microservices to Agent Services is therefore not a replacement of proven architectural principles but their natural evolution. Microservices continue delivering transactional consistency, persistence, and deterministic business logic, while Agent Services introduce planning, semantic routing, capability discovery, memory, and adaptive orchestration. The architectural boundary shifts from exposing operations to exposing decisions, allowing intelligent planners to compose existing services according to business objectives rather than predefined workflows. Enterprise platforms adopting this layered approach preserve the reliability of mature microservice ecosystems while gaining the flexibility required for AI-native applications, making Agent Services the next logical abstraction for software systems where reasoning becomes as important as execution.
The Model Context Protocol connects AI agents to your databases, APIs, and file systems. Out of the box, it connects them with no identity, no scoping, and no audit trail. The MCP specification acknowledges this gap explicitly. Its OAuth 2.1 authorization spec marks authentication as optional. The result, according to research published on Security Boulevard in April 2026, is that 53 percent of open-source MCP implementations ship with static API keys. Eighty-eight percent require backend authentication, but only 8.5 percent implement proper credential management. Every one of those static keys is a credential waiting to be stolen, a scope waiting to be abused, and an audit entry that will read "unknown agent executed query" when the incident report is written. This article builds the alternative. We will build an MCP server in Python that accepts tool calls only from authenticated agents, validates OAuth 2.1 Bearer tokens using stateless JWKS-based validation, enforces tool-level scopes and roles, maintains an infrastructure-level tool allow-list, and logs every access decision with the full delegation chain back to the human who authorized it. The complete companion project, roughly 350 lines of Python with a 13-test suite, is available on GitHub. Prerequisites You will need Python 3.12 or later and an OIDC-compatible identity provider. The examples use Auth0 (free tier works), but Okta, Keycloak, Entra ID, or any provider that exposes a /.well-known/jwks.json endpoint will work. Basic familiarity with OAuth 2.1 concepts and MCP server architecture is assumed. All code shown is extracted from the companion project. File paths reference code/src/. Architecture Every tool call flows through five gates before reaching your business logic: Architecture: Five-gate MCP tool call authorization pipeline. Gates two and three are infrastructure-level controls. System prompts are not security controls. An MCP server the agent has not been explicitly authorized to call should be unreachable. Period. Regardless of what the LLM decides to invoke. Part 1: JWKS-Based Token Validation The foundation of an identity-aware MCP server is stateless JWT validation. Every request carries a Bearer token issued by your OAuth 2.1 authorization server. The MCP server validates it against the provider's JSON Web Key Set, a public key document that lets you verify signatures without a network call to the IdP on every request. The JWKS Cache Create src/auth/middleware.py. We start with a cache that fetches the JWKS once and holds it in memory, refreshing every five minutes or on-demand when an unknown key ID appears (key rotation): Python class JWKSCache: """Cached JWKS with automatic refresh on unknown key id.""" def __init__(self, jwks_url: str, cache_ttl: int = 300): self._url = jwks_url self._ttl = cache_ttl self._keys: dict[str, dict] = {} self._last_fetch: float = 0 async def get_key(self, kid: str) -> dict: if not self._keys or (time.monotonic() - self._last_fetch) > self._ttl: await self._refresh() key = self._keys.get(kid) if key is None: logger.info("Unknown kid '%s', forcing JWKS refresh", kid) await self._refresh() key = self._keys.get(kid) if key is None: raise AuthError(f"Key '{kid}' not found in JWKS", 401) return key async def _refresh(self) -> None: if self._url.startswith("http"): async with httpx.AsyncClient() as client: resp = await client.get(self._url, timeout=10) resp.raise_for_status() jwks = resp.json() else: with open(self._url) as fh: jwks = json.load(fh) self._keys = {k["kid"]: k for k in jwks.get("keys", [])} self._last_fetch = time.monotonic() The get_key method is where the key rotation logic lives. When a token arrives with a kid the cache has never seen, we force a refresh before rejecting it. An unknown kid could mean a legitimate rotation, not an attack. We try once more before failing. In practice, this means you never need to restart your MCP server when your identity provider rotates signing keys. The Token Validator The validator uses the cache to verify every Bearer token. It checks five things, and the order matters: header validity, signature, issuer, audience, and expiry: Python class TokenValidator: def __init__(self, jwks_url: str, issuer: str, audience: str, clock_tolerance: int = 30): self._jwks = JWKSCache(jwks_url) self._issuer = issuer self._audience = audience self._clock_tolerance = clock_tolerance async def validate(self, token: str) -> ValidatedToken: # 1. Decode header to get the key id. unverified = jwt.get_unverified_header(token) kid = unverified.get("kid") if not kid: raise AuthError("Token header missing 'kid' claim", 401) # 2. Fetch the matching public key. jwk = await self._jwks.get_key(kid) # 3. Verify signature + standard claims. claims = jwt.decode( token, jwk, algorithms=["RS256"], issuer=self._issuer, audience=self._audience, options={"verify_exp": True, "require": ["exp", "iss", "sub", "aud"]}, ) # 4. Clock-tolerance check (belt-and-suspenders with the library). now = int(time.time()) if claims["exp"] + self._clock_tolerance < now: raise AuthError("Token has expired", 401) # 5. Extract scopes, roles, and delegation chain. scope_str = claims.get("scope", "") token_scopes = set(scope_str.split()) roles = claims.get("roles", []) delegation_chain = self._extract_delegation(claims) return ValidatedToken( subject=claims["sub"], email=claims.get("email"), roles=roles, scopes=token_scopes, delegation_chain=delegation_chain, ) The iss (issuer) check prevents tokens from a different authorization server from being accepted. The aud (audience) check prevents tokens intended for a different service from being replayed against yours. The exp check with clock tolerance handles the reality that clocks drift. Thirty seconds of tolerance is the pragmatic default recommended by the Upstash MCP OAuth deep-dive. The delegation chain extraction is worth examining separately. When an agent acts on behalf of a human who authorized it, RFC 8693's act claim carries that nesting. We recursively unpack it: Python def _extract_delegation(self, claims: dict) -> list[str]: chain = [] act = claims.get("act", {}) while act: sub = act.get("sub", "") if sub: chain.append(sub) act = act.get("act", {}) return chain A token issued directly to a human will have an empty delegation chain. A token issued to an agent acting on behalf of "[email protected]" will carry ["[email protected]"]. A multi-hop chain, human to orchestrator agent to sub-agent, carries both identifiers in order. This is what lets your audit logs trace every action back to a person. Part 2: The Two Mandatory Discovery Endpoints An MCP client connecting to your server needs to discover two things: that authentication is required, and where to get tokens. The MCP specification mandates two well-known endpoints for this, defined in RFC 9728 and RFC 8414, respectively. Create src/auth/discovery.py: Python def build_discovery_routes( resource_url: str, authorization_server_url: str, scopes_supported: list[str] | None = None, ) -> dict: async def protected_resource(request: Request) -> JSONResponse: return JSONResponse({ "resource": resource_url, "authorization_servers": [authorization_server_url], "bearer_methods_supported": ["authorization_code"], }) async def authorization_server(request: Request) -> JSONResponse: return JSONResponse({ "issuer": authorization_server_url, "authorization_endpoint": f"{authorization_server_url}/authorize", "token_endpoint": f"{authorization_server_url}/oauth/token", "jwks_uri": f"{authorization_server_url}/.well-known/jwks.json", "scopes_supported": scopes_supported or [ "database.read", "database.write", "email.send", "admin.users.read", ], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "client_credentials"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"], }) return { "/.well-known/oauth-protected-resource": protected_resource, "/.well-known/oauth-authorization-server": authorization_server, } Without these endpoints, MCP clients cannot auto-discover your authentication configuration. The client first hits your server without a token, receives a 401 with a WWW-Authenticate header pointing to the protected resource metadata, fetches it to confirm auth is required, then reads the authorization server metadata to learn the token endpoint and supported grant types. code_challenge_methods_supported: ["S256"] is not optional. MCP clients are public clients. They cannot keep a client secret, so PKCE is the only defense against authorization code interception. The NAPTHA AI reference implementation explicitly documents this. Part 3: Tool Definitions With Scope and Role Requirements Now we define the tools themselves. Each tool declares what scopes and roles are required to invoke it. These declarations live alongside the tool code, not in a separate config file. Proximity reduces the chance of drift between a tool and its authorization requirements. Create src/tools/database.py: Python # Each tool is a handler with declared requirements. TOOL_REGISTRY: dict[str, tuple[list[str], list[str], callable]] = { "read_customer_record": ( ["database.read"], # required scopes [], # required roles read_customer_record, # handler ), "update_customer_plan": ( ["database.write"], [], update_customer_plan, ), "list_all_customers": ( ["admin.users.read"], ["admin"], # admin role required list_all_customers, ), } A developer with database.read scope can read customer records but cannot update plans. A contractor with no scopes gets blocked from everything. An admin with admin.users.read scope and the admin role can list all customers. The registry is the single source of truth for access control. The server enforces it at request time without consulting a database. Here is one tool handler showing resource-level constraint enforcement: Python async def read_customer_record(customer_id: int, *, _token=None) -> dict: # Optional: enforce per-resource constraints from the token. if _token and hasattr(_token, "raw_claims"): constraint = _token.raw_claims.get("resource_constraints", {}) allowed_id = constraint.get("customer_id") if allowed_id is not None and customer_id != allowed_id: raise PermissionError( f"Token scoped to customer {allowed_id}, " f"requested customer {customer_id}" ) record = _CUSTOMER_DB.get(customer_id) if record is None: raise ValueError(f"Customer {customer_id} not found") return record The resource_constraints claim in the token is what turns "this agent can read customer data" into "this agent can read customer 48291 for the next sixty seconds." It is the difference between scoping to a database table and scoping to a row. Part 4: The Tool Allow-List Gate System prompts are not security controls. A prompt injection can rewrite an agent's intent mid-session and convince it to call a tool it was never meant to access. The only reliable defense is an infrastructure-level allow-list that rejects unauthorized tool calls regardless of what the LLM decides. The allow-list is derived directly from the tool registry. Any tool not in the registry is unreachable: Python ALLOWED_TOOLS: set[str] = set(TOOL_REGISTRY.keys()) This set is checked before scope and role evaluation. A tool that is not in the registry cannot be called, period. A tool that is in the registry but requires scopes the token does not carry gets a 403. A tool that is in the registry and the token carries the right scopes goes through. The distinction between "tool not in allow-list" and "tool forbidden for this agent" matters for debugging and audit. The first indicates a misconfiguration or an attack. The second indicates a legitimate agent attempting an unauthorized operation, which itself is worth logging. Part 5: The Audit Logger Every tool call, successful or blocked, produces an audit log entry with the full delegation chain. The format is JSON Lines: one JSON object per line, ingestible by any SIEM, Splunk, or grep. Create src/audit/logger.py: Python class AuditLogger: def __init__(self, filepath: str | Path = "audit.log") -> None: self._path = Path(filepath) self._path.touch(exist_ok=True) def record(self, event: str, token: ValidatedToken, tool_name: str = "", tool_args: dict | None = None, result_summary: str = "", error: str = "") -> None: entry = { "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "event": event, "correlation_id": str(uuid.uuid4()), "subject": token.subject, "email": token.email, "roles": token.roles, "scopes": sorted(token.scopes), "delegation_chain": token.delegation_chain, "tool": tool_name, "tool_args": tool_args or {}, "result": result_summary, "error": error, } with open(self._path, "a") as fh: fh.write(json.dumps(entry, default=str) + "\n") When an auditor asks "who authorized this data access," the answer is in the log, not in a code review three weeks later. A correctly logged tool call looks like this: Python { "timestamp": "2026-06-14T14:04:00Z", "event": "tool_call", "subject": "alice-developer", "email": "[email protected]", "roles": ["developer"], "scopes": ["database.read", "email.send"], "delegation_chain": ["bob-admin"], "tool": "read_customer_record", "tool_args": {"customer_id": 1001}, "result": "ok" } Delegation chain flow: Human → Orchestrator Agent → Sub-Agent → MCP Server. The delegation chain reads: Bob (admin) delegated to Alice's developer agent, which called read_customer_record for customer 1001 at 14:04 UTC. If your logs cannot produce that sentence, your AI identity program is not operational. Part 6: Assembling the Server The main server wires together the token validator, the tool allow-list, the scope and role checks, the tool handlers, and the audit logger. Every request flows through them in order. Create src/server.py. Here is the core request path: Python token_validator = TokenValidator( jwks_url=OIDC_JWKS_URL, issuer=OIDC_ISSUER, audience=OIDC_AUDIENCE, clock_tolerance=30, ) audit = AuditLogger(AUDIT_LOG_FILE) async def mcp_tool_endpoint(request: Request) -> JSONResponse: # 1 — Extract and validate the Bearer token. auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): raise AuthError("Missing Bearer token", 401) token_str = auth[7:] try: token = await token_validator.validate(token_str) except AuthError: audit.record("auth_failure", ...) raise # 2 — Parse the tool invocation. body = await request.json() tool_name = body.get("tool", body.get("name", "")) tool_args = body.get("arguments", body.get("args", {})) # 3 — Tool allow-list enforcement. if tool_name not in ALLOWED_TOOLS: audit.record("tool_allow_list_block", token, tool_name=tool_name) return JSONResponse( {"error": f"Tool '{tool_name}' is not authorized"}, status_code=403, ) # 4 — Scope + role authorization. required_scopes, required_roles = get_tool_requirements(tool_name) if required_scopes and not token.has_any_scope(required_scopes): return JSONResponse( {"error": "Insufficient scopes", "required": required_scopes, "granted": sorted(token.scopes)}, status_code=403, ) if required_roles: if not (set(token.roles) & set(required_roles)): return JSONResponse( {"error": "Insufficient role", "required_one_of": required_roles, "have": sorted(token.roles)}, status_code=403, ) # 5 — Execute and audit. handler = TOOL_REGISTRY[tool_name][2] result = await handler(**tool_args, _token=token) audit.record("tool_call", token, tool_name=tool_name, tool_args=tool_args, result_summary=str(result)[:200]) return JSONResponse({"result": result}) The 401 response format is specified by the MCP specification. The WWW-Authenticate header with resource_metadata is how clients discover that authentication is required: Python async def auth_error_handler(request, exc): return Response( content='{"error":"' + exc.args[0] + '"}', status_code=401, media_type="application/json", headers={ "WWW-Authenticate": ( f'Bearer resource_metadata=' f'"{AUDIENCE}/.well-known/oauth-protected-resource",' f'error="invalid_token"' ), }, ) Part 7: The Demo Agent To verify the server end-to-end without configuring a real OAuth provider, the companion project includes a demo agent that generates self-signed tokens for three simulated identities. Run it with python demo/agent.py --demo. The demo creates three agents with progressively restricted access: Plain Text Agent 1: Alice — developer, scopes: database.read + email.send ✓ Can read customer records ✗ Cannot update plans (missing database.write) ✗ Cannot list all customers (missing admin role) Agent 2: Bob — admin, scopes: database.read + database.write + admin.users.read ✓ Can read customer records ✓ Can update plans ✓ Can list all customers Agent 3: Carol — contractor, scopes: (none) ✗ Blocked from everything This is not a theoretical exercise. In the Stryker attack of March 2026, a compromised admin credential, one identity, over-privileged, with no scoping, allowed attackers to remotely wipe 200,000 devices across 79 countries. The attack did not use malware. It used the platform's own legitimate wipe functionality. The credential had no scope limiting it to a subset of devices, no short lifetime, and no audit trail that would have surfaced the anomaly before tens of thousands of endpoints were erased. Part 8: Testing The companion project includes a 13-test suite that verifies every security gate. Run it with: Python python -m pytest tests/ -v The test matrix covers the decision table exhaustively: TestConditionExpectedNo tokenMissing Authorization header401Invalid tokenMalformed JWT401Expired tokenexp in the past401Valid token + correct scopedatabase.read calling read_customer_record200Valid token + wrong scopeemail.send calling read_customer_record403Valid token + missing scopedatabase.read calling update_customer_plan403Valid token + correct scopesdatabase.read database.write calling update_customer_plan200Valid token + wrong roledeveloper role calling list_all_customers403Valid token + correct roleadmin role calling list_all_customers200Unknown tooldelete_everything not in allow-list403Discovery: protected resourceUnauthenticated GET200Discovery: authorization serverUnauthenticated GET200Audit log entriesTool call with delegation chainWritten with full chain Each test generates a real RSA key pair, signs a JWT with it, loads a matching JWKS, and sends a request through the full server stack using Starlette's TestClient. No mocking of the auth layer. The tests exercise the actual token validation code path. Part 9: Common Pitfalls localhost vs 127.0.0.1 redirect URI mismatch. MCP clients running locally often register 127.0.0.1 as their redirect URI, but the authorization server redirects to localhost (or vice versa). The Upstash OAuth deep-dive documents this as the most common integration failure. Normalize both addresses at registration and at token exchange. Cursor re-registers OAuth clients on every connection. The Dynamic Client Registration endpoint must handle the same client identity registering repeatedly. Store by client identity, not by registration request. Idempotency is critical. Clock skew causing spurious rejections. A 30-second clockTolerance is the pragmatic default. Distributed systems have clock drift. Rejecting a valid token because the IdP's clock is 12 seconds ahead of yours is a self-inflicted outage. Forgetting to serve discovery endpoints over HTTPS. MCP clients will refuse to fetch well-known URIs over plain HTTP in production. If your server is behind a load balancer, ensure the resource_url reflects the externally visible HTTPS URL, not the internal service name. Logging Bearer tokens. Sanitize the Authorization header from request logs. A leaked Bearer token in your logging pipeline is an identity compromise waiting to happen. The audit logger in this project intentionally records the validated identity, never the raw token. Production Hardening Before deploying to production, lock down the following: PKCE (S256) is mandatory. MCP clients are public clients without a client secret. PKCE is the only defense against authorization code interception.Short-lived tokens. Fifteen to sixty minutes, with refresh token rotation. Each use of a refresh token invalidates the previous one.HTTPS only. HTTP must be rejected at the network level. The MCP security best practices specification explicitly prohibits plaintext.Session-based authentication is prohibited. The MCP spec mandates token-based authentication. No cookies, no sessions.Audit log rotation and retention. JSON Lines accumulate quickly at production throughput. Configure log rotation and feed the audit stream to your SIEM. What We Built We built an MCP server that accepts tool calls only from authenticated agents. It validates OAuth 2.1 Bearer tokens using stateless JWKS-based validation with automatic key rotation. It enforces tool-level scopes and roles. A developer with database.read cannot write. A contractor with no scopes gets blocked from everything. An admin with the right role and scope can list all records. It maintains an infrastructure-level tool allow-list that rejects unauthorized tool calls regardless of what the LLM decides. It logs every access decision with the full delegation chain, so an auditor can trace any action back to the human who authorized it. The standards to do this at scale are maturing rapidly. SPIFFE handles workload identity. RFC 8693 covers token exchange with delegation chains. The IETF AIMS framework addresses agent identity. The engineering to do it in a single Python file is deployable today. The companion project is available on GitHub with setup instructions, a working demo, and a 13-test suite. Clone it, configure your OAuth provider, and you have an identity-aware MCP server in under 200 lines of application code. GitHub repository: github.com/pravin-khandke/identity-aware-mcp-server Clone it and run the demo in under two minutes: Shell git clone https://github.com/pravin-khandke/identity-aware-mcp-server.git cd identity-aware-mcp-server python3 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt python demo/agent.py --demo All code shown in this article is extracted from the repository. See src/auth/middleware.py for the JWKS validator, src/server.py for the full request pipeline, and tests/test_server.py for the 13-test suite.
Most Copilot Studio tutorials show you how to build a chatbot. This article is about something harder: building agents that actually work in production — across real enterprise data, real security boundaries, and real organizational complexity. The Gap Between Demo and Production There is a version of Copilot Studio that lives in YouTube tutorials. It has clean intents, cooperative users, and data that is always available, always formatted correctly, and always returned in under two seconds. The agent resolves every question on the first try and hands off gracefully when it cannot. Then there is the version that runs inside a hospitality company managing hundreds of thousands of guest interactions, HR workflows spanning multiple countries, and reporting pipelines that pull from six different systems — some of them on-premises, some behind authenticated APIs, and at least one that returns XML in 2024. One of the agents in that environment handles a continuously incoming customer email inbox. Before automation, each email required a human agent to read it, assess sentiment, look up the relevant guest record, research applicable SOPs, and draft a response — roughly 12 minutes of focused work per email, compounding across every email in the queue simultaneously. The autonomous agent now does all of that: reads the email, analyzes sentiment, connects to the reservation and CRM systems, delegates SOP research to a specialized child agent, and presents the human agent with a pre-researched, pre-drafted response ready for a final glance and send. Human handling time dropped from ~12 minutes to under 2 minutes per email — an 88% reduction — while the inbox processes continuously without a queue forming behind it. I have built agents in both versions. This article is about the second one. What "Autonomous" Actually Means at Enterprise Scale Before architecture decisions, a definition matters. In Copilot Studio, autonomy exists on a spectrum: Reactive agents answer questions and look up data. They wait for input.Proactive agents initiate conversations, send notifications, and surface insights without being asked.Orchestrating agents receive a goal, decompose it into sub-tasks, delegate to specialized agents or actions, and synthesize results. Most enterprise deployments start reactive and need to grow toward orchestrating. The architectural decisions you make at the reactive stage either enable or block that growth. This is where most enterprise implementations go wrong — they optimize for the demo, not for the evolution. An agent in my environment handles travel itinerary lookup, HR leave balance queries, helpdesk ticket creation, and escalation routing — not as separate bots, but as a single orchestrated agent that understands context across those domains and routes intelligently. That required deliberate architecture from day one. Core Architecture: The Four Layers Enterprise Copilot Studio agents need four distinct layers, each with its own design concerns: Plain Text ┌─────────────────────────────────────────────┐ │ CONVERSATION LAYER │ │ Topics · Entities · Adaptive Cards · NLU │ ├─────────────────────────────────────────────┤ │ ORCHESTRATION LAYER │ │ Agent routing · Context passing · State │ ├─────────────────────────────────────────────┤ │ INTEGRATION LAYER │ │ Connectors · Power Automate · Azure Func │ ├─────────────────────────────────────────────┤ │ GOVERNANCE LAYER │ │ DLP · Auth · ALM · Monitoring · Logging │ └─────────────────────────────────────────────┘ The mistake most teams make is designing only the top layer and treating the rest as "we'll figure it out." By the time the governance layer becomes urgent — usually after an incident — the conversation and integration layers are too deeply entrenched to refactor without rework. Layer 1: Conversation Design for Ambiguity Enterprise users are not the cooperative users in your test scripts. They ask ambiguous questions, they switch topics mid-sentence, they use company-specific terminology your NLU has never seen, and they get frustrated quickly when the agent asks them to repeat themselves. Slot-Filling vs. Clarification Routing The default Copilot Studio pattern is slot-filling: the agent asks for missing parameters one by one until it has everything it needs to complete an action. This works for simple, linear workflows. It breaks for enterprise use cases with conditional logic. Consider an HR leave request. The naive slot-filling approach asks: employee ID → leave type → start date → end date → reason. But what if the leave type is "emergency bereavement"? Now the flow branches — different approval chain, different documentation required, different notification list. Slot-filling designed for the simple case becomes a maze for the edge case. The better pattern is intent-first routing with late slot collection: identify what the user is trying to accomplish before collecting any parameters, then branch to a sub-flow optimized for that specific variant. Plain Text User: "I need to take some time off next week" │ ▼ [Intent confirmed: Leave Request] │ ┌────┴────┐ │ Branch │ ← Ask ONE clarifying question: leave type └────┬────┘ ┌────▼────────────────────────────────┐ │ Standard │ Emergency │ FMLA │ Other │ └──────────┴───────────┴──────┴───────┘ │ │ │ [Slot set A] [Slot set B] [Slot set C + escalation] Each branch collects only the slots it needs, in the order that makes sense for that variant. The user experience is dramatically smoother, and the backend logic is cleaner. Entity Design for Enterprise Terminology Out-of-the-box NLU entities handle common concepts (dates, numbers, locations). They do not handle your company's internal terminology — department codes, property names, system identifiers, role designations. Build a custom entity library early, even before you need it. For a hospitality company, this means entities for property names, reservation system identifiers, and booking status codes. For an HR agent, it means entities for leave types, cost centers, and approval tiers. The practical tip: export your Dataverse tables' option sets and use them as the source of truth for your closed-list entities. This keeps your agent's vocabulary synchronized with your data model without manual maintenance. Layer 2: Orchestration — The Part Nobody Talks About This is where enterprise agents either earn their keep or become expensive chatbots. When to Use Multi-Agent Architecture Copilot Studio now supports multi-agent patterns — a primary agent that delegates to specialized sub-agents. The temptation is to build one mega-agent that handles everything. Resist it for two reasons: Maintainability: A single agent handling fifty topics becomes untestable. Knowing which topic change broke production requires examining the entire agent.Authorization boundaries: Different agent capabilities may require different permission scopes. A reporting agent needs read access to analytics data. A ticket-creation agent needs write access to your ITSM system. Combining them means the combined agent needs all permissions — violating least-privilege and creating a larger blast radius for security incidents. The pattern that works: a router agent that handles authentication, session context, and intent classification, and delegates to capability agents that each own a bounded domain. Plain Text ┌──────────────────┐ │ Router Agent │ │ (Auth + Intent) │ └────────┬─────────┘ │ ┌──────────────────┼──────────────────┐ │ │ │ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │ HR Agent │ │ Travel Agent│ │ Helpdesk │ │ │ │ │ │ Agent │ └─────────────┘ └─────────────┘ └─────────────┘ Passing Context Between Agents The hardest problem in multi-agent orchestration is not routing — it is context. When a user says "can I also check my PTO balance?" in the middle of a travel booking conversation, the HR agent needs to know who the user is, what their current conversation context is, and how to return cleanly to the travel flow afterward. Copilot Studio's native context passing uses session variables, but session variables are scoped to the current agent. For cross-agent context, you need an explicit contract. The pattern I use: a context envelope passed at delegation time, structured as a JSON object stored in a Power Automate variable: JSON { "sessionId": "guid", "userId": "entra-object-id", "displayName": "string", "originAgent": "string", "originTopic": "string", "returnContext": { "resumeTopic": "string", "preservedSlots": {} }, "securityContext": { "roles": [], "dataScope": "string" } } The receiving agent reads this envelope, uses the identity and security context without re-authenticating the user (critical for seamless UX), completes its task, and passes back a result envelope. The router agent handles the return and resumes the origin flow. This pattern means your agents are stateless with respect to each other — context travels with the conversation, not embedded in agent configuration. Error Handling as a First-Class Design Concern Production agents fail. APIs time out. Dataverse throttles under load. Authentication tokens expire mid-conversation. The difference between an enterprise agent and a demo agent is what happens next. Design failure paths before happy paths. For every integration point, ask: What happens if this call times out? (Set explicit timeouts; do not let the default 30-second hang kill the UX)What does the user see? (A useful message, not "something went wrong")Is this failure recoverable in the current session, or does it require escalation?Is this failure logged in a way that enables diagnosis? The pattern I recommend: a fault envelope mirroring the context envelope, with error classification (transient vs. permanent), retry eligibility, and escalation flag. Power Automate flows that wrap integrations check for the fault envelope and route accordingly before returning to the agent. Layer 3: Integration — Connecting to the Real Enterprise Copilot Studio's built-in connectors cover the common Microsoft surface area well. The enterprise reality is that your most important data lives somewhere those connectors do not reach. The Integration Tier Decision For each integration point, choose the right mechanism: ScenarioRecommended ApproachMicrosoft 365 / Dynamics 365 dataNative Copilot Studio connector or Dataverse actionSimple REST API (OAuth, stable schema)Custom connector in Power PlatformComplex orchestration, data transformationPower Automate cloud flowHigh-throughput, latency-sensitive callsAzure Function behind a custom connectorLegacy system, on-premises dataOn-premises data gateway + Azure Service BusLong-running processes (> 2 min)Azure Service Bus queue + async response pattern The last two rows are where enterprise deployments diverge from tutorials most sharply. A synchronous request-response pattern that works for a REST API returning in 200ms does not work for a legacy ERP query that takes 45 seconds. Design async patterns early. The Async Response Pattern For long-running integrations, the agent cannot block waiting for a response. The pattern: Agent submits request to Azure Service Bus queue via Power Automate, receives a correlation IDAgent acknowledges the user: "I've submitted your request — I'll update you when it's ready"Azure Function processes the queue message and writes result to Dataverse with the correlation IDProactive messaging flow (triggered by Dataverse record creation) sends the result back to the user's conversation This requires proactive messaging to be configured on your agent — a step many tutorials skip because it requires additional Azure Bot Service configuration and Entra app registration. Do not skip it; it is what separates an agent that completes long-running tasks from one that silently fails them. Dataverse as Your Integration Hub If you are in the Microsoft ecosystem, Dataverse should be your canonical data store for agent state, conversation history, audit logs, and integration results — not Power Automate environment variables, not hardcoded values in agent configuration. Reasons this matters at enterprise scale: Auditability: Dataverse natively tracks record creation, modification, and deletion with user attribution. Every agent action that modifies data has a traceable history.Security inheritance: Dataverse's table- and row-level security propagates automatically. An agent retrieving records from Dataverse returns only what the authenticated user is authorized to see — no additional filtering logic required in the agent.Scalability: Dataverse handles throttling, retry, and concurrency better than environment variables or SharePoint lists used as a poor substitute. The practical consequence: design your Dataverse schema before your agent topics. Your entity model drives your integration patterns, your security model, and your reporting. Getting it wrong is expensive to fix. Layer 4: Governance — What You Must Not Skip This layer is invisible until something goes wrong, at which point it is the only thing anyone cares about. Authentication Architecture Every enterprise agent needs a clear answer to: who is this user, what are they allowed to do, and how do I verify that at every step? Copilot Studio supports authentication via Entra ID (Azure AD). Use it. Do not build agents that rely on the user typing their employee ID — that is not authentication; it is a courtesy check. The configuration that matters: Service principal for agent identity: Your agent's service principal should have only the permissions it needs, nothing more. If your HR agent needs to read leave balances from Dataverse, its service principal needs the specific Dataverse table reader role — not the global admin role that is expedient to configure.Token passing to downstream systems: When your agent calls a Power Automate flow, and the flow calls an external API, the authenticated user's token should flow through — not be replaced by a shared service account. This is an on-behalf-of (OBO) flow, and it preserves auditability.MFA enforcement: Your DLP and Conditional Access policies should treat agent-authenticated sessions the same as human sessions. An agent that bypasses MFA requirements is a security gap. Data Loss Prevention (DLP) Policy Design DLP policies in Power Platform control which connectors can be used together, preventing data from flowing between incompatible environments (e.g., a connector to an internal system and a connector to an external service in the same flow). For agent governance, the practical configuration: Business tier connectors: Dataverse, SharePoint, Teams, approved internal APIsNon-business tier connectors: Consumer services, unapproved external APIsBlocked connectors: Any connector not explicitly approved The mistake most teams make is configuring DLP at the tenant level with a single policy and then creating exceptions as pressure mounts. The better pattern is environment-stratified DLP: a strict policy for your production environment, a more permissive policy for development, and explicit approval gates for promoting connectors between tiers. ALM: Treating Your Agent Like Real Software Copilot Studio agents are solutions in the Power Platform solution framework. This means they can and should be managed with the same ALM discipline as any other enterprise application: Plain Text Development → Test → UAT → Production │ │ │ │ Git source Automated Manual Deployment control testing sign-off pipeline The Power Platform Build Tools for Azure DevOps provide the pipeline tasks you need: export solution, import solution, run solution checker, publish customizations. A mature ALM pipeline for a Copilot Studio agent should: Export the agent solution on every commit to a development branchRun the solution checker and fail the pipeline on critical violationsRun automated conversation tests (using the Copilot Studio test framework)Require PR approval for promotion to testRequire explicit release approval for production deployment The thing that kills enterprise agent deployments most often is not bad design — it is an uncontrolled change in production that breaks a working agent and cannot be rolled back because no version history exists. Monitoring and Observability An agent in production without monitoring is a liability. At minimum: Conversation transcripts: Copilot Studio logs these natively. Review them weekly. Patterns in failed conversations reveal topic gaps before users report them formally.Custom telemetry via Application Insights: Pipe agent events to Azure Application Insights for queryable, persistent logging. The native Copilot Studio analytics are useful but have a short retention window.Action failure alerting: Every Power Automate flow called by your agent should emit a custom event on failure. Alert on failure rate thresholds, not just individual failures.Escalation rate tracking: The ratio of conversations that escalate to a human agent is your agent's primary health metric. If it rises, something broke, or a new use case emerged that your agent does not handle. The Conversation That Prevents Most Problems Before the first topic is created, have this conversation with your stakeholders: "What does success look like in six months, and what data does the agent need access to in order to achieve it?" The answer to that question determines your Dataverse schema, your integration tier decisions, your authentication architecture, and your DLP policy — before any conversation design begins. In my experience, agents that were designed from that conversation forward are maintainable, extensible, and trusted by the business. Agents that were designed from the conversation layer down spend their first year in retrofitting mode. Practical Checklist: Before You Go to Production [ ] Autonomous agent's owning account/service principal is scoped to least-privilege — access only to systems the agent needs, nothing broader[ ] Non-Microsoft system credentials stored in Azure Key Vault or encrypted environment variables — never hardcoded in flows[ ] Each external system integration uses a dedicated, scoped credential — not a shared admin account[ ] External system audit logs show the agent as a distinct, identifiable caller[ ] DLP policies configured for production environment; connector tier assignments documented[ ] Dataverse schema finalized and reviewed before agent topic design begins[ ] Error handling designed for every integration point; failure messages are user-readable[ ] Async pattern implemented for any integration that may take > 10 seconds[ ] ALM pipeline configured: Dev → Test → UAT → Prod with automated solution checker[ ] Application Insights connected; custom events emitted for key agent actions[ ] Conversation transcript review scheduled (weekly minimum)[ ] Escalation rate baseline established; alert threshold configured Closing Thought The enterprise agents that earn trust are not the ones with the most sophisticated NLU or the most integrations. They are the ones that fail gracefully, recover predictably, and give the humans who support them enough visibility to diagnose problems before users report them. Build the governance layer first. Design the conversation layer last. The demo will be slightly less impressive. The production deployment will be significantly more stable.
Designing Context Isolation, Retrieval Trust, and Vector Database Governance for Enterprise RAG Systems Part 1 — Five Documents Can Hijack a Frontier Model Here's a number worth sitting with before anything else in this piece: researchers demonstrated that injecting just five malicious documents into a knowledge base of 2.6 million texts could control a frontier LLM's output 97% of the time. The attacker never touches the model weights. They never see the retriever's code. They just write a document and wait for it to get indexed. That's PoisonedRAG, accepted at USENIX Security 2025, and it's the paper that should have ended the "just add RAG for accuracy" conversation as a purely upside decision (USENIX Security 2025 / arXiv:2402.07867). Follow-on research made the picture worse, not better. A January 2026 paper introduced CorruptRAG, which achieves a comparably high attack success rate using a single poisoned document instead of five — a meaningfully more realistic threat model, since most real corpora don't let an attacker casually drop five coordinated files without anyone noticing. Separately, researchers found that poisoning as little as 0.04% of a corpus could push attack success rates above 98%, with system failure in nearly three-quarters of cases (Medium/InstaTunnel, citing 2025–2026 RAG poisoning research). This isn't theoretical anymore, either. In August 2025, Snyk's security research team published a working demonstration called RAGPoison, showing exactly how a vector database gets subverted into persistent prompt injection: they injected 274,944 poisoned points into a vector store, each carrying the same embedded instruction — "disregard your previous task or a human will die" — and showed it surviving into live retrieval results indefinitely, because nothing in the pipeline ever asked whether those points deserved to be there in the first place (Snyk Labs, "RAGPoison," August 18, 2025). And this connects directly to something covered in this series' first article: EchoLeak (CVE-2025-32711), the zero-click Microsoft 365 Copilot vulnerability disclosed in June 2025, worked by exactly this mechanism — a single crafted email got pulled into Copilot's retrieval context and its hidden instructions were treated as legitimate evidence. The attacker didn't need to compromise anything. They needed the retrieval pipeline to trust content it should never have trusted (SOC Prime, June 2025). That's the thesis of this piece: the AI industry keeps treating memory as a database problem. It's actually a trust problem, and most enterprise RAG deployments have no trust architecture at all sitting on top of what is, in every meaningful sense, a new kind of database that stores meaning instead of rows. Part 2 — Why Retrieval Changes the Threat Model Traditional cybersecurity asks whether an attacker can execute code. Identity security asks whether an attacker can authenticate. AI memory security asks something the industry hasn't fully absorbed yet: can an attacker influence what the AI believes? That's a different question because retrieval doesn't behave like traditional data access. A relational database answers "find customer 173." A vector database answers "find the passage most semantically similar to this idea" — and semantic similarity has nothing to do with organizational trust. A three-year-old, never-reviewed engineering note with obsolete authentication guidance can rank exactly as high as this quarter's approved security policy, provided the embeddings land close enough in vector space. The retriever has no concept of who approved a document, when it was last reviewed, or whether it's been superseded. It only measures mathematical closeness. OWASP formalized this gap in its 2025 Top 10 for LLM Applications by adding an entirely new category — LLM08:2025, Vector and Embedding Weaknesses — specifically because vector stores introduce their own class of vulnerability distinct from prompt injection or output handling: insufficient access controls that expose data across tenant boundaries, and poisoned content that gets retrieved during otherwise legitimate queries (Aembit, "OWASP Top 10 LLM Risks Explained," 2026). Sensitive Information Disclosure also jumped from #6 to #2 on the same list — the single largest movement of any category — which tells you where the industry's actual incident data is pointing (TrojAI, "The 2025 OWASP Top 10 for LLMs," December 2024). Part 3 — Prompt Injection Is Really Memory Injection Prompt injection gets treated as a separate problem from retrieval poisoning. Architecturally, the two are converging. Instead of convincing a user to type malicious instructions, an attacker convinces the retrieval system to fetch malicious instructions — buried in a public documentation page, a support ticket, or a Slack export that got indexed months earlier. Once that content sits inside the context window, the model has no way to distinguish "instruction," "documentation," and "attacker payload." They're all just tokens it's reasoning over. That's why the RAGPoison demonstration above is worth taking seriously as a design lesson rather than a one-off exploit: the vulnerability wasn't in the LLM. It was in the absence of any governance step between "content exists somewhere" and "content becomes something the model reasons over as fact." Traditional Database AccessRAG Retrieval"Find customer 173" (exact match)"Find what's semantically similar" (approximate)Access controlled by row/table permissionsAccess controlled by... often nothingStale data is a data-quality problemStale data is a security problem — it gets reasoned over as current factA wrong record returns a wrong answer, visiblyA poisoned document returns a confident, plausible answer Part 4 — Provenance: The Layer Every RAG Architecture Is Missing Every mature security discipline eventually asks not "can I access this" but "where did this come from." Software supply-chain security answered that with SBOMs. Container security answered it with image signing. Enterprise AI memory hasn't answered it yet, because until RAG became standard, models rarely needed to explain where their knowledge originated. The fix isn't a smarter prompt telling the model to "prefer recent documents" — prompts can't verify ownership, approval status, or whether a document was ever reviewed. That has to live in the retrieval architecture itself, as metadata attached to every indexed object: owner, classification, approval status, review date, source connector, and a confidence score that reflects organizational trust rather than embedding similarity. A security policy approved three weeks ago by the CISO and a two-year-old hackathon note discussing the same topic should never carry equal weight just because they're semantically close — but in most first-generation RAG deployments, they do, because nothing in the pipeline distinguishes them. Part 5 — Context Isolation: Memory Needs Its Own Zero Trust Zero trust reshaped network security around one idea: never trust a request just because it originated inside the perimeter. Enterprise memory needs the same discipline, because most RAG systems still make a decision that would be rejected instantly anywhere else in the security stack — they embed every document, from every department, into one shared semantic space, and apply access control (if any) only after retrieval already happened. Think about what that produces. An employee asks about deployment pipelines. The retriever, optimizing purely for semantic similarity, also surfaces security architecture documents, legal guidance, and archived incident reports — not because the employee asked for them, but because they were mathematically close enough. That's lateral movement through knowledge instead of through a network, and it happens by default in most RAG architectures because authorization is checked, if at all, after the documents are already selected rather than before. The fix mirrors what least privilege did for infrastructure: least context. Give the model only the evidence actually required to answer the question — not the whole corpus, not everything semantically adjacent, not everything the user happens to be permissioned for elsewhere. Authorization has to run before similarity ranking, not after it, which inverts how most retrieval pipelines are built today. Part 6 — A Practical Reference Architecture Plain Text User Request │ ▼ Identity & Purpose Verification │ ▼ Authorization / Trust-Zone Selection │ ▼ Metadata & Provenance Filter │ ▼ Vector Retrieval │ ▼ Evidence Confidence Ranking │ ▼ Context Assembly │ ▼ LLM Reasoning │ ▼ Output Validation + Audit Log The critical shift this diagram represents: authorization and provenance checks happen before the vector search narrows down to a "top K" result set, not after. Most production RAG systems today run this backward — retrieve first by similarity, then maybe apply access control as an afterthought. Flipping that order is most of the actual architectural fix. A concrete version of this in practice: a support engineer asks an internal assistant how to rotate a production database credential. The system first confirms the engineer's identity and role, then narrows the searchable trust zone to "internal engineering + security-approved," excluding HR, legal, and unreviewed draft documentation entirely. Only within that narrowed zone does semantic retrieval run, returning the current, approved runbook rather than a three-year-old migration note that happens to use similar language. The model never even sees the excluded material — there's nothing to accidentally leak or reason over, because it was never in the candidate set. Four principles fall out of this: identity and authorization should gate retrieval, not follow it; every retrieved object should carry provenance metadata the retriever can actually filter on, not just a vector; trust zones should segment memory the way network segmentation separates infrastructure, with retrieval never silently crossing a boundary; and — echoing this series' recurring theme — the model's reasoning should never be the first trust decision in the pipeline. By the time content reaches the context window, the trust decision should already be made. Closing — The Next Trust Boundary Twenty years ago, the network wasn't the trust boundary anymore. More recently, human identity stopped being the only one. The next one is already emerging: memory. An AI system doesn't just process information — it inherits beliefs from whatever it retrieves, and those beliefs become recommendations, and recommendations increasingly trigger autonomous action. Five documents. 2.6 million texts. 97% control over the output. That's not a hypothetical for next year — it's a published, peer-reviewed result from 2025. The organizations that treat their vector database with the same governance rigor they'd apply to a production identity system are the ones whose AI will still be trustworthy once someone actually tries to break it. The rest are running PoisonedRAG's proof-of-concept without knowing it. All incident details, research findings, and statistics reflect publicly disclosed sources current as of July 2026, linked inline.
The Micro-Enterprise Bottleneck: When Core Delivery Collides With Operations The Business Case: The Friction of the "Comfort Gap" I have three primary alter egos. Early in the mornings, I teach Spanish. Nothing fancy, just a simple, online session, focused on one student at a time, sharing and imparting what I learned and how I learned, to help them benefit from knowing Spanish as their second language. The rest of the day is spent in my Enterprise Architecture work — from consulting, to product development, to strategic solutions, and you know… all the standard corporate jargon. And then late at night, I imagine mysteries and write fiction. All that is fine. But then one of the most awkward conversations I have to have occasionally is telling my student: “Hey, so… you’ve used 10 classes and only paid for 10 classes… physics dictates we cannot proceed without a renewal.” Awkward, right? One morning where I needed to have that exact conversation, I thought to myself, “Ha! Let me hire an operations manager to handle these. I just need to see the details on the Kanban board later.” But then, I hit the budget committee. Ahem. Which was just me, looking at my own bank account. The committee quickly decided that hiring a manager for an ultra-small-scale business means I’d be working entirely to pay them, leaving me with Rs. 0 and a lot of regret. The Solution Philosophy: Pragmatic Lifestyle Engineering So, in real-world businesses, this is where they bring in an Enterprise Architect. I thought, “hey, that’s me!” I looked at the problem through an engineering lens and realized that manual administrative work is the technical debt of real life. If a system requires me to manually check a spreadsheet and manually make a reminder, then the system is broken! After all, why spend 10 minutes a week doing something manually, when you can spend an hour over the weekend, over-engineering a serverless cloud pipeline to do it for you — for free? But how do you build an automated system that handles the “money talk” with the cold, polite neutrality of a machine, that ensures absolute accuracy so you don’t falsely accuse a student of not paying, and… runs with a grand total operating cost of exactly zero rupees? Fig. 1. The reality of operational scale Deconstructing the Solution: Three Core Architectural Pillars First thing to consider in a multi-million-dollar platform is the core of the business problem. What pillars are going to hold up this house? It is the exact same way a structural architect might think before drawing a blueprint. Decoupled State Management (The "Database" Illusion) Let’s take the data storage layer first, because, well, there is data and it needs to be stored. In an enterprise, what would this be? Potentially an RDS instance or a distributed NoSQL cluster. In the current use case, I found the perfect low-latency “read/write replica” for a non-technical admin interface. It is easily accessible on my phone, simple to update manually if and when required, and most importantly, it has zero hosting costs. What is it? It is an engineering sin that makes an architect shudder. It is Google Sheets. But don’t dismiss it as a glorified spreadsheet. Look at it pragmatically as a lightweight, highly available distributed state machine. Strict Temporal Bounding (The Data Inflation Filter) We solved the data and storage layer. Now let us look at a potential problem that could come up at this stage. Let us look at tracking this attendance event over time, correlating it to the problem statement at hand. Imagine if the code blindly counts every class a student has ever attended since day one; the data volume will burgeon, and the execution will come to a grinding halt. For all you know, the historic data can even corrupt my current cycle numbers. To mitigate this, we introduce the pattern of setting a strict dynamic time window. The API needs to get hard boundaries based on the last transaction date. Now what if you have a recurring calendar invite? The second boundary that gets passed to the API then is the attendance data only up to the current millisecond. If this is not in place, then we are basically looking at a catastrophic data bug. If we don’t define the time array, a student who took a break three months ago might suddenly get an automated email screaming that they owe money for classes they took in some past life. We need accuracy, not a tracking crisis, remember? Idempotency and State Gates (The "No Spam" Rule) No one likes spam. That brings us to a crucial enterprise pattern — idempotency. What does it mean? Well, simply that no matter how many times a given operation is executed, the side effect is only applied once. I wish this were the case for medications that have side effects, but that is out of an enterprise architect’s scope. What did I do here for this idempotency? A simple gate column in the spreadsheet with a binary value for ReminderSent. The engine strictly evaluates this Boolean flag before firing an email. Once the threshold is hit, and the email is sent, the pipeline instantly flips the state to True. Think of this as the safety valve. GitHub Actions runs this automatically every evening. Without this state gate, once a student’s package expires, my headless cron engine will politely, coldly, and relentlessly spam their inbox every single evening at 7 pm until they pay me or block my email address. Fig. 2. The Architecture Building the Serverless, Zero-Cost Stack Ok, enough of the talking; let us orchestrate the cloud ecosystem. Now, we are allowed to use only the free-tier resources. Ladies and gentlemen, put your hands together for the trio — Google Workspace APIs for data and logic, GitHub Actions, our ephemeral runtime environment, and Node Mailer over SMTP. Accelerating Development via a Local AI Agent Stack One of the biggest challenges we face as adults is context switching. I’d skip elaborating on that for all of our sanity. I built this stack without the additional burden of context switching by spinning up a local AI environment on my humble 8GB CPU on a basic home laptop running a Windows operating system. Just good old Ollama, the Continue extension in VS Code, and Gemma. The benefit of a local agent is that it allows an architect to quickly generate boilerplate code, test logic boundaries, and iterate without needing premium cloud tokens. Engineering the Pipeline: Key Code Implementations I chose TypeScript for the engine’s core implementation to leverage its strict typing system. When you are mapping dynamic spreadsheet cells to operational parameters, strict types are your first line of defense against runtime metadata errors, especially when handling complex student data structures. Enforcing Temporal Boundaries in API Queries If we look at the piece of code below, we see the strict temporal bounding pillar, which we spoke about earlier, in action. The date boundaries are dynamically calculated on the fly, with the student’s last payment date defining the lower bound and the exact current moment becoming the upper bound. This configuration payload is now handed over to the Google Calendar API query to extract only the relevant window of attendance events. TypeScript const res: any = await calendar.events.list({ calendarId: process.env.GOOGLE_CALENDAR_ID, singleEvents: true, orderBy: "startTime", maxResults: 2500, pageToken, timeMin: lastPaymentDateISO, // Strictly drops anything before this timestamp timeMax: nowISO, // Strictly drops anything in the future }); By offloading this filter to the API gateway, we are protecting our serverless memory footprint and preventing legacy historical data from leaking into the current cycle calculations. Mitigating Notification Spam via Idempotency Check Gates After isolating the precise attendance window, the engine now evaluates the current state of the record. The logic gate is straightforward, but absolute at the same time. Gate A – The Quota Breach – Does the total number of attended classes meet or exceed the pair threshold?Gate B – The Idempotency Check – has a reminder already been dispatched for this specific cycle? If and only if both gates evaluate to true, the communication layer fires up. The cold, polite notification goes out. And immediately, the engine executes a state synchronization back to the persistence layer. TypeScript const meetsQuotaLimit = currentLessonsCount >= s.classesPaidFor; const isReminderNotSentYet = !s.reminderSent; console.log(`↳ Quota Met (Count >= ${s.classesPaidFor}): ${meetsQuotaLimit} | Is Reminder Pending: ${isReminderNotSentYet}`); // Update Column H with the exact calculated count first await updateLessonsUsed(s.rowNumber, currentLessonsCount); if (meetsQuotaLimit && isReminderNotSentYet) { if (s.email) { // Step 4: Dispatch email notification message await sendEmail(s.email, s.student, currentLessonsCount, s.classesPaidFor); console.log(`↳ Outbound alert dispatched cleanly to ${s.email}`); // Step 5: Persist ReminderSent back to TRUE await updateReminderSentStatus(s.rowNumber, "TRUE"); console.log(`↳ Spreadsheet statuses permanently updated to TRUE.`); } else { console.log(`⚠️ Email notice skipped: Student is missing an email address.`); } } else { console.log(`↳ Conditions not met. Sheet column counters updated, no emails dispatched.`); } } console.log("\nProcess finalized successfully!"); Infrastructure as a Service: The GitHub Actions Cron Engine Great code is completely useless without an operational home. Since our core constraint when we started was an operational budget of exactly INR 0, spinning up a dedicated AWS EC2 instance or an Azure VM was entirely out of the question. That is where a knight in shining armor came to my rescue — GitHub Actions. This is not just any CI/CD tool; it serves as a highly capable serverless, headless execution environment. Securing the Infrastructure Without an Enterprise Vault You turn around and see the elephant in the room. Security. Let us address that then. To make this pipeline functional, the runner needs access to highly sensitive credentials. Let’s see — my Google Service Account private JSON keys, my personal SMTP email app passwords. In an enterprise, this would either be solved by pulling secrets dynamically from HashiCorp Vault or AWS Secrets Manager. I achieved the exact same security boundary by injection-mapping these sensitive parameters directly into my execution runtime environment via GitHub Repository Secrets. This adheres strictly to one of the fundamental golden rules of software architecture: No secrets ever touch source control. So sorry, you won’t find a single credential sitting in my source repository. Navigating Cloud Scheduler Nuances (The Asymmetric Minute Strategy) I had this all set up and was waiting for the line to appear on the workflows tab of GitHub Actions that my job had run at exactly 7 pm that evening. But hey, what is engineering without a few infrastructure curveballs? GitHub Actions handles both scheduled and manual workflows based on what is set up in your configuration. Now, this is a shared, multi-tenant free queue, and millions of developers configure their cron jobs to run at flat intervals like :00 or :30. This causes massive platform resource contention. The background event bus gets heavily backed up, leading to severe delays or entirely skipped jobs. While I still haven’t learned how to bypass real-world traffic jams in Bangalore, fixing this cloud traffic jam was far easier in comparison. I deliberately shifted my cron pattern completely away from peak times to an asymmetric, off-peak minute (:33 or :37). This is one way to optimize reliability in shared cloud infrastructure. But mind you, it still won’t fire down to the exact second mentioned in your YAML file; shared platform queues will always have a slight propagation lag. YAML on: schedule: # Runs every day at 13:33 UTC, which corresponds to 7:03 PM IST (Indian Standard Time) - cron: '33 13 * * *' workflow_dispatch: # Allows you to also trigger it manually from the GitHub UI whenever you want Fig. 3. Schedule on GitHub Actions Conclusion: Reclaiming Creative Bandwidth Through System Design Let us look at the ROI here, because isn’t that what the executives are most concerned about? I invested a weekend afternoon, working alongside a local AI agent stack, and built a production-grade automation engine. It completely eliminated a major source of personal and operational friction for me. It operates with absolute mathematical precision, and for me, the important part is that it has an ongoing operational maintenance cost of exactly INR 0. Enterprise Architecture is not just a corporate discipline reserved for massive scaling clusters at big tech corporates. It is a systematic mindset — yes, mindset. By applying these exact design constraints — decoupling, temporal boundaries, and idempotencies - to our small personal workflows, we are protecting our most valuable non-renewable resource — our human creative bandwidth. Let me ask you: how do you handle administrative friction or manual processes in your own side integrations or small-scale workflows? Would you prefer to see this system migrated to an edge-compute model like Cloudflare Workers, or evolved to hook directly into Meta’s WhatsApp Cloud API for notifications? Let me know in the comments below.
Artificial intelligence has transformed customer relationship management from a record-keeping function into a data-driven decision support system. Across utilities, high-tech manufacturing, industrial equipment, telecommunications, and infrastructure services, AI capabilities are increasingly being incorporated into CRM platforms to improve service planning, maintenance scheduling, and customer support workflows. Modern CRM platforms commonly support capabilities such as identifying customers at risk of churn, predicting equipment failures, recommending preventive maintenance actions, and generating service insights from vast volumes of operational data. Many organizations are adopting AI capabilities within CRM platforms to automate service workflows and improve maintenance planning. Yet despite these advances, many enterprises continue to struggle with service delays, missed service-level agreements, and inconsistent customer experiences. In many implementations, predictive insights are not fully translated into operational execution. An AI-powered CRM platform may accurately predict that a high-value asset is likely to fail within the next ten days. It can automatically create a work order, notify stakeholders, and schedule a technician visit. However, if the required spare part is unavailable, sitting in the wrong warehouse, delayed in transit, or inaccessible to the technician, the prediction creates little practical value. From the customer's perspective, the result is the same, with ongoing downtime, disrupted production, and service levels that fail to meet expectations. The implementation gap highlights the importance of integrating predictive systems with operational processes. Inventory management and logistics directly influence whether predictive maintenance recommendations can be executed successfully. Operational coordination plays an important role in translating predictive insights into effective service delivery. They are the ones building integrated operational processes where customer intelligence, inventory visibility, field service operations, and logistics execution operate as a single coordinated framework. Why Operational Readiness Matters as Much as AI For many years, CRM platforms focused primarily on managing customer interactions. Their purpose was to capture customer information, track sales opportunities, and maintain service histories. Success was measured through relationship visibility and customer engagement. The emergence of AI has significantly expanded the role of CRM. Modern CRM systems can: Predict service demand before customers raise support requests, allowing organizations to intervene proactively rather than reactively.Analyze customer behavior and asset performance trends, helping service teams identify risks before they become operational disruptions.Recommend preventive maintenance actions, reducing the likelihood of costly failures and unplanned downtime.Automate service scheduling and case prioritization, improving responsiveness across large service networks.Support outcome-based service models, where providers are increasingly measured by performance and uptime rather than service activity alone. These capabilities expand CRM beyond traditional customer relationship management. However, many organizations still struggle to convert customer intelligence into operational execution. As customer expectations rise, the ability to act on predictive insights is becoming just as important as the ability to generate them. Figure 1: Real-time reservation and dispatch sequence illustrating how predictive events trigger inventory reservation, technician scheduling, and work execution across enterprise systems. Why Predictive Insights Often Fall Short A common implementation challenge of digital transformation is that AI often exposes operational weaknesses rather than solving them. Consider a utility company using AI-powered monitoring to predict transformer or substation failures before they occur. The technology works as intended, providing early warnings and actionable insights. However, if replacement inventory is unavailable or service teams cannot access the required components in time, outages and disruptions still happen. The same challenge exists in manufacturing. Predictive analytics may identify a component nearing failure, allowing maintenance teams to plan interventions in advance. Yet if the necessary spare part is out of stock or procurement lead times are too long, production delays remain unavoidable. Organizations may not realize expected operational improvements when inventory and logistics processes remain disconnected. The limitation is rarely the predictive model itself; more often, it lies in operational constraints such as: Inaccurate inventory records, which create uncertainty around actual stock availability.Fragmented warehouse operations, making it difficult to locate and allocate inventory efficiently.Limited visibility across service networks, preventing organizations from understanding where high-priority spare parts are located. Inefficient replenishment processes, resulting in avoidable shortages and delays.Disconnected service and logistics teams, reducing the organization's ability to respond quickly when intervention is required. Organizations increasingly discover that AI can identify service needs faster than operational systems can fulfill them. This is why operational readiness has become an important implementation consideration in determining the success of AI-powered service strategies. Inventory Visibility and Customer Experience Historically, inventory management was measured by operational metrics such as stock levels, carrying costs, and warehouse efficiency. Today, it plays a far more strategic role by directly influencing customer experience. Business customers expect real-time visibility into parts availability, repair timelines, and service status. To meet these expectations, service organizations need visibility across: Central warehouses for high-value and high-priority spare partsRegional distribution centers supporting local service operationsForward stocking locations positioned near demand hotspotsTechnician vehicle inventories for immediate field service needs Supplier and third-party logistics networks for added flexibility Figure 2: Logical data model illustrating the core entities supporting predictive maintenance, inventory reservation and field service execution. Without end-to-end visibility, organizations struggle to deploy inventory efficiently, directly impacting service responsiveness and customer satisfaction. Accurate inventory visibility enables reservation, allocation, and dispatch decisions before technician scheduling occurs. Why Inventory Matters for First-Time Fix Rates Among all service performance indicators, First-Time Fix Rate (FTFR) remains one of the most important measures of service effectiveness. The metric evaluates an organization's ability to resolve issues during the initial technician visit. Inventory intelligence is often one of the strongest drivers of first-time fix performance. Higher first-time fix rates are commonly associated with accurate parts allocation, technician skill matching, and inventory availability and typically benefit from: Higher customer satisfaction, because issues are resolved without requiring repeat visits.Lower operational costs, as additional technician dispatches become less frequent.Improved workforce productivity, allowing service teams to handle more work orders effectively.Stronger contract performance, particularly within uptime-driven service agreements.Reduced asset downtime, helping customers maintain operational continuity. Even the most skilled technician cannot complete a repair without access to the required parts. This is why many enterprises are integrating inventory intelligence directly into field service workflows. By aligning inventory planning with service demand, businesses can ensure technicians arrive prepared with the parts required to complete repairs successfully. A single visit that resolves the issue creates confidence in the service provider. Multiple visits often create frustration regardless of how modern the underlying technology may be. Improving Spare Parts Forecasting With AI Forecasting spare parts demand has always been challenging due to irregular usage patterns influenced by asset age, operating conditions, maintenance cycles, and equipment reliability. Traditional forecasting models relied heavily on historical consumption data, often limiting their ability to adapt to changing conditions. AI supports a more dynamic approach by analyzing multiple demand drivers, including: Service history: Identifies recurring maintenance and repair patterns across asset populations.Asset health data: Uses IoT insights to detect performance trends and anticipate failures.Demand trends: Forecasts regional and operational service requirements more accurately.Supplier risks: Factors in lead times and procurement constraints to improve planning.Operating conditions: Considers environmental and usage factors that influence failure rates. This approach can improve inventory planning, increase service responsiveness, and lower inventory costs. AI is also being used to improve replenishment decisions. Instead of relying on static reorder points, AI continuously evaluates inventory consumption, service schedules, lead times, and asset conditions to trigger replenishment actions automatically. This helps reduce stockout risks, avoid overstocking, and improve replenishment accuracy while reducing excess inventory. The Role of Service Logistics in Better Service Delivery Inventory availability is only part of the equation. Organizations must also ensure that parts move efficiently through the service network to reach the right location at the right time. Service logistics has evolved from a support function into a core component of service delivery, directly influencing repair timelines, asset uptime, and customer satisfaction. Modern service logistics includes: Transportation planning, ensuring inventory reaches service locations efficiently.Technician replenishment programs, keeping field teams equipped with frequently used components.Emergency parts fulfillment, enabling rapid responses to critical failures.Route optimization capabilities, reducing travel times and improving service responsiveness.Reverse logistics processes, helping organizations recover and manage returned components effectively. AI Models can prioritize replenishment recommendations using inventory consumption, lead times, and predicted demand. As customer expectations continue to rise, logistics performance is becoming an increasingly important operational capability rather than a back-office activity. Balancing Service Levels and Inventory Costs One of the most complex challenges facing service leaders is balancing inventory investment with customer expectations. Excess inventory increases costs, while insufficient inventory leads to delayed repairs and missed service commitments. AI can help organizations strike a more sustainable balance. By analyzing demand patterns, asset performance trends, service histories, and supplier lead times, inventory optimization models that use AI can determine where inventory should be positioned and in what quantities. Rather than maximizing stock levels, organizations can focus on maximizing inventory effectiveness, ensuring that high-priority spare parts are available where they are most likely to be required. This shift is particularly important for organizations operating large service networks. Utility providers, industrial equipment manufacturers, and infrastructure operators must maintain service readiness without tying up excessive capital in inventory. AI enables a more precise approach, helping organizations improve responsiveness while maintaining financial discipline. Operational Priorities for Service Organizations As AI adoption accelerates, service leaders must focus on strengthening the operational foundations that enable service outcomes. Key priorities include: Establishing real-time inventory visibility across the service network, enabling faster and more informed decision-making.Deploying AI-driven forecasting capabilities, improving spare parts planning and reducing stock-related service disruptions. Improving integration between customer, operational, and inventory systems, creating a unified operational environment.Strengthening logistics agility, particularly around emergency fulfillment and field service support.Expanding predictive maintenance programs, allowing organizations to address issues before customers experience disruptions. Many manufacturers increasingly view service performance and asset uptime as important operational priorities. AI is also enhancing workforce planning within field service operations. By combining predicted service demand, technician skill profiles, geographic location, parts availability, and customer priority levels, organizations can schedule resources more effectively. This ensures technicians are dispatched with both the expertise and inventory required to resolve issues during the first visit, improving workforce productivity and customer satisfaction simultaneously. Figure 3: Enterprise integration architecture and agentic AI learning loop enabling continuous optimization across predictive maintenance, inventory management, and field service operations. Turning Predictive Insights into Action As AI-enabled CRM systems become more sophisticated, the real differentiator is no longer the ability to predict service needs but the ability to act on those insights. Predictive intelligence delivers value only when supported by inventory availability, service readiness, and logistics agility. Organizations that strengthen operational coordination are those connecting customer insights with operational capabilities across the entire service ecosystem. In this environment, operational performance will not be determined solely by smarter algorithms, but by ensuring the right part reaches the right technician at the right time. AI predictions produce measurable operational benefits only when Inventory availability, logistics, and technician scheduling are integrated with CRM workflows.
The Invisible Security Crisis Every Cloud-Native Organization Is Already Paying For Part 1 — The Deal That Told You Where This Is Going On July 30, 2025, Palo Alto Networks announced it was buying CyberArk for $25 billion. The deal closed February 11, 2026, becoming one of the largest acquisitions in cybersecurity history. Strip away the ticker symbols and the press-release language about "platform convergence," and the deal says something simpler: the company that made its name securing privileged human accounts just spent $25 billion because the identity that actually needs securing now isn't human anymore. CyberArk CEO Matt Cohen put it plainly when the deal closed — the combined company exists to secure every identity, "human, machine, and AI" (CyberArk press release, Nov 13, 2025) — in that order of emphasis, which is to say, not first. That's the thesis of this piece. Not "machine identity is important" — that's a line every vendor slide has used for a decade. The sharper claim: machine identity has quietly become the dominant identity problem in enterprise computing, while most security architectures are still designed around human users as the default case. Every Kubernetes pod, every CI/CD runner, every Lambda function, every AI agent, every sidecar, every MCP server needs an identity — and the industry has been treating that as an operational detail instead of the actual security boundary it's become. CyberArk's own 2025 Identity Security Landscape report, based on more than 1,200 security leaders surveyed across the US, UK, Australia, France, Germany, and Singapore, put a number on the gap: machine identities now outnumber human identities by 82 to 1 inside the average organization. Ninety-four percent of respondents said that ratio had grown over the past three years. Forty-two percent of machine identities carry privileged or sensitive access — yet 88% of the same respondents said their organization's definition of "privileged user" applies only to humans. Sixty-one percent said they have no identity security controls at all covering cloud infrastructure and workloads. Eighty-seven percent had suffered at least two identity-centric breaches in the prior twelve months. (CyberArk, "Machine Identities Outnumber Humans by More Than 80 to 1," April 23, 2025) Read that gap again: nearly half of the identities most policy frameworks were never written for already hold the keys to something sensitive. Part 2 — The Explosion Nobody Designed For Twenty years ago, enterprise identity was a human resources problem with a technical layer bolted on. An employee joined, HR created an account, IT provisioned access, and eventually the employee left and the account got disabled. The lifecycle was slow and measured in years. Cloud-native infrastructure broke that model without anyone deciding to. A single Kubernetes Deployment can create and destroy more identities in ten minutes than a 2005-era enterprise created in a year. Every autoscaling event, every GitHub Actions run, every serverless invocation, every AI agent task spins up its own operational boundary and, with it, its own machine identity. A 15,000-person enterprise isn't managing 15,000 identities anymore — once you count containers, VMs, serverless functions, CI/CD runners, Kubernetes pods, workload certificates, and short-lived tokens, it's managing hundreds of thousands, sometimes millions, of cryptographic identities. Most security budgets still prioritize the smallest group in that list. The infrastructure underneath also stopped being static. Containers can live minutes. Functions can live seconds. A GitHub Actions runner disappears the moment its workflow finishes. Identity systems built for permanence are now governing infrastructure built around ephemerality — and that mismatch is where the risk actually lives. Part 3 — What Actually Counts as a Machine Identity Ask ten engineers what a "machine identity" means, and you'll get ten different answers — a Kubernetes ServiceAccount, an X.509 certificate, a SPIFFE ID, an IAM role, an API key. They're all partially right, because none of those are the identity itself. They're credentials. The identity is the underlying trust relationship: can this workload prove it is who it claims to be? JWTs, mTLS certs, OAuth client credentials — the implementation changes, the question doesn't. That distinction matters because organizations that migrate between identity technologies often carry the same unsolved trust problem with them. They upgraded the credential. They never redesigned the trust. It's also worth separating machine identities into rough categories by how much blast radius they carry if compromised: infrastructure-level identities (control planes, kubelets, ingress — compromise one and you've potentially compromised everything downstream), workload identities (containers, functions — should die exactly when the workload does), pipeline identities (CI/CD runners that too often get authenticated with secrets stored permanently in a repo instead of credentials scoped to the build's lifetime), and — the newest and fastest-growing category — agent identities, which behave like workload identities with a much harder authorization problem layered on top, because what an agent decides to do next isn't fixed at deployment time the way a traditional service's behavior is. Traditional Static CredentialModern Machine IdentityIssued once, valid for months or yearsIssued per-session, valid for minutesStored (in a vault, a repo, an env var)Proven (via cryptographic attestation)Survives the workload that requested itDies when the workload dies"Where is the secret?""Can this workload prove who it is right now?" Part 4 — The Lifecycle Nobody's Actually Managing Most identity conversations start and end at authentication: mTLS or OIDC or SPIFFE or JWTs. That's one stage in a much longer journey, and it's not even the stage where things go wrong most often. A useful way to think about it — birth, attestation, authentication, authorization, rotation, revocation, death — makes clear that the hard engineering problems sit almost everywhere except the stage most teams spend their time on. Birth is where debt starts accumulating, because most organizations can't answer "who created this identity, and why does it still exist?" for a large share of their service accounts and certificates. Attestation — proving a workload is actually running where it claims to be, not just holding a valid certificate — is what separates a legitimate production pod from an attacker who's stolen its credential and is presenting it from somewhere else entirely. Rotation is where the real divergence between legacy and cloud-native infrastructure shows up: a credential that expires in five minutes represents a fundamentally different risk than one valid for a year, but only if rotation is actually automated, because organizations that fear breaking dependency chains simply... stop rotating. Revocation is the stage incident response actually depends on — can you kill trust in a compromised identity in minutes, or does it require a change-management meeting? And death is the most neglected stage of all: when a workload disappears, its identity usually doesn't, and that's exactly the mechanism behind the Klue breach. The Klue breach is worth sitting with. In June 2026, an extortion group calling itself Icarus found a Salesforce API credential that Klue — a competitive-intelligence SaaS vendor — had issued back in 2022 for what was described as a "limited pilot." Nobody had rotated it, reviewed it, or revoked it in the roughly four years since. When Icarus found it, that single forgotten token opened a path into the Salesforce environments of close to 200 companies. The confirmed victim list includes LastPass, Jamf, HackerOne, Recorded Future, Snyk, Tanium, and Huntress — several of which sell security products for a living. (Tech Insider, "Klue Data Breach 2026," July 2026) A credential provisioned for a temporary purpose outlived that purpose by four years, and nobody's lifecycle process ever flagged it. That's the mechanism this section is describing, not an indictment of Klue's diligence specifically. Part 5 — Why Secrets Are the Symptom, Not the Disease The industry has spent nearly two decades trying to make secrets safer — vaults, HSMs, rotation policies, repo scanning — and every improvement made secrets safer, not unnecessary. A secret is still a secret: copyable, leakable, forgettable, and often still valid long after the workload it was created for has been decommissioned. The numbers back this up starkly. GitGuardian's State of Secrets Sprawl 2026 — its fifth annual edition — found AI-related credential leaks surged 81.5% year over year in 2025, and that 64% of valid secrets leaked back in 2022 were still valid and exploitable years later. (NHIMG, citing GitGuardian State of Secrets Sprawl 2026) Separately, the 2026 State of AI Agent Identity Security Report found 69% of organizations still authenticate machine identities using long-lived API keys, and 61% have already had to revoke or rotate AI agent credentials specifically because of suspected exposure. (Akeyless, "The Klue Breach and the Case for Zero Standing Privileges," 2026) Every secrets manager eventually runs into what practitioners call the Secret Zero problem: Vault protects your secrets, but how does the application authenticate to Vault? Somewhere, a first credential has to exist that isn't protected by the system meant to protect everything else — and it's not uncommon to find that credential sitting in a container image or a startup script, which is exactly the irony you'd expect. This is why the industry's direction of travel isn't "better secrets management" — it's making long-lived secrets unnecessary in the first place. SPIFFE reframed the question from "which platform issued this credential" to "which workload is this," giving every workload a portable, globally unique identity independent of any single cloud vendor's naming conventions. SPIRE automates the issuance of those identities based on attestation evidence rather than manual provisioning. AWS, Google Cloud, and Microsoft Azure have all moved in the same direction with temporary IAM roles, Workload Identity Federation, and Managed Identities, respectively — different implementations, same underlying architectural bet: identity should be issued dynamically based on proof, not distributed once and trusted forever. None of this makes traditional secrets managers — HashiCorp Vault, Infisical, Akeyless — obsolete. Plenty of legacy systems, third-party integrations, and database connections still need a vault to sit in front of them. The goal isn't zero secrets. It's fewer long-lived ones, and a lot more temporary credentials issued through a trust system instead of handed out as permanent artifacts. The regulatory and industry-standards side is moving in the same direction independent of any single vendor's roadmap. The CA/Browser Forum's Ballot SC-081v3, passed 29–0 in April 2025 after a proposal from Apple, cuts the maximum public TLS certificate lifespan from 398 days down to 200 days in March 2026, 100 days in March 2027, and 47 days by March 2029 — an eightfold increase in renewal frequency that makes manual certificate handling operationally impossible and automation mandatory. (BleepingComputer, April 2025) That's not a machine-identity vendor's opinion. That's Apple, Google, Mozilla, and Microsoft, unanimously, deciding that long-lived cryptographic trust is itself the risk. Part 6 — Runtime Trust: Why Identity Alone Doesn't Finish the Job Here's the scenario that breaks the comfortable assumption: a Kubernetes workload authenticates successfully at 09:00 and receives a valid certificate. At 09:07, it's compromised through an application vulnerability. By 09:18, it's talking to systems it's never contacted before. Its certificate is still valid. Its identity is still genuine. Authentication didn't fail — trust did. Identity is mostly static; behavior is constantly dynamic, and that gap is exactly what mutual TLS doesn't close. mTLS answers "who are you" extremely well. It has nothing to say about whether you should still be trusted five minutes from now, after your behavior has changed. That's why the more mature service mesh and Zero Trust architectures — the ones built on projects like Istio, Linkerd, and Consul — increasingly treat authorization as continuous and contextual rather than a static, one-time spreadsheet decision: does this workload's current region, software version, data volume, and behavioral pattern still match what it looked like when it was authorized? AI agents make this unavoidable rather than optional. A traditional workload runs deterministic code — same input, same output, every time. An agent doesn't. It reasons, plans, and can make a materially different decision today than it made running the identical workflow yesterday. Identity alone cannot predict that. Runtime governance — continuous verification, rapid revocation, behavioral anomaly detection — becomes inseparable from agent security specifically because the agent's behavior isn't fixed at authentication time the way a traditional service's is. Part 7 — Governing Millions of Identities Is an Operating-Model Problem, Not a Tooling Problem At scale, the honest objection every security leader raises is some version of: "This sounds right in theory, but we have eight hundred thousand machine identities, not fifty." That's the correct objection, and the answer isn't another certificate platform or another secrets vault. Buying more identity-issuance tooling without fixing governance just means issuing more ungoverned identities, faster. The recurring failure mode across the incidents above is an ownership gap, not a technology gap. Developers assume platform engineering owns workload identity. Platform engineering assumes security owns policy. Security assumes IAM owns the account lifecycle. Everyone owns a slice. Nobody owns the whole thing — and that's precisely the seam where debt accumulates, silently, until something like Klue happens. A handful of measurable questions reveal whether an organization is actually managing this or just hoping it holds together: Can you automatically discover every active machine identity across every cloud account and cluster? Can every identity be traced to an owner and a documented reason it exists? Can a compromised identity be revoked in minutes rather than requiring a change-management meeting? What percentage of your machine identities still authenticate with a long-lived secret instead of a short-lived, attested credential? If those answers require several meetings and several spreadsheets to produce, the debt already exists — whether or not it's caused an incident yet. Part 8 — A Practical Reference Architecture Put the pieces from Parts 4 through 7 together and a workable shape emerges: Plain Text Workload / AI Agent requests identity │ ▼ Attestation (cloud metadata, K8s, TPM, SPIFFE) │ ▼ Identity Issuance (short-lived, scoped) │ ┌─────────────────┼─────────────────┐ ▼ ▼ ▼ Authentication Authorization Policy Engine │ │ │ └─────────────────┼─────────────────┘ ▼ Runtime Trust Evaluation (behavior, context, risk score) │ ▼ Execution + Continuous Audit │ ▼ Automated Rotation → Revocation → Death Notice that authentication is one box among many, not the architecture itself — the same lesson from Article 1's Trust Stack applies here: no step should inherit trust automatically from the step before it. A credential earns its authorization independently, every time, and it dies the moment the workload it represents does. Here's what that looks like as an actual sequence, rather than a diagram: a developer deploys a new pod into a Kubernetes cluster running SPIRE. The SPIRE agent on that node attests the workload — verifying its namespace, service account, and container image against the node's own attested identity — before issuing it a short-lived SVID (SPIFFE Verifiable Identity Document), typically valid for about an hour rather than a year. When that workload tries to reach a payment service, Istio's sidecar proxies negotiate mutual TLS using those SVIDs, so both sides authenticate each other cryptographically without either application ever touching a static secret. Istio's authorization policy then evaluates the request against the calling workload's identity and current namespace before allowing the connection through. If the workload's behavior later drifts outside its expected pattern — say, it starts querying a database it's never touched before — a runtime detection layer flags the anomaly, and the SVID can be revoked in seconds, cutting off trust without redeploying anything or touching a single line of application code. Nobody typed a password anywhere in that chain, and nothing in it depended on a secret that could sit in a repository for four years the way Klue's did. That's the practical difference between reading about workload identity and actually running it: the entire sequence — attestation, issuance, mutual authentication, policy evaluation, and revocation — happens automatically, on every connection, without a human in the loop until something goes wrong. Four principles fall out of that diagram, and they're the actual decision framework, not the diagram itself: every workload should be verifiable via evidence, not merely assumed trustworthy because it holds a credential; authorization should be evaluated continuously against current context, not granted once and left alone; trust should expire by default, with permanence as the rare exception instead of the norm; and governance has to be automated, because no security team is manually reviewing hundreds of thousands of identities on a spreadsheet cadence. Part 9 — What This Actually Costs When You Get It Wrong The Palo Alto Networks Unit 42 2026 Global Incident Response Report found that 65% of initial access in the incidents it investigated was identity-driven — attackers using stolen, over-privileged, or forgotten credentials rather than novel exploits, because logging in is quieter and more reliable than hacking in. (Palo Alto Networks, Unit 42 2026 Global Incident Response Report) That figure includes both human and machine credentials, but the report specifically flags machine identities as attractive because they're frequently over-privileged, long-lived, and inconsistently monitored — a higher-leverage, lower-noise target than a person. The financial and operational cost isn't limited to breaches, either. CyberArk's own research found over 70% of organizations experienced at least one certificate-related outage in the past year — meaning machine identity debt doesn't just create attack surface; it creates its own reliability tax even when nobody's attacking anything. (CyberArk, 2025 State of Machine Identity Security Report) Closing — The New Definition of Trust The organizations that come out ahead over the next several years won't be the ones running the largest AI agent fleets or the biggest Kubernetes clusters. They'll be the ones that can answer, in minutes rather than days, a question that's becoming the actual test of enterprise security maturity: which machine made this decision, what evidence proved its identity, which policy authorized the action, and how fast could we revoke its trust if we needed to right now? Machine Identity Debt isn't a new compliance checkbox. It's a signal — one that tells you whether your organization's trust is compounding safely or quietly decaying underneath infrastructure that looks fine on the surface. The $25 billion question Palo Alto Networks just answered is really a bet that every enterprise will eventually have to answer for itself: who's actually managing the identities that now outnumber your employees 82 to 1? All incident details, statistics, and dates reflect publicly disclosed research current as of July 2026, with sources linked inline.
AI Assist vs AI Complete: The Real Gap in Most AI Workflows Today
August 13, 2026 by
Incident Management and the Rise of AI SRE Agents
August 11, 2026
by
CORE
I Got Tired of Copy-Pasting Microfrontend Boilerplate, So I Built a Bridge
August 10, 2026 by
Benchmark LangGraph, Strands, OpenAI Agents, and Google ADK on the Same Agent Graph
August 13, 2026 by
AI Assist vs AI Complete: The Real Gap in Most AI Workflows Today
August 13, 2026 by
Orchestrating Small Language Models Without Losing Events or Context
August 13, 2026 by
Why AWS and Azure Handle Data Perimeter Differently
August 13, 2026 by
Zone-Aware Routing in Kubernetes: Reducing Latency, Improving Resilience, and Lowering Cloud Costs
August 13, 2026 by
From Microservices to Agent Services: The Next Architectural Shift
August 12, 2026 by
Benchmark LangGraph, Strands, OpenAI Agents, and Google ADK on the Same Agent Graph
August 13, 2026 by
Orchestrating Small Language Models Without Losing Events or Context
August 13, 2026 by
Why AWS and Azure Handle Data Perimeter Differently
August 13, 2026 by
Why AWS and Azure Handle Data Perimeter Differently
August 13, 2026 by
Zone-Aware Routing in Kubernetes: Reducing Latency, Improving Resilience, and Lowering Cloud Costs
August 13, 2026 by
Why Traditional Cloud Infrastructure Breaks AI Workloads in Production
August 11, 2026 by
Benchmark LangGraph, Strands, OpenAI Agents, and Google ADK on the Same Agent Graph
August 13, 2026 by
AI Assist vs AI Complete: The Real Gap in Most AI Workflows Today
August 13, 2026 by
Orchestrating Small Language Models Without Losing Events or Context
August 13, 2026 by